diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index 98336357b..93744b411 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -184,7 +184,13 @@ extension ServiceLifecycleCoordinator { _ = 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 @@ -1042,6 +1048,9 @@ extension ServiceLifecycleCoordinator { } } + /// 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 } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift index e3928e1d8..06fa78984 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift @@ -60,11 +60,12 @@ enum SessionCapsRuntimeSupport { /// 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" diff --git a/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift b/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift index 76898d7f5..c6ff54a07 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift @@ -42,7 +42,6 @@ struct SessionCapsSettingsSection: View { @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? @@ -71,68 +70,24 @@ struct SessionCapsSettingsSection: View { .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.") @@ -185,6 +140,67 @@ struct SessionCapsSettingsSection: View { } } + /// 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 { + 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( + 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) } } + .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 { @@ -304,7 +320,6 @@ struct SessionCapsSettingsSection: View { 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 } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index 28fadade6..073287d5b 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift @@ -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 diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index 72f3eed6d..8bfa663df 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift @@ -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) { @@ -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) @@ -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)) } diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift index ddd67f61f..b2045d52c 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift @@ -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)) diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index b82058cec..ef7c525c5 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -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 @@ -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 diff --git a/docs/bugs/2026-11-07-session-startup-retired-during-caps-fleet-write.md b/docs/bugs/2026-11-07-session-startup-retired-during-caps-fleet-write.md new file mode 100644 index 000000000..976ee7f85 --- /dev/null +++ b/docs/bugs/2026-11-07-session-startup-retired-during-caps-fleet-write.md @@ -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. diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index bd0b8d5c6..270781316 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -12,15 +12,27 @@ KeyPath applies the same rule to every compatible connected keyboard by default. There is no keyboard to choose before the rule works. A keyboard connected later is set up automatically once all keys are released. -If you previously chose one keyboard for Caps Lock, KeyPath keeps that choice -when you upgrade. Use **Settings → General → Caps Lock options → Use Caps Lock -rules on all keyboards** to switch to the new default. You can also choose -**Use on one keyboard only** there and select a keyboard from the picker. +The switch beside **Settings → General → Caps Lock rules** turns Caps Lock +rules on or off for every keyboard. To limit them to a single keyboard, choose +**Use on one keyboard only…** below the keyboard list and pick it from the menu. +**Use on All Keyboards** returns to the default. A one-keyboard choice lasts +until you restart your Mac. After a restart, KeyPath asks you to choose the +keyboard again. + +```text +Caps Lock rules [ on ] +On · all compatible keyboards · Uses F18 behind the scenes + Use on + [x] cl80 + [x] Built-in keyboard + Use on one keyboard only… +``` ## Make an exception for one keyboard -Open **Settings → General → Caps Lock rules → Caps Lock options**. Turn -off **Use on [keyboard name]** to leave that keyboard's Caps Lock key alone. +Open **Settings → General → Caps Lock rules**. When more than one keyboard is +connected, each one has a checkbox. Clear a keyboard's checkbox to leave its +Caps Lock key alone. Other keyboards continue using the rule. Turn it back on to resume. The exception follows a keyboard reconnected at the same port, with the same serial number when one is available. Moving it to another port makes it a new