Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,13 @@
_ = await stopSessionRuntime()
return false
}
for _ in 0 ..< 40 {
// Startup includes writing the Caps Lock mapping to every keyboard
// interface, several hidutil runs each. With a few keyboard-class
// devices attached that takes longer than four seconds, and an
// iteration count retired healthy workers mid-write. A worker that
// fails or exits still ends this wait at once.
let startupDeadline = ContinuousClock.now + Self.sessionStartupTimeout
while ContinuousClock.now < startupDeadline {
guard sessionStartIsCurrent(generation) else {
_ = await stopSessionRuntime()
return false
Expand Down Expand Up @@ -554,7 +560,7 @@
if let terminalReport {
AppLogger.shared
.warnUnlessQuietTest(
"[SessionRuntime] Session ended: state=\(terminalReport.state) failure=\(terminalReport.failure ?? "none") retirementReason=\(terminalReport.retirementReason ?? "none") at=\(terminalReport.timestamp)"

Check warning on line 563 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 220 characters (line_length)
)
} else {
AppLogger.shared.warnUnlessQuietTest("[SessionRuntime] Session ended without a terminal report")
Expand Down Expand Up @@ -664,7 +670,7 @@
continue
}
#endif
do { try await Task.sleep(for: .milliseconds(250)) }

Check warning on line 673 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { return false }
}
return false
Expand Down Expand Up @@ -735,7 +741,7 @@
case SessionCapsRuntimeSupport.keyboardDisconnectedReason:
return "The keyboard chosen for Caps Lock was disconnected. Choose a keyboard again in Settings > General."
case SessionCapsRuntimeSupport.ownershipLostReason:
return "KeyPath could no longer verify its Caps Lock mapping on the chosen keyboard, so it stopped and restored it. If another app changed Caps Lock, or a second identical keyboard is connected, fix that, then quit and reopen KeyPath."

Check warning on line 744 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 247 characters (line_length)
case "tap-disabled-by-timeout":
return "macOS stopped keyboard capture because it timed out. Quit and reopen KeyPath to recover."
default:
Expand Down Expand Up @@ -854,7 +860,7 @@
} else if SessionCapsRuntimeSupport.selectedKeyboardOnly(),
let selection = try await chooseCapsSelection()
{
passed = try String(decoding: JSONEncoder().encode(selection), as: UTF8.self)

Check warning on line 863 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Prefer failable `String(bytes:encoding:)` initializer when converting `Data` to `String` (optional_data_string_conversion)
sessionCapsIntermediate = selection.intermediateUsage
}
configuration.environment = [SessionCapsRuntimeSupport.selectionEnvironmentKey: passed]
Expand All @@ -878,7 +884,7 @@
$0.replacingOccurrences(of: $1, with: "[redacted-path]")
}.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
AppLogger.shared.log(
"Session application launch failed parentPID=\(getpid()) nonce=\(nonce) capabilitiesOnly=\(capabilitiesOnly) domain=\(launchError.domain.prefix(128)) code=\(launchError.code) description=\(description.prefix(256))"

Check warning on line 887 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 230 characters (line_length)
)
try? FileManager.default.removeItem(at: directory)
throw error
Expand Down Expand Up @@ -1042,6 +1048,9 @@
}
}

/// How long a launched worker may stay in `starting` before it is retired.
static let sessionStartupTimeout: Duration = .seconds(20)

/// The reloaded profile no longer uses Caps Lock; nothing is paused.
func clearCapsPaused() {
guard sessionCapsPaused != nil || sessionCapsDisconnectedName != nil else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@
/// Advanced compatibility mode. The normal rule scope is every keyboard.
static let selectedKeyboardOnlyKey = "keypath.session.caps.selected.only"

/// Preserve the scope of an existing exact-device selection on upgrade.
/// A fresh installation has no selection and starts in all-keyboards mode.
/// One-keyboard scope is opt-in only. A saved selection on its own does
/// not imply it: a choice left over from before all-keyboards mode
/// otherwise pinned that Mac to one keyboard, and because a selection
/// expires at every restart, paused Caps Lock rules after each reboot.
static func selectedKeyboardOnly(defaults: UserDefaults = .standard) -> Bool {
if let explicit = defaults.object(forKey: selectedKeyboardOnlyKey) as? Bool { return explicit }
return defaults.data(forKey: selectionKey) != nil
defaults.bool(forKey: selectedKeyboardOnlyKey)
}

static let allIntermediateKey = "keypath.session.caps.all.intermediate"
Expand Down Expand Up @@ -339,7 +340,7 @@
var length = bytes.count
guard sysctlbyname("kern.bootsessionuuid", &bytes, &length, nil, 0) == 0,
length > 1, length <= bytes.count else { throw Refusal.bootIdentity }
let value = String(decoding: bytes.prefix(length - 1).map { UInt8(bitPattern: $0) }, as: UTF8.self)

Check warning on line 343 in Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift

View workflow job for this annotation

GitHub Actions / quality-full

Prefer failable `String(bytes:encoding:)` initializer when converting `Data` to `String` (optional_data_string_conversion)
guard UUID(uuidString: value) != nil else { throw Refusal.bootIdentity }
return value
}
Expand Down Expand Up @@ -434,9 +435,9 @@

static func recoverIndependent(legacy: () throws -> Void, fleet: () throws -> Void) throws {
var firstFailure: Error?
do { try legacy() }

Check warning on line 438 in Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift

View workflow job for this annotation

GitHub Actions / quality-full

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { firstFailure = error }
do { try fleet() }

Check warning on line 440 in Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift

View workflow job for this annotation

GitHub Actions / quality-full

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch {
if firstFailure == nil { firstFailure = error }
}
Expand Down
139 changes: 77 additions & 62 deletions Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
@State private var excludedIDs = SessionCapsRuntimeSupport.excludedKeyboardIDs()
@State private var automaticDisabled = UserDefaults.standard.bool(forKey: SessionCapsRuntimeSupport.automaticSetupDisabledKey)
@State private var selectedOnly = SessionCapsRuntimeSupport.selectedKeyboardOnly()
@State private var showAdvanced = false
@State private var choosingOneKeyboard = false
@State private var busy = false
@State private var message: String?
Expand Down Expand Up @@ -71,68 +70,24 @@
.settingsAction()
.accessibilityIdentifier("settings-caps-check-again")
}
Toggle("Caps Lock rules", isOn: Binding(
get: { !automaticDisabled },
set: { enabled in Task { await setEnabled(enabled) } }
))
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.regular)
.disabled(busy)
.accessibilityIdentifier("settings-caps-toggle")
.accessibilityLabel("Caps Lock rules")
}
DisclosureGroup("Caps Lock options", isExpanded: $showAdvanced) {
VStack(alignment: .leading, spacing: SettingsDesign.labelGap) {
if selectedOnly {
Button("Use Caps Lock rules on all keyboards") {
Task { await setSelectedOnly(false) }
}
.disabled(busy)
.accessibilityIdentifier("settings-caps-use-all-keyboards")
} else if choosingOneKeyboard {
Button("Cancel choosing a keyboard") {
choosingOneKeyboard = false
message = nil
}
.accessibilityIdentifier("settings-caps-cancel-keyboard-choice")
} else {
ForEach(keyboards) { keyboard in
let id = SessionCapsRuntimeSupport.preferenceID(keyboard.device)
Toggle("Use on \(keyboard.name)", isOn: Binding(
get: { !excludedIDs.contains(id) },
set: { enabled in Task { await setKeyboardEnabled(id, enabled: enabled) } }
))
.disabled(busy || automaticDisabled)
.accessibilityIdentifier("settings-caps-keyboard-\(keyboard.id)")
}
Button("Use on one keyboard only") {
Task { await setSelectedOnly(true) }
}
.disabled(busy || keyboards.isEmpty)
.accessibilityIdentifier("settings-caps-use-one-keyboard")
}

if selectedOnly || choosingOneKeyboard {
Picker("Keyboard", selection: Binding(
get: { choosingOneKeyboard ? nil : (choices.pending?.registryEntryID ?? device?.registryEntryID) },
set: { id in
guard let chosen = keyboards.first(where: { $0.id == id }) else { return }
Task { await choose(chosen.device) }
}
)) {
if device == nil || choosingOneKeyboard { Text("Choose a keyboard").tag(UInt64?.none) }
ForEach(keyboards) { keyboard in
Text(keyboard.name).tag(UInt64?.some(keyboard.id))
}
}
.pickerStyle(.menu)
.fixedSize()
.disabled(busy)
.accessibilityIdentifier("settings-caps-keyboard-picker")
.accessibilityHint("Caps Lock rules apply to the keyboard you choose")
}

Toggle("Enable Caps Lock rules", isOn: Binding(
get: { !automaticDisabled },
set: { enabled in Task { await setEnabled(enabled) } }
))
.disabled(busy)
.accessibilityIdentifier("settings-caps-toggle")
}
.padding(.leading, 20 + SettingsDesign.rowGap)
// Shown in place, never behind a disclosure: with one keyboard there
// is nothing to choose, and with several the choices are visible.
if !automaticDisabled {
keyboardScope
.padding(.leading, 20 + SettingsDesign.rowGap)
.padding(.top, SettingsDesign.labelGap * 3)
}
.accessibilityIdentifier("settings-caps-advanced")
if !automaticDisabled, withheld > 0 {
Text(withheld == 1 ? "One connected keyboard can\u{2019}t be used for Caps Lock rules."
: "\(withheld) connected keyboards can\u{2019}t be used for Caps Lock rules.")
Expand Down Expand Up @@ -185,6 +140,67 @@
}
}

/// Which keyboards Caps Lock rules use. All keyboards is the default, so a
/// single keyboard shows nothing here; several get a checkbox each.
@ViewBuilder
private var keyboardScope: some View {
if selectedOnly || choosingOneKeyboard {
HStack(spacing: SettingsDesign.optionGap) {
Picker("Keyboard", selection: Binding(
get: { choosingOneKeyboard ? nil : (choices.pending?.registryEntryID ?? device?.registryEntryID) },
set: { id in
guard let chosen = keyboards.first(where: { $0.id == id }) else { return }
Task { await choose(chosen.device) }
}
)) {
if device == nil || choosingOneKeyboard { Text("Choose a keyboard").tag(UInt64?.none) }
ForEach(keyboards) { keyboard in
Text(keyboard.name).tag(UInt64?.some(keyboard.id))
}
}
.pickerStyle(.menu)
.fixedSize()
.disabled(busy)
.accessibilityIdentifier("settings-caps-keyboard-picker")
.accessibilityHint("Caps Lock rules apply to the keyboard you choose")
Spacer()
if selectedOnly {
Button("Use on All Keyboards") { Task { await setSelectedOnly(false) } }
.settingsAction()
.disabled(busy)
.accessibilityIdentifier("settings-caps-use-all-keyboards")
} else {
Button("Cancel") {
choosingOneKeyboard = false
message = nil
}
.settingsAction()
.accessibilityIdentifier("settings-caps-cancel-keyboard-choice")
}
}
} else if keyboards.count > 1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the remaining keyboard's exclusion control accessible

If a user unchecks one keyboard while two are connected and then disconnects the other, this condition hides the remaining keyboard's checkbox even though its exclusion is still persisted. SessionRuntimeWorker continues filtering that device out using excludedKeyboardIDs(), and toggling the main Caps Lock switch does not clear those exclusions. Consequently, Caps Lock rules remain off on the only connected keyboard, with no way to re-enable it in Settings without connecting another keyboard. Show the scope controls when a connected keyboard is excluded, even if only one remains.

Useful? React with 👍 / 👎.

VStack(alignment: .leading, spacing: 6) {
Text("Use on").font(.caption).foregroundStyle(.secondary)
ForEach(keyboards) { keyboard in
let id = SessionCapsRuntimeSupport.preferenceID(keyboard.device)
Toggle(keyboard.name, isOn: Binding(

Check warning on line 186 in Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift

View workflow job for this annotation

GitHub Actions / quality-full

Interactive UI element (Button/Toggle/Picker) should have .accessibilityIdentifier() modifier. See ACCESSIBILITY_COVERAGE.md (require_accessibility_identifier)
get: { !excludedIDs.contains(id) },
set: { enabled in Task { await setKeyboardEnabled(id, enabled: enabled) } }
))
.toggleStyle(.checkbox)
.disabled(busy)
.accessibilityIdentifier("settings-caps-keyboard-\(keyboard.id)")
}
Button("Use on one keyboard only…") { Task { await setSelectedOnly(true) } }

Check warning on line 194 in Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift

View workflow job for this annotation

GitHub Actions / quality-full

Interactive UI element (Button/Toggle/Picker) should have .accessibilityIdentifier() modifier. See ACCESSIBILITY_COVERAGE.md (require_accessibility_identifier)
.buttonStyle(.link)
.font(.caption)
.disabled(busy)
.padding(.top, 2)
.accessibilityIdentifier("settings-caps-use-one-keyboard")
}
}
}

/// With a withheld keyboard present, even one eligible keyboard is an
/// explicit choice, matching automatic setup.
private var showsPicker: Bool {
Expand Down Expand Up @@ -304,7 +320,6 @@
guard !busy, only != selectedOnly || choosingOneKeyboard else { return }
if only {
choosingOneKeyboard = true
showAdvanced = true
message = "Choose the keyboard whose Caps Lock key should use these rules."
return
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ extension StatusSettingsTabView {
}
}

/// Everything else is healthy and only Caps Lock rules are paused. The
/// setup wizard knows nothing about Caps Lock, so "Fix it" must open
/// Settings > General, where the Caps Lock controls live.
var capsPausedIsOnlyIssue: Bool {
guard isDriverless, kanataManager.capsPausedMessage != nil,
let context = systemContext, context.services.kanataRunning, context.services.isHealthy,
tcpConfigured != false, permissionSnapshot?.isSystemReady ?? false
else { return false }
return !wizardIssues.contains { $0.severity == .critical || $0.severity == .error }
}

var overallHealthLevel: OverallHealthLevel {
// The user deliberately switched the service off. This is a calm,
// intentional state — not a fault — so it takes precedence over both the
Expand Down
14 changes: 12 additions & 2 deletions Sources/KeyPathAppKit/UI/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ struct StatusSettingsTabView: View {
userDisabledService && !effectiveServiceRunning
}

/// The wizard repairs installation and permissions. A paused Caps Lock
/// rule is fixed in Settings > General instead.
private func fixSystemIssues() {
if capsPausedIsOnlyIssue {
NotificationCenter.default.post(name: .openSettingsGeneral, object: nil)
} else {
wizardInitialPage = .summary
}
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Expand All @@ -90,7 +100,7 @@ struct StatusSettingsTabView: View {
.fill(systemHealthTint.opacity(0.15))
.frame(width: 80, height: 80)

Button(action: { wizardInitialPage = .summary }) {
Button(action: fixSystemIssues) {
Image(systemName: systemHealthIcon)
.font(.largeTitle)
.foregroundColor(systemHealthTint)
Expand Down Expand Up @@ -148,7 +158,7 @@ struct StatusSettingsTabView: View {
.accessibilityIdentifier("status-remove-legacy-service-button")
.accessibilityLabel("Remove the KeyPath 1.0.1 keyboard service")
} else if !isSystemHealthy, !isIntentionallyDisabled {
Button(action: { wizardInitialPage = .summary }) {
Button(action: fixSystemIssues) {
Label("Fix it", systemImage: "wand.and.stars")
.font(.body.weight(.semibold))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ final class SessionCapsMappingFleetTests: XCTestCase {
}
}

func testExistingSelectionKeepsSingleKeyboardScopeUntilExplicitlyChanged() {
func testSingleKeyboardScopeIsOptInOnly() {
let domain = "caps-fleet-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
XCTAssertFalse(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults))
// A leftover selection alone must not pin the Mac to one keyboard.
defaults.set(Data("legacy".utf8), forKey: SessionCapsRuntimeSupport.selectionKey)
XCTAssertFalse(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults))
defaults.set(true, forKey: SessionCapsRuntimeSupport.selectedKeyboardOnlyKey)
XCTAssertTrue(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults))
defaults.set(false, forKey: SessionCapsRuntimeSupport.selectedKeyboardOnlyKey)
XCTAssertFalse(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults))
Expand Down
21 changes: 15 additions & 6 deletions docs/architecture/all-keyboards-caps.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ The original prototype tracked exactly one device. Everything downstream inherit
- Automatic selection required exactly one eligible device and nothing withheld.

Any other inventory paused Caps Lock rules until the user picked a keyboard.
The fleet implementation replaced that default while retaining a selected-only mode
for users who already made a choice.
The fleet implementation replaced that default, keeping a selected-only mode
as an explicit opt-in.

### What that costs

Expand Down Expand Up @@ -237,12 +237,21 @@ explicit user choice, and nothing stops working without saying so. KeyPath never
its pair ahead of the user's. Mappings on other source keys are kept and restored byte
for byte, which A4 showed is safe.

**Migration.** An existing `SessionCapsSelection` keeps the advanced
"only this keyboard" scope. Users who never chose one get all keyboards.
**Migration.** None. One-keyboard scope is opt-in only, through
`selectedKeyboardOnlyKey`. A saved `SessionCapsSelection` on its own no longer
implies that scope. The earlier rule kept any Mac with a pre-fleet selection in
one-keyboard mode. Because a selection is bound to its boot session, that Mac then
paused Caps Lock rules after every restart whenever it had more than one
keyboard-class device attached, for example a keyboard plus a mouse with a
keyboard interface. KeyPath has no installed base to preserve, so the fallback
was removed (2026-11-07).
`automaticSetupDisabledKey` keeps its meaning: no automatic Caps setup at all.

**UI.** Settings shows keyboard coverage, per-keyboard exceptions, and the
selected-only option under Caps Lock options. The tour no longer requires a keyboard
**UI.** Settings shows keyboard coverage inline: an on/off switch on the Caps
Lock rules row, a checkbox per keyboard when several are connected, and the
selected-only option below them. Nothing is behind a disclosure group. When the
only problem is paused Caps Lock rules, the Status tab's "Fix it" opens
Settings > General instead of the setup wizard. The tour no longer requires a keyboard
selection step.

## Follow-up questions and results
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Session startup retired healthy workers during the Caps Lock fleet write

**Date:** 2026-11-07
**Area:** driverless session runtime, all-keyboards Caps Lock

## Symptom

With a Caps Lock rule enabled, KeyPath on the mini never finished starting.
The log repeated:

```
[SessionRuntime] Startup retired state=starting reason=none failure=none
[SessionWorker] Received signal 15; retiring
```

The wizard then opened with "Start driverless remapping" as the only issue.

## Cause

The parent waited a fixed 40 iterations of 100 ms for the worker to report
`running`. In all-keyboards mode the worker first writes the Caps Lock mapping
to every keyboard interface. For each device it reads the current rows,
writes, and reads back, each through a separate `hidutil` run. The mini had a
cl80, an X2A mouse with a keyboard interface, and the KeyPath physical HID
fixture attached. Acquisition took about 5.1 s, so every attempt was retired
mid-write. A stack sample showed the worker in
`SessionCapsMappingFleet.acquire`, waiting on `hidutil`, not hung.

The single-keyboard path writes one device and stayed under the limit, which is
why this appeared only once that Mac moved to all-keyboards mode.

## Fix

The startup wait is time-bounded (`sessionStartupTimeout`, 20 s) instead of
counted. A worker that reports `failed` or `secureInput`, or exits, still ends
the wait at once.

## Follow-up

Batching the per-device `hidutil` runs would shorten startup itself. A worker
that reports progress during acquisition would also let the parent tell a slow
start from a hung one.
Loading
Loading