From 7b87d489fad5afde31cfa8e1452bc51c81cd6ac6 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 20:20:53 -0800 Subject: [PATCH 01/14] Add an in-app upgrade path from KeyPath 1.0.1 (#1353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session runtime refuses to start beside 1.0.1's root kanata daemon, and until now the app offered no way to remove it. Settings → Status now offers Remove Old Service when that refusal stands. It calls InstallerEngine.removeLegacyPrivilegedRuntime (also reachable as AutoFixAction.removeLegacyPrivilegedRuntime), which runs inside the installer transaction: best-effort SMAppService unregistration, then one administrator batch that boots out every com.keypath.* label 1.0.1 used, stops root KeyPath processes the app cannot signal (ADR-040), and deletes KeyPath's own system files. Success requires a fresh read-only observation with no loaded legacy service, no root KeyPath process, and no legacy file. An unreadable service state never counts as gone. A cancelled or failed batch is never success. The Karabiner VirtualHID driver is reported, not removed, since Karabiner-Elements shares it; the user is told what remains and pointed to a new guide. After removal the daemon evidence caches are invalidated so the refusal clears on fresh evidence, then KeyPath starts. This is not a recipe: privileged recipes stay unavailable, and a forged plan naming the removal is refused. The clean-VM run from 1.0.1 is written up in docs/testing/upgrade-from-1-0-1.md and has not been run (the mini is offline). Co-Authored-By: Claude Opus 5.5 --- .../Managers/LegacyServiceRegistrations.swift | 42 +++ ...eLifecycleCoordinator+SessionRuntime.swift | 115 ++++-- .../Services/Kanata/KanataDaemonService.swift | 12 + .../Services/MainAppStateController.swift | 55 ++- .../Settings/SettingsView+StatusDetails.swift | 38 +- .../UI/Settings/SettingsView.swift | 47 ++- .../WizardProtocolConformances.swift | 3 + .../Core/AutoFixActionDescriptions.swift | 2 + .../Core/InstallerEngine+Recipes.swift | 2 + .../Core/InstallerEngine.swift | 78 +++- .../Core/InstallerRecipeID.swift | 1 + .../Core/LegacyPrivilegedRuntimeCleanup.swift | 345 ++++++++++++++++++ .../Core/WizardAsyncOperationManager.swift | 2 + .../UI/InstallationWizardView+Actions.swift | 3 + .../WizardDependencies.swift | 4 + Sources/KeyPathWizardCore/WizardTypes.swift | 21 +- .../LegacyPrivilegedRuntimeCleanupTests.swift | 303 +++++++++++++++ .../MainAppStateControllerTests.swift | 18 +- .../SessionFailureRecoveryMessageTests.swift | 11 + .../SessionLegacyDaemonRefusalTests.swift | 20 +- ...adr-045-retire-privileged-runtime-stack.md | 14 + docs/testing/upgrade-from-1-0-1.md | 68 ++++ guides/upgrading-from-1-0-1.md | 53 +++ 23 files changed, 1189 insertions(+), 68 deletions(-) create mode 100644 Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift create mode 100644 Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift create mode 100644 Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift create mode 100644 docs/testing/upgrade-from-1-0-1.md create mode 100644 guides/upgrading-from-1-0-1.md diff --git a/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift b/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift new file mode 100644 index 000000000..9d595eb88 --- /dev/null +++ b/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift @@ -0,0 +1,42 @@ +import KeyPathCore +import KeyPathInstallationWizard +import ServiceManagement + +/// Drops the SMAppService registrations KeyPath 1.0.1 made for its kanata daemon +/// and helper (#1353). Migration only: this never registers anything. +/// +/// Best effort, and it needs no administrator prompt. Current builds no longer +/// ship those plists, so ServiceManagement often cannot resolve them; the root +/// cleanup batch boots the jobs out regardless. Success is judged by that +/// batch's verification, never by these calls. +@MainActor +enum LegacyServiceRegistrations { + static func unregister() async -> [String] { + await unregister( + statusFor: { await SystemStateProvider.shared.freshSMAppServiceStatus(for: $0) }, + service: { HelperManager.smServiceFactory($0) } + ) + } + + static func unregister( + statusFor: (String) async -> SMAppService.Status, + service: (String) -> SMAppServiceProtocol + ) async -> [String] { + var logs: [String] = [] + for plistName in LegacyPrivilegedRuntime.serviceManagementPlists { + let initial = await statusFor(plistName) + guard initial == .enabled || initial == .requiresApproval else { + logs.append("[legacy-cleanup] \(plistName): not registered with ServiceManagement") + continue + } + do { + try await service(plistName).unregister() + logs.append("[legacy-cleanup] \(plistName): unregistered") + } catch { + logs.append("[legacy-cleanup] \(plistName): unregister failed (\(error.localizedDescription)); removing as administrator") + } + await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: plistName) + } + return logs + } +} diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index be6d7afd0..f70188c04 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -6,7 +6,9 @@ import KeyPathPermissions extension ServiceLifecycleCoordinator { func currentSessionReport() -> SessionRuntimeReport? { #if DEBUG - if let report = testSessionCurrentReport { return report() } + if let report = testSessionCurrentReport { + return report() + } #endif guard let application = sessionApplication, !application.isTerminated, let url = sessionReportURL, let nonce = sessionNonce, @@ -23,7 +25,9 @@ extension ServiceLifecycleCoordinator { } func sessionCapabilities() async -> PermissionOracle.PermissionSet? { - if let report = currentSessionReport() { return Self.permissionSet(report) } + if let report = currentSessionReport() { + return Self.permissionSet(report) + } guard !startsSuppressed else { return nil } // Capability checks launch a separate copy of the app too. Serialize // them with stop so uninstall waits for existing probes before trashing @@ -39,14 +43,18 @@ extension ServiceLifecycleCoordinator { // An owned runtime may finish starting while the independent probe is // suspended. Revalidate ownership/freshness before publishing the older // probe result (including a missing result) to the permission cache. - if let report = currentSessionReport() { return Self.permissionSet(report) } + if let report = currentSessionReport() { + return Self.permissionSet(report) + } return probedPermissions } private func probeSessionCapabilities() async -> PermissionOracle.PermissionSet? { guard !startsSuppressed, !Task.isCancelled else { return nil } #if DEBUG - if let probe = testSessionCapabilityProbe { return await probe() } + if let probe = testSessionCapabilityProbe { + return await probe() + } #endif do { let (application, url, nonce) = try await launchSessionProcess(capabilitiesOnly: true) @@ -64,16 +72,23 @@ extension ServiceLifecycleCoordinator { // Never let that become a process-group/broadcast signal target. // Signal only the independently launched process owned by this call. let probePID = application.processIdentifier - if probePID > 0, !application.isTerminated { kill(probePID, SIGTERM) } + if probePID > 0, !application.isTerminated { + kill(probePID, SIGTERM) + } } catch { AppLogger.shared.warn("Session permission report unavailable") } return nil } + static let legacyDaemonRefusalMessage = + "The keyboard service from KeyPath 1.0.1 is still running. Remove it to start KeyPath." + private func isLegacyDaemonRunning() async -> Bool { #if DEBUG - if let probe = testSessionLegacyDaemonRunning { return await probe() } + if let probe = testSessionLegacyDaemonRunning { + return await probe() + } #endif return await kanataDaemonService.isDaemonRunning() } @@ -82,14 +97,26 @@ extension ServiceLifecycleCoordinator { /// does not keep blocking the UI until the next start. func legacyDaemonRefusalForStartup() async -> String? { guard sessionLegacyDaemonRefusal != nil else { return nil } - if let report = currentSessionReport(), report.state == .running, report.tapActive { return nil } + if let report = currentSessionReport(), report.state == .running, report.tapActive { + return nil + } let generation = sessionIntentGeneration let legacyRunning = await isLegacyDaemonRunning() // A newer request owns the refusal now, and this reading predates it. - if !legacyRunning, generation == sessionIntentGeneration { clearLegacyDaemonRefusal() } + if !legacyRunning, generation == sessionIntentGeneration { + clearLegacyDaemonRefusal() + } return sessionLegacyDaemonRefusal } + /// Re-reads the legacy daemon with fresh evidence after the 1.0.1 stack was + /// removed, clearing the refusal only if it is really gone. Returns whether a + /// refusal still stands. + func refreshLegacyDaemonRefusalAfterCleanup() async -> Bool { + await kanataDaemonService.invalidateEvidenceCaches() + return await legacyDaemonRefusalForStartup() != nil + } + /// The published error goes too, or Settings would keep naming a stopped daemon. func clearLegacyDaemonRefusal() { guard sessionLegacyDaemonRefusal != nil else { return } @@ -116,7 +143,7 @@ extension ServiceLifecycleCoordinator { let legacyRunning = await isLegacyDaemonRunning() guard sessionStartIsCurrent(generation) else { return false } sessionLegacyDaemonRefusal = legacyRunning - ? "Stop the existing keyboard service before trying driverless mode." : nil + ? Self.legacyDaemonRefusalMessage : nil if let sessionLegacyDaemonRefusal { onError?(sessionLegacyDaemonRefusal) onStateChanged?() @@ -193,9 +220,13 @@ extension ServiceLifecycleCoordinator { ) return true } - if report.state == .failed || report.state == .secureInput { break } + if report.state == .failed || report.state == .secureInput { + break + } + } + if application.isTerminated { + break } - if application.isTerminated { break } try await Task.sleep(for: .milliseconds(100)) } let terminalReport = sessionReportURL.flatMap(Self.readSessionReport) @@ -232,12 +263,21 @@ extension ServiceLifecycleCoordinator { } if scheduleInterruptedStartupRecovery(terminalReport, nonce: nonce, pid: application.processIdentifier, - generation: generation, cleanedUp: cleanedUp) { return false } + generation: generation, cleanedUp: cleanedUp) + { + return false + } let failure = terminalReport?.failure.map(Self.sessionFailureRecoveryMessage) - if sessionStartIsCurrent(generation) { onError?("Driverless runtime could not start: \(failure ?? "no current tap and TCP evidence")") } + if sessionStartIsCurrent(generation) { + onError?("Driverless runtime could not start: \(failure ?? "no current tap and TCP evidence")") + } } catch { - if didLaunch { _ = await stopSessionRuntime() } - if sessionStartIsCurrent(generation) { onError?("Driverless runtime launch failed: \(error.localizedDescription)") } + if didLaunch { + _ = await stopSessionRuntime() + } + if sessionStartIsCurrent(generation) { + onError?("Driverless runtime launch failed: \(error.localizedDescription)") + } } onStateChanged?() return false @@ -299,9 +339,13 @@ extension ServiceLifecycleCoordinator { // The worker is dead: release exclusion before normal journal recovery. sessionTerminationGuard?.release() sessionTerminationGuard = nil - if let application { _ = recoverSessionOutputs(for: application) } + if let application { + _ = recoverSessionOutputs(for: application) + } guard await restoreSessionCaps(for: application) else { - if let application { scheduleSessionCleanup(application, nonce: nonce) } + if let application { + scheduleSessionCleanup(application, nonce: nonce) + } return false } if let reportURL { @@ -338,7 +382,9 @@ extension ServiceLifecycleCoordinator { guard !application.isTerminated else { return } _ = Self.signalSessionProcess(pid: application.processIdentifier, signal: SIGKILL) for _ in 0 ..< 40 { - if application.isTerminated { return } + if application.isTerminated { + return + } await waitForSessionTermination() } } @@ -360,7 +406,9 @@ extension ServiceLifecycleCoordinator { await (try? sessionOperationGate.withOperation { @MainActor [self] _ in guard sessionNonce == nonce, let application = sessionApplication, application.processIdentifier == pid else { return false } - if !application.isTerminated { await terminateIdleSessionWorker(application, nonce: nonce) } + if !application.isTerminated { + await terminateIdleSessionWorker(application, nonce: nonce) + } guard application.isTerminated else { return true } return await !stopSessionRuntime() }) ?? true @@ -383,10 +431,14 @@ extension ServiceLifecycleCoordinator { sendTermination: () -> Void, wait: () async -> Void) async -> Bool { - if isTerminated() { return true } + if isTerminated() { + return true + } sendTermination() for _ in 0 ..< 600 { - if isTerminated() { return true } + if isTerminated() { + return true + } await wait() } return isTerminated() @@ -606,7 +658,9 @@ extension ServiceLifecycleCoordinator { if blocker == nil { AppLogger.shared.info("[SessionRuntime] Resuming after keyboard readiness") sessionWakeRetryGeneration = nil - if await resumeSessionRuntime(expectedGeneration: expectedGeneration, reason: "System wake") { return true } + if await resumeSessionRuntime(expectedGeneration: expectedGeneration, reason: "System wake") { + return true + } guard sessionStartIsCurrent(expectedGeneration), sessionWakeRetryGeneration == expectedGeneration else { return false } AppLogger.shared.info("[SessionRuntime] Wake startup interrupted; retrying after readiness checks") @@ -639,7 +693,10 @@ extension ServiceLifecycleCoordinator { // startup has installed its supervisor. Preserve the same recovery loop // after verified cleanup, just as for a held-key activation race. if report.state == .stopped || report.state == .failed, - report.recoveryReason == "system-wake-observed" { return true } + report.recoveryReason == "system-wake-observed" + { + return true + } return report.state == .failed && report.failure == "managed-caps-activation-refused: physicalKeysHeld" } @@ -649,14 +706,18 @@ extension ServiceLifecycleCoordinator { static func capsConflictMessage(conflict: SessionCapsConflict?, raycast: SessionCapsConflict.RaycastHyperKey?) -> String { - if let conflict { return conflict.message } + if let conflict { + return conflict.message + } return (raycast?.enabled == true && raycast?.keyCode == 57 ? SessionCapsConflict.raycastHyperKey : .unknown(destination: 0)).message } private func sessionWakeReadinessBlocker() -> String? { #if DEBUG - if let ready = testSessionWakeReady { return ready() ? nil : "test-not-ready" } + if let ready = testSessionWakeReady { + return ready() ? nil : "test-not-ready" + } #endif guard !IsSecureEventInputEnabled() else { return "secure-input" } guard let console = SessionRuntimeEnvironmentObserver.currentConsole(), @@ -743,7 +804,9 @@ extension ServiceLifecycleCoordinator { let owner: SessionCapsMappingPolicy.Owner? = if let nonce, let pid { try .init(uid: uid, parentPID: parentPID, workerPID: pid, nonce: nonce, generation: nonce, bootSessionUUID: SessionCapsRuntimeSupport.bootSessionUUID()) - } else { nil } + } else { + nil + } try SessionCapsRuntimeSupport.recoverPending(directory: directory, expectedOwner: owner) }.value } diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 7f3f4484f..2f3492790 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -406,6 +406,18 @@ final class KanataDaemonService { } } + /// Drops cached launchd and ServiceManagement evidence so the next read is + /// fresh. Used after the 1.0.1 daemon is removed, which the PID cache (10s) + /// and the cached SMAppService status would otherwise still report. + func invalidateEvidenceCaches() async { + await pidCache.invalidateCache() + await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: Constants.daemonPlistName) + // A lingering registration with no process otherwise holds a prior + // `.running` for a few samples, which would refuse the next start. + enabledWithoutProcessSampleCount = 0 + lastObservedState = .stopped + } + /// Returns whether the internal recovery daemon is currently active. func isDaemonRunning() async -> Bool { let status = await refreshStatus() diff --git a/Sources/KeyPathAppKit/Services/MainAppStateController.swift b/Sources/KeyPathAppKit/Services/MainAppStateController.swift index cc8af2aa9..c8527f866 100644 --- a/Sources/KeyPathAppKit/Services/MainAppStateController.swift +++ b/Sources/KeyPathAppKit/Services/MainAppStateController.swift @@ -45,12 +45,16 @@ class MainAppStateController { case failed(blockingCount: Int, totalCount: Int) var isSuccess: Bool { - if case .success = self { return true } + if case .success = self { + return true + } return false } var hasCriticalIssues: Bool { - if case let .failed(blockingCount, _) = self, blockingCount > 0 { return true } + if case let .failed(blockingCount, _) = self, blockingCount > 0 { + return true + } return false } } @@ -477,7 +481,7 @@ class MainAppStateController { switch self { case .configuration: "Configuration cannot run in driverless mode" case .recovery: "Keyboard recovery requires attention" - case .legacyDaemon: "Another KeyPath keyboard service is running" + case .legacyDaemon: "The KeyPath 1.0.1 keyboard service is still running" } } @@ -485,14 +489,21 @@ class MainAppStateController { switch self { case .configuration: "Edit the configuration and retry. Existing rules were preserved." case .recovery: "The recovery record was preserved. Do not clear it to force a restart." - case .legacyDaemon: "KeyPath will not stop it for you. Stop it, then start KeyPath again. Existing rules were preserved." + case .legacyDaemon: "Choose Remove Old Service in Settings → Status. KeyPath asks for your administrator password once, removes the old service, and starts again. Existing rules were preserved." } } + + /// Only the 1.0.1 daemon has a fix, and it runs only when the user asks. + var autoFixAction: AutoFixAction? { + self == .legacyDaemon ? .removeLegacyPrivilegedRuntime : nil + } } private func performValidation(requiresFreshResult: Bool = false) async { if let inProgressValidation { - if requiresFreshResult { validationRefreshPending = true } + if requiresFreshResult { + validationRefreshPending = true + } AppLogger.shared.log( "🔍 [MainAppStateController] Validation already in progress - waiting for published result" ) @@ -507,7 +518,9 @@ class MainAppStateController { await performValidationBody() // An explicit refresh may arrive after this capture started. Invalidate // again because the completed capture can have repopulated caches. - if validationRefreshPending { validator?.invalidateCaches() } + if validationRefreshPending { + validator?.invalidateCaches() + } } while validationRefreshPending inProgressValidation = nil } @@ -827,7 +840,7 @@ class MainAppStateController { // snapshot cannot make a proven unsupported configuration start running. issues = [WizardIssue(identifier: .component(.keyPathRuntime), severity: .error, category: .systemRequirements, title: kind.title, - description: reason, autoFixAction: nil, + description: reason, autoFixAction: kind.autoFixAction, userAction: kind.userAction)] validationState = .failed(blockingCount: 1, totalCount: 1) lastValidatedSystemContext = nil @@ -860,7 +873,9 @@ class MainAppStateController { if let refusal = await serviceLifecycle?.legacyDaemonRefusalForStartup() { return .legacyDaemonRefused(refusal) } - if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } + if serviceLifecycle?.isRecoveringFromSystemWake == true { + return .recoveringFromWake + } let timing = startupGateTiming() let start = startupGateNow() let definitiveDeadline = start.addingTimeInterval(timing.definitiveGrace) @@ -874,7 +889,9 @@ class MainAppStateController { if let refusal = await serviceLifecycle?.legacyDaemonRefusalForStartup() { return .legacyDaemonRefused(refusal) } - if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } + if serviceLifecycle?.isRecoveringFromSystemWake == true { + return .recoveringFromWake + } if KanataRuntimeBackend.selected == .session { let permissions: PermissionOracle.Snapshot #if DEBUG @@ -886,7 +903,9 @@ class MainAppStateController { #else permissions = await SystemStateProvider.shared.currentPermissionSnapshot() #endif - if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } + if serviceLifecycle?.isRecoveringFromSystemWake == true { + return .recoveringFromWake + } if Self.hasDefinitiveSessionPermissionFailure(permissions) { return .missingSessionPermissions } @@ -897,8 +916,12 @@ class MainAppStateController { if let refusal = serviceLifecycle?.recoveryRefusalForStartup { return .sessionRecoveryRefused(refusal) } - if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } - if recoveryRevision != serviceLifecycle?.sessionWakeRecoveryRevision { continue } + if serviceLifecycle?.isRecoveringFromSystemWake == true { + return .recoveringFromWake + } + if recoveryRevision != serviceLifecycle?.sessionWakeRecoveryRevision { + continue + } let isReady = health.isReady if isReady { if checks > 0 { @@ -926,7 +949,9 @@ class MainAppStateController { private func startupGateNow() -> Date { #if DEBUG - if let override = startupGateNowOverride { return override() } + if let override = startupGateNowOverride { + return override() + } #endif return Date() } @@ -1051,7 +1076,9 @@ class MainAppStateController { /// Check if validation is currently running var isValidating: Bool { - if case .checking = validationState { return true } + if case .checking = validationState { + return true + } return false } } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index 2ecd9e80b..5dc3c5bc0 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift @@ -163,6 +163,11 @@ extension StatusSettingsTabView { return details } + /// The removal is offered exactly when the published refusal names the 1.0.1 service. + static func offersLegacyRuntimeRemoval(issues: [WizardIssue]) -> Bool { + issues.contains { $0.autoFixAction == .removeLegacyPrivilegedRuntime } + } + /// A stopped runtime says why when it knows, instead of only how to start it. static func stoppedMessage(lastError: String?) -> String { guard let lastError, !lastError.isEmpty else { return "Runtime is stopped. Use the switch above to turn it on." } @@ -220,19 +225,30 @@ extension StatusSettingsTabView { ) } + var actions: [StatusDetailAction] = [] + if Self.offersLegacyRuntimeRemoval(issues: wizardIssues) { + actions.append(StatusDetailAction( + title: removingLegacyRuntime ? "Removing Old Service…" : "Remove Old Service", + icon: "trash", + handler: { Task { await removeLegacyRuntimeAndStart() } }, + identifier: "status-action-remove-legacy-service", + accessibilityTitle: "Remove the KeyPath 1.0.1 keyboard service" + )) + } + actions += [ + StatusDetailAction(title: isDriverless ? "Open System Settings" : "Open Login Items", icon: "list.bullet") { + SystemDiagnostics.open(isDriverless ? .systemSettings : .loginItems) + }, + StatusDetailAction(title: "Open Wizard", icon: "wand.and.stars") { + wizardInitialPage = .summary + } + ] return StatusDetail( title: isDriverless ? "Keyboard remapping" : "KeyPath Runtime", message: Self.stoppedMessage(lastError: kanataManager.lastError), icon: "pause.circle", level: .warning, - actions: [ - StatusDetailAction(title: isDriverless ? "Open System Settings" : "Open Login Items", icon: "list.bullet") { - SystemDiagnostics.open(isDriverless ? .systemSettings : .loginItems) - }, - StatusDetailAction(title: "Open Wizard", icon: "wand.and.stars") { - wizardInitialPage = .summary - } - ] + actions: actions ) } @@ -316,8 +332,7 @@ extension StatusSettingsTabView { } var tcpDetail: StatusDetail? { - guard !isDriverless else { return nil } - guard systemContext?.services.kanataRunning == true else { return nil } + guard !isDriverless, systemContext?.services.kanataRunning == true else { return nil } guard let tcpConfigured else { return StatusDetail( title: "TCP Communication", @@ -396,8 +411,7 @@ extension StatusSettingsTabView { // Show logs affordance when service isn't healthy or has daemon issues. // Not when the user simply turned the service off — there's nothing to // troubleshoot. - guard !isIntentionallyDisabled else { return nil } - guard serviceStatusDetail.level != .success else { return nil } + guard !isIntentionallyDisabled, serviceStatusDetail.level != .success else { return nil } return StatusDetail( title: isDriverless ? "KeyPath Logs" : "Kanata Logs", message: isDriverless ? "Open KeyPath’s log for remapping startup errors and permission failures." : "Open the daemon stderr log for startup errors and permission failures.", diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index 0c018c8f4..0c751194b 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift @@ -28,6 +28,7 @@ struct StatusSettingsTabView: View { @State private var launchAtLogin = LaunchAtLoginController.shared @State private var settingsToastManager = WizardToastManager() @State var showingPermissionAlert = false + @State var removingLegacyRuntime = false @State private var localServiceRunning: Bool? // Optimistic local state for instant toggle feedback /// Persisted record that the user explicitly switched the service off (as @@ -471,7 +472,9 @@ struct StatusSettingsTabView: View { /// install is not yet reflected; that brief warm-up is intended. Validation /// publishes roughly every minute, so rescans are throttled to match. private func refreshDuplicateAppCopies() { - if let last = lastDuplicateScan, Date().timeIntervalSince(last) < 60 { return } + if let last = lastDuplicateScan, Date().timeIntervalSince(last) < 60 { + return + } lastDuplicateScan = Date() Task { @MainActor in let duplicates = await HelperMaintenance.shared.detectDuplicateAppCopies() @@ -480,6 +483,48 @@ struct StatusSettingsTabView: View { } } + /// Removes the KeyPath 1.0.1 service the session runtime refuses to start + /// beside (#1353), then starts KeyPath. Runs only when the user asks. + func removeLegacyRuntimeAndStart() async { + guard !removingLegacyRuntime else { return } + removingLegacyRuntime = true + defer { removingLegacyRuntime = false } + settingsToastManager.showInfo("Removing the old KeyPath service…") + + let result = await InstallerEngine().removeLegacyPrivilegedRuntime(using: PrivilegeBroker()) + guard result.report.success else { + await refreshStatus() + settingsToastManager.showError( + result.report.failureReason ?? "The old KeyPath service could not be removed.", duration: 10 + ) + return + } + + let lifecycle = kanataManager.underlyingManager.serviceLifecycleCoordinator + if await lifecycle.refreshLegacyDaemonRefusalAfterCleanup() { + await refreshStatus() + settingsToastManager.showError( + "The old KeyPath service was removed, but KeyPath still detects it. Restart your Mac, then open KeyPath again.", + duration: 10 + ) + return + } + + let started = await kanataManager.startKanata(reason: "KeyPath 1.0.1 service removed") + await refreshStatus() + var message = started + ? "Old KeyPath service removed. KeyPath is running." + : "Old KeyPath service removed. \(kanataManager.lastError ?? "Turn KeyPath on with the switch above.")" + if let notice = result.virtualHIDNotice { + message += " \(notice)" + } + if started { + settingsToastManager.showSuccess(message, duration: 10) + } else { + settingsToastManager.showInfo(message, duration: 10) + } + } + private func startViaInstallerEngine() async { await MainActor.run { settingsToastManager.showInfo("Starting…") diff --git a/Sources/KeyPathAppKit/WizardProtocolConformances.swift b/Sources/KeyPathAppKit/WizardProtocolConformances.swift index 7ccdd9e0a..e75e2be48 100644 --- a/Sources/KeyPathAppKit/WizardProtocolConformances.swift +++ b/Sources/KeyPathAppKit/WizardProtocolConformances.swift @@ -169,6 +169,9 @@ public func configureWizardDependencies(runtimeCoordinator: RuntimeCoordinator) let result = try await AdminCommandExecutorHolder.shared.execute(batch: batch) return (exitCode: result.exitCode, output: result.output) } + WizardDependencies.unregisterLegacyServiceRegistrations = { + await LegacyServiceRegistrations.unregister() + } WizardDependencies.resourceBundle = KeyPathAppKitResources.bundle // ExternalKanataService static methods diff --git a/Sources/KeyPathInstallationWizard/Core/AutoFixActionDescriptions.swift b/Sources/KeyPathInstallationWizard/Core/AutoFixActionDescriptions.swift index 256f1a202..48c9d151c 100644 --- a/Sources/KeyPathInstallationWizard/Core/AutoFixActionDescriptions.swift +++ b/Sources/KeyPathInstallationWizard/Core/AutoFixActionDescriptions.swift @@ -46,6 +46,8 @@ public enum AutoFixActionDescriptions { "Fix Karabiner driver version (v6 → v5)" case .installCorrectVHIDDriver: "Install Karabiner VirtualHID driver" + case .removeLegacyPrivilegedRuntime: + "Remove the old KeyPath keyboard service" } } diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift b/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift index f7b23510a..a78ca2dca 100644 --- a/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift +++ b/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift @@ -122,6 +122,8 @@ public extension InstallerEngine { "start-session-runtime" case .synchronizeConfigPaths: InstallerRecipeID.synchronizeConfigPaths + case .removeLegacyPrivilegedRuntime: + InstallerRecipeID.removeLegacyPrivilegedRuntime } } } diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift b/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift index 429554b83..bab8aa717 100644 --- a/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift +++ b/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift @@ -56,12 +56,17 @@ public final class InstallerEngine { injectedValidator ?? WizardDependencies.systemValidator } + /// Removes the KeyPath 1.0.1 privileged stack. Injected only by tests. + private let injectedLegacyCleanup: LegacyPrivilegedRuntimeCleanup? + /// Internal designated initializer to share construction logic public init( processLifecycleManager _: ProcessLifecycleManager, - systemValidator injectedValidator: (any WizardSystemValidating)? = nil + systemValidator injectedValidator: (any WizardSystemValidating)? = nil, + legacyRuntimeCleanup: LegacyPrivilegedRuntimeCleanup? = nil ) { self.injectedValidator = injectedValidator + injectedLegacyCleanup = legacyRuntimeCleanup AppLogger.shared.log("🔧 [InstallerEngine] Initialized") } @@ -786,6 +791,9 @@ public final class InstallerEngine { using broker: PrivilegeBroker ) async -> InstallerReport { AppLogger.shared.log("🔧 [InstallerEngine] runSingleAction(\(action), using:) starting") + if action == .removeLegacyPrivilegedRuntime { + return await removeLegacyPrivilegedRuntimeWithinTransaction().report + } let context = await inspectSystem() // Determine which intent would include this action. @@ -867,6 +875,74 @@ public final class InstallerEngine { return report } + // MARK: - KeyPath 1.0.1 migration + + /// Result of removing the 1.0.1 privileged stack. + public struct LegacyRuntimeRemovalResult: Sendable { + public let report: InstallerReport + /// What remains of the Karabiner driver, for the user. Nil when nothing does. + public let virtualHIDNotice: String? + } + + /// Stops and removes the KeyPath 1.0.1 kanata daemon, helper, and their files + /// with one administrator prompt (#1353). Success means a fresh read-only + /// observation found none of it left: no loaded legacy service, no root + /// KeyPath process, no legacy file. The Karabiner driver is reported, not + /// removed. This does not start the session runtime; the caller does that. + /// + /// Not a recipe: privileged recipes stay unavailable (ADR-045). This only + /// tears the retired stack down. + public func removeLegacyPrivilegedRuntime(using _: PrivilegeBroker) async -> LegacyRuntimeRemovalResult { + await withInstallerTransaction { + await removeLegacyPrivilegedRuntimeWithinTransaction() + } + } + + private func removeLegacyPrivilegedRuntimeWithinTransaction() async -> LegacyRuntimeRemovalResult { + let runID = UUID() + let recipeID = InstallerRecipeID.removeLegacyPrivilegedRuntime + let start = Date() + let cleanup = injectedLegacyCleanup ?? .live + let outcome = await cleanup.run() + let duration = Date().timeIntervalSince(start) + // Wizard callers require final evidence they own; capture it after the work. + let finalContext = await captureFreshContext() + let completionState: InstallerCompletionState = if !outcome.success { + outcome.operationSucceeded ? .verificationFailed : .executionFailed + } else if outcome.requestedAuthorization { + .completed + } else { + .verifiedNoOp + } + let telemetry = InstallerRepairTelemetryEvent( + runID: runID, beforeSnapshotID: nil, afterSnapshotID: finalContext.snapshotID, + trigger: .singleAction, intent: InstallIntent.repair.telemetryValue, + stateMatrixRow: nil, stateMatrixPlan: [], + action: recipeID, recipeID: recipeID, recipeType: nil, + postconditionResult: outcome.success ? .succeeded : .failed, + error: outcome.failureReason + ) + let report = InstallerReport( + runID: runID, + afterSnapshotID: finalContext.snapshotID, + success: outcome.success, + completionState: completionState, + failureReason: outcome.failureReason, + executedRecipes: outcome.requestedAuthorization ? [RecipeResult( + recipeID: recipeID, success: outcome.success, error: outcome.failureReason, + duration: duration, logs: outcome.logs + )] : [], + finalContext: finalContext, + logs: outcome.logs, + repairTelemetry: [telemetry] + ) + AppLogger.shared.log( + "🧹 [InstallerEngine] Legacy runtime removal - success: \(outcome.success), " + + "prompted: \(outcome.requestedAuthorization), remaining: \(outcome.after.remainingDescription)" + ) + return LegacyRuntimeRemovalResult(report: report, virtualHIDNotice: outcome.virtualHIDNotice) + } + // MARK: - Unavailable compatibility APIs public func uninstallVirtualHIDDrivers(using _: PrivilegeBroker) async throws { diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerRecipeID.swift b/Sources/KeyPathInstallationWizard/Core/InstallerRecipeID.swift index ec1d42cea..3757d05d2 100644 --- a/Sources/KeyPathInstallationWizard/Core/InstallerRecipeID.swift +++ b/Sources/KeyPathInstallationWizard/Core/InstallerRecipeID.swift @@ -6,6 +6,7 @@ import Foundation public enum InstallerRecipeID { public static let installRequiredRuntimeServices = "install-required-runtime-services" public static let installCorrectVHIDDriver = "install-correct-vhid-driver" + public static let removeLegacyPrivilegedRuntime = "remove-legacy-privileged-runtime" public static let installLogRotation = "install-log-rotation" public static let installPrivilegedHelper = "install-privileged-helper" public static let reinstallPrivilegedHelper = "reinstall-privileged-helper" diff --git a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift new file mode 100644 index 000000000..9ba8626c7 --- /dev/null +++ b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift @@ -0,0 +1,345 @@ +import Foundation +import KeyPathCore +import KeyPathWizardCore + +// MARK: - What KeyPath 1.0.1 left behind + +/// The privileged runtime public KeyPath 1.0.1 installed, as seen from the +/// session-runtime build that replaces it (ADR-045, issue #1353). +/// +/// This is migration code, not a backend: it only stops and removes these +/// items. Nothing here installs, registers, or repairs them. +public enum LegacyPrivilegedRuntime { + /// Launchd labels 1.0.1 loaded into the system domain. + public static let serviceLabels = [ + "com.keypath.kanata", + "com.keypath.helper", + "com.keypath.karabiner-vhiddaemon", + "com.keypath.karabiner-vhidmanager", + "com.keypath.logrotate" + ] + + /// Only these two keep the session runtime from starting. The others are + /// removed because they are KeyPath's own, not because they conflict. + public static let blockingServiceLabels: Set = ["com.keypath.kanata", "com.keypath.helper"] + + /// SMAppService plist names 1.0.1 registered from its app bundle. + public static let serviceManagementPlists = ["com.keypath.kanata.plist", "com.keypath.helper.plist"] + + /// Root-owned files that only KeyPath creates. Each is removed with `rm -f`. + public static let systemFiles = [ + "/Library/LaunchDaemons/com.keypath.kanata.plist", + "/Library/LaunchDaemons/com.keypath.helper.plist", + "/Library/LaunchDaemons/com.keypath.karabiner-vhiddaemon.plist", + "/Library/LaunchDaemons/com.keypath.karabiner-vhidmanager.plist", + "/Library/LaunchDaemons/com.keypath.logrotate.plist", + "/Library/PrivilegedHelperTools/com.keypath.helper", + "/Library/KeyPath/bin/kanata", + "/etc/newsyslog.d/com.keypath.conf", + "/usr/local/bin/keypath-logrotate.sh", + "/var/log/com.keypath.kanata.stdout.log", + "/var/log/com.keypath.kanata.stderr.log" + ] + + /// Matches the 1.0.1 kanata launcher, kanata, and the helper, by bundle path. + /// `pgrep -f` takes extended regular expressions. + public static let processPattern = "Library/(KeyPath|HelperTools)/" + + /// The Karabiner driver is shared with Karabiner-Elements, so KeyPath never + /// removes it. These paths only decide what to tell the user. + public static let driverManagerApp = "/Applications/.Karabiner-VirtualHIDDevice-Manager.app" + public static let driverSupportDirectory = "/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice" + public static let karabinerElementsApp = "/Applications/Karabiner-Elements.app" +} + +// MARK: - Evidence + +/// What remains of the Karabiner VirtualHID driver after the cleanup. +public struct LegacyVirtualHIDRemnants: Equatable, Sendable { + public var driverInstalled: Bool + public var karabinerElementsInstalled: Bool + + public init(driverInstalled: Bool, karabinerElementsInstalled: Bool) { + self.driverInstalled = driverInstalled + self.karabinerElementsInstalled = karabinerElementsInstalled + } + + public static let none = LegacyVirtualHIDRemnants(driverInstalled: false, karabinerElementsInstalled: false) + + /// What to tell the user, or nil when there is nothing left to mention. + public var notice: String? { + guard driverInstalled else { return nil } + if karabinerElementsInstalled { + return "The Karabiner virtual keyboard driver is still installed. Karabiner-Elements uses it, so KeyPath left it in place." + } + return "The Karabiner virtual keyboard driver from KeyPath 1.0.1 is still installed. KeyPath no longer needs it. " + + "To remove it, see “Upgrading from KeyPath 1.0.1” in KeyPath Help." + } +} + +/// Read-only observation of the 1.0.1 stack. Taken without administrator rights. +public struct LegacyPrivilegedRuntimeEvidence: Equatable, Sendable { + /// Labels launchd reports as loaded in the system domain. + public var loadedServices: [String] + /// Labels whose state could not be read. Never treated as absent. + public var unreadableServices: [String] + /// KeyPath processes that are alive but that this user cannot signal: root (ADR-040). + public var rootProcesses: [SystemProcessMatch] + public var leftoverFiles: [String] + public var virtualHID: LegacyVirtualHIDRemnants + + public init( + loadedServices: [String] = [], + unreadableServices: [String] = [], + rootProcesses: [SystemProcessMatch] = [], + leftoverFiles: [String] = [], + virtualHID: LegacyVirtualHIDRemnants = .none + ) { + self.loadedServices = loadedServices + self.unreadableServices = unreadableServices + self.rootProcesses = rootProcesses + self.leftoverFiles = leftoverFiles + self.virtualHID = virtualHID + } + + /// Nothing of the privileged stack is left, and every service state was read. + public var isClean: Bool { + loadedServices.isEmpty && unreadableServices.isEmpty && rootProcesses.isEmpty && leftoverFiles.isEmpty + } + + /// A summary of what is still present, for failure messages. + public var remainingDescription: String { + var parts: [String] = [] + if !loadedServices.isEmpty { + parts.append("still loaded: \(loadedServices.joined(separator: ", "))") + } + if !unreadableServices.isEmpty { + parts.append("could not check: \(unreadableServices.joined(separator: ", "))") + } + if !rootProcesses.isEmpty { + parts.append("still running: \(rootProcesses.map { "PID \($0.pid)" }.joined(separator: ", "))") + } + if !leftoverFiles.isEmpty { + parts.append("files remain: \(leftoverFiles.joined(separator: ", "))") + } + return parts.joined(separator: "; ") + } +} + +// MARK: - Cleanup + +/// Removes the KeyPath 1.0.1 privileged stack with one administrator prompt and +/// reports success only after a fresh observation finds none of it left. +/// +/// Order: best-effort SMAppService unregistration (no prompt), then a single +/// root batch that boots out every legacy label, stops any root KeyPath process +/// the app cannot signal itself (ADR-040), and deletes KeyPath's own system +/// files. The VirtualHID driver is reported, never removed. +@MainActor +public struct LegacyPrivilegedRuntimeCleanup { + public struct Dependencies { + public var inspect: () async -> LegacyPrivilegedRuntimeEvidence + /// Asks ServiceManagement to drop 1.0.1's registrations. Returns log lines. + public var unregisterRegistrations: () async -> [String] + public var executeBatch: (PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String) + public var sleep: (Duration) async -> Void + + public init( + inspect: @escaping () async -> LegacyPrivilegedRuntimeEvidence, + unregisterRegistrations: @escaping () async -> [String], + executeBatch: @escaping (PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String), + sleep: @escaping (Duration) async -> Void = { try? await Task.sleep(for: $0) } + ) { + self.inspect = inspect + self.unregisterRegistrations = unregisterRegistrations + self.executeBatch = executeBatch + self.sleep = sleep + } + } + + public struct Outcome: Sendable { + public let success: Bool + /// False when nothing was found and no administrator prompt was shown. + public let requestedAuthorization: Bool + /// The administrator batch ran and exited 0 (or was not needed). + public let operationSucceeded: Bool + public let before: LegacyPrivilegedRuntimeEvidence + public let after: LegacyPrivilegedRuntimeEvidence + public let failureReason: String? + public let logs: [String] + + public var virtualHIDNotice: String? { + after.virtualHID.notice + } + } + + /// launchd tears a booted-out job down asynchronously; give it this long. + static let verificationAttempts = 10 + static let verificationInterval: Duration = .milliseconds(500) + + private let dependencies: Dependencies + + public init(dependencies: Dependencies) { + self.dependencies = dependencies + } + + public func run() async -> Outcome { + var logs: [String] = [] + let before = await dependencies.inspect() + logs.append("[legacy-cleanup] Found: \(before.isClean ? "nothing" : before.remainingDescription)") + guard !before.isClean else { + return Outcome(success: true, requestedAuthorization: false, operationSucceeded: true, + before: before, after: before, + failureReason: nil, logs: logs) + } + + logs += await dependencies.unregisterRegistrations() + + let batch = Self.batch(stopping: before.rootProcesses.map(\.pid)) + var operationFailure: String? + do { + let result = try await dependencies.executeBatch(batch) + if result.exitCode != 0 { + operationFailure = Self.describeBatchFailure(exitCode: result.exitCode, output: result.output) + } + logs.append("[legacy-cleanup] Administrator batch exited \(result.exitCode)") + } catch { + operationFailure = "Could not run the cleanup as administrator: \(error.localizedDescription)" + } + if let operationFailure { + logs.append("[legacy-cleanup] \(operationFailure)") + } + + var after = await dependencies.inspect() + if operationFailure == nil { + var attempt = 1 + while !after.isClean, attempt < Self.verificationAttempts { + await dependencies.sleep(Self.verificationInterval) + after = await dependencies.inspect() + attempt += 1 + } + } + + let verificationFailure = after.isClean ? nil : "Old KeyPath service was not fully removed (\(after.remainingDescription))." + logs.append("[legacy-cleanup] Verified: \(after.isClean ? "nothing left" : after.remainingDescription)") + let failureReason: String? = switch (operationFailure, verificationFailure) { + case (nil, nil): nil + case let (operation?, nil): operation + case let (nil, verification?): verification + case let (operation?, verification?): "\(operation) \(verification)" + } + // A failed batch is never reported as success, even if a later look finds + // nothing: the user cancelled or something went wrong, and they should know. + return Outcome(success: failureReason == nil, requestedAuthorization: true, + operationSucceeded: operationFailure == nil, before: before, after: after, + failureReason: failureReason, logs: logs) + } + + /// The single root script. Every step tolerates an already-absent item so a + /// partial earlier run, or a Mac that never had one of them, still succeeds. + static func batch(stopping pids: [pid_t]) -> PrivilegedCommandRunner.Batch { + var commands = LegacyPrivilegedRuntime.serviceLabels.map { + "/bin/launchctl bootout system/\($0) 2>/dev/null || true" + } + let positive = pids.filter { $0 > 0 } + if !positive.isEmpty { + // Re-check the command line as root before signalling, so a PID that + // was recycled since the read-only probe is never killed. + commands.append(""" + for kp_pid in \(positive.map(String.init).joined(separator: " ")); do + if /bin/ps -o command= -p "$kp_pid" 2>/dev/null | /usr/bin/grep -qE '\(LegacyPrivilegedRuntime.processPattern)'; then + /bin/kill -TERM "$kp_pid" 2>/dev/null || true + fi + done + """) + } + commands += LegacyPrivilegedRuntime.systemFiles.map { "/bin/rm -f \(shellQuoted($0))" } + commands.append("/bin/rmdir /Library/KeyPath/bin /Library/KeyPath 2>/dev/null || true") + return PrivilegedCommandRunner.Batch( + label: "Remove the old KeyPath keyboard service", + commands: commands, + prompt: "KeyPath needs to remove the keyboard service installed by KeyPath 1.0.1." + ) + } + + static func describeBatchFailure(exitCode: Int32, output: String) -> String { + if output.contains("-128") || output.localizedCaseInsensitiveContains("user canceled") { + return "Administrator authorization was cancelled." + } + let detail = output.trimmingCharacters(in: .whitespacesAndNewlines) + return detail.isEmpty + ? "The cleanup exited with status \(exitCode)." + : "The cleanup exited with status \(exitCode): \(detail.prefix(300))" + } + + private static func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } +} + +// MARK: - Live probes + +public extension LegacyPrivilegedRuntimeCleanup { + /// launchd's "Could not find service" status for `launchctl print`. + static let launchctlServiceNotFound: Int32 = 113 + + /// Live dependencies. Unregistration and the admin batch come from the app + /// via `WizardDependencies`; without them the cleanup fails rather than + /// silently skipping work. + static var live: Self { + Self(dependencies: Dependencies( + inspect: { await inspectLive() }, + unregisterRegistrations: { + await WizardDependencies.unregisterLegacyServiceRegistrations?() + ?? ["[legacy-cleanup] ServiceManagement unregistration not configured"] + }, + executeBatch: { batch in + guard let execute = WizardDependencies.executePrivilegedBatch else { + throw InstallerError.unknownRecipe("Administrator commands are not configured") + } + return try await execute(batch) + } + )) + } + + static func inspectLive( + provider: SystemStateProvider = .shared, + fileExists: @escaping (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }, + isAlive: @escaping (pid_t) -> Bool = { SystemStateProvider.isProcessAlive(pid: $0) }, + canSignal: @escaping (pid_t) -> Bool = { SystemStateProvider.canSignalProcess(pid: $0) } + ) async -> LegacyPrivilegedRuntimeEvidence { + var loaded: [String] = [] + var unreadable: [String] = [] + for label in LegacyPrivilegedRuntime.serviceLabels { + let evidence = await provider.launchctlPrint(target: "system/\(label)") + switch evidence.exitCode { + case 0: loaded.append(label) + case launchctlServiceNotFound: break + default: unreadable.append(label) + } + } + let matches = await provider.processMatches(matching: LegacyPrivilegedRuntime.processPattern) + return LegacyPrivilegedRuntimeEvidence( + loadedServices: loaded, + unreadableServices: unreadable, + rootProcesses: rootProcesses(in: matches, isAlive: isAlive, canSignal: canSignal), + leftoverFiles: LegacyPrivilegedRuntime.systemFiles.filter(fileExists), + virtualHID: LegacyVirtualHIDRemnants( + driverInstalled: fileExists(LegacyPrivilegedRuntime.driverManagerApp) + || fileExists(LegacyPrivilegedRuntime.driverSupportDirectory), + karabinerElementsInstalled: fileExists(LegacyPrivilegedRuntime.karabinerElementsApp) + ) + ) + } + + /// Alive but not signalable means another user, in practice root (ADR-040: + /// only ESRCH means gone; EPERM means alive). The app's own session worker + /// is signalable and so never counted. + static func rootProcesses( + in matches: [SystemProcessMatch], + isAlive: (pid_t) -> Bool, + canSignal: (pid_t) -> Bool + ) -> [SystemProcessMatch] { + matches.filter { $0.pid > 0 && isAlive($0.pid) && !canSignal($0.pid) } + } +} diff --git a/Sources/KeyPathInstallationWizard/Core/WizardAsyncOperationManager.swift b/Sources/KeyPathInstallationWizard/Core/WizardAsyncOperationManager.swift index c486bcb75..d43f0fba4 100644 --- a/Sources/KeyPathInstallationWizard/Core/WizardAsyncOperationManager.swift +++ b/Sources/KeyPathInstallationWizard/Core/WizardAsyncOperationManager.swift @@ -643,6 +643,8 @@ public extension AutoFixAction { "Fix Karabiner Driver Version" case .installCorrectVHIDDriver: "Install Karabiner Driver" + case .removeLegacyPrivilegedRuntime: + "Remove Old KeyPath Service" } } } diff --git a/Sources/KeyPathInstallationWizard/UI/InstallationWizardView+Actions.swift b/Sources/KeyPathInstallationWizard/UI/InstallationWizardView+Actions.swift index eec94629e..8392f164a 100644 --- a/Sources/KeyPathInstallationWizard/UI/InstallationWizardView+Actions.swift +++ b/Sources/KeyPathInstallationWizard/UI/InstallationWizardView+Actions.swift @@ -95,6 +95,9 @@ public extension InstallationWizardView { // Give VHID/launch-service operations more time let timeoutSeconds = switch action { + case .removeLegacyPrivilegedRuntime: + // Includes the administrator password dialog. + 300.0 case .restartVirtualHIDDaemon, .installCorrectVHIDDriver, .repairVHIDDaemonServices, .installRequiredRuntimeServices: 30.0 diff --git a/Sources/KeyPathWizardCore/WizardDependencies.swift b/Sources/KeyPathWizardCore/WizardDependencies.swift index 32f76a7a3..98d0448e0 100644 --- a/Sources/KeyPathWizardCore/WizardDependencies.swift +++ b/Sources/KeyPathWizardCore/WizardDependencies.swift @@ -66,6 +66,9 @@ public enum WizardDependencies { /// AdminCommandExecutor - execute(batch:) for privileged commands public static var executePrivilegedBatch: ((PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String))? + /// Drops KeyPath 1.0.1's SMAppService registrations (migration only, #1353). Returns log lines. + public static var unregisterLegacyServiceRegistrations: (() async -> [String])? + /// KeyPathAppKitResources bundle accessor public static var resourceBundle: Bundle? @@ -105,6 +108,7 @@ public enum WizardDependencies { helperNeedsApproval = nil createUninstallCoordinator = nil executePrivilegedBatch = nil + unregisterLegacyServiceRegistrations = nil resourceBundle = nil getExternalKanataInfo = nil stopExternalKanata = nil diff --git a/Sources/KeyPathWizardCore/WizardTypes.swift b/Sources/KeyPathWizardCore/WizardTypes.swift index 9ad03f740..4fab2a351 100644 --- a/Sources/KeyPathWizardCore/WizardTypes.swift +++ b/Sources/KeyPathWizardCore/WizardTypes.swift @@ -214,6 +214,7 @@ public enum AutoFixAction: Equatable, Sendable { case restartCommServer // Restart service to enable TCP functionality case fixDriverVersionMismatch // Download and install correct Karabiner driver version (v5 for kanata v1.9.0) case installCorrectVHIDDriver // Download and install the correct driver when missing (helper-first) + case removeLegacyPrivilegedRuntime // Remove the KeyPath 1.0.1 daemon and helper (migration, #1353) } /// Structured identifier for wizard issues to enable type-safe navigation @@ -226,25 +227,33 @@ public enum IssueIdentifier: Equatable, Sendable { /// Check if this identifier represents a conflict public var isConflict: Bool { - if case .conflict = self { return true } + if case .conflict = self { + return true + } return false } /// Check if this identifier represents a permission issue public var isPermission: Bool { - if case .permission = self { return true } + if case .permission = self { + return true + } return false } /// Check if this identifier represents a component issue public var isComponent: Bool { - if case .component = self { return true } + if case .component = self { + return true + } return false } /// Check if this identifier represents a daemon issue public var isDaemon: Bool { - if case .daemon = self { return true } + if case .daemon = self { + return true + } return false } @@ -552,7 +561,9 @@ public extension [WizardIssue] { guard !isEmpty else { return "" } func truncate(_ s: String, limit: Int = 90) -> String { - if s.count <= limit { return s } + if s.count <= limit { + return s + } let end = s.index(s.startIndex, offsetBy: limit) return String(s[s.startIndex ..< end]).trimmingCharacters(in: .whitespacesAndNewlines) + "…" } diff --git a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift new file mode 100644 index 000000000..39c43444c --- /dev/null +++ b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift @@ -0,0 +1,303 @@ +@testable import KeyPathAppKit +import KeyPathCore +import KeyPathDaemonLifecycle +@testable import KeyPathInstallationWizard +import KeyPathWizardCore +import ServiceManagement +import XCTest + +/// The upgrade path from public 1.0.1 (#1353): one administrator prompt removes +/// the kanata daemon and helper, and success needs fresh evidence that none of +/// it is left. The VirtualHID driver is reported, never removed. +@MainActor +final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { + private var inspections: [LegacyPrivilegedRuntimeEvidence] = [] + private var inspectCount = 0 + private var batches: [PrivilegedCommandRunner.Batch] = [] + private var unregisterCount = 0 + private var sleeps = 0 + private var batchResult: Result<(exitCode: Int32, output: String), Error> = .success((0, "")) + + private static let installed101 = LegacyPrivilegedRuntimeEvidence( + loadedServices: ["com.keypath.kanata", "com.keypath.helper"], + rootProcesses: [SystemProcessMatch( + pid: 4242, + command: "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher --port 37001" + )], + leftoverFiles: ["/Library/PrivilegedHelperTools/com.keypath.helper"], + virtualHID: LegacyVirtualHIDRemnants(driverInstalled: true, karabinerElementsInstalled: false) + ) + private static let clean = LegacyPrivilegedRuntimeEvidence( + virtualHID: LegacyVirtualHIDRemnants(driverInstalled: true, karabinerElementsInstalled: false) + ) + + private func makeCleanup() -> LegacyPrivilegedRuntimeCleanup { + LegacyPrivilegedRuntimeCleanup(dependencies: .init( + inspect: { [unowned self] in + inspectCount += 1 + // Repeat the last reading once the script runs out. + return inspections.isEmpty ? Self.clean : (inspections.count > 1 ? inspections.removeFirst() : inspections[0]) + }, + unregisterRegistrations: { [unowned self] in + unregisterCount += 1 + return ["unregistered"] + }, + executeBatch: { [unowned self] batch in + batches.append(batch) + return try batchResult.get() + }, + sleep: { [unowned self] _ in sleeps += 1 } + )) + } + + // MARK: - Cleanup + + func testNothingLeftNeedsNoAuthorization() async { + inspections = [Self.clean] + let outcome = await makeCleanup().run() + XCTAssertTrue(outcome.success) + XCTAssertFalse(outcome.requestedAuthorization) + XCTAssertTrue(batches.isEmpty, "A Mac without 1.0.1 must never see an administrator prompt") + XCTAssertEqual(unregisterCount, 0) + } + + func testOneAuthorizationRemovesTheStackAndWaitsForLaunchdTeardown() async { + let stillTearingDown = LegacyPrivilegedRuntimeEvidence(loadedServices: ["com.keypath.kanata"]) + inspections = [Self.installed101, stillTearingDown, Self.clean] + let outcome = await makeCleanup().run() + + XCTAssertTrue(outcome.success, outcome.failureReason ?? "") + XCTAssertTrue(outcome.requestedAuthorization) + XCTAssertEqual(batches.count, 1, "The whole cleanup must take one administrator prompt") + XCTAssertEqual(unregisterCount, 1) + XCTAssertEqual(inspectCount, 3) + XCTAssertEqual(sleeps, 1) + XCTAssertTrue(outcome.after.isClean) + + let script = batches[0].script + for label in LegacyPrivilegedRuntime.serviceLabels { + XCTAssertTrue(script.contains("/bin/launchctl bootout system/\(label)"), label) + } + for file in LegacyPrivilegedRuntime.systemFiles { + XCTAssertTrue(script.contains("/bin/rm -f '\(file)'"), file) + } + XCTAssertTrue(script.contains("for kp_pid in 4242;")) + XCTAssertTrue(script.contains("/bin/ps -o command= -p \"$kp_pid\""), + "The PID must be re-checked as root before it is signalled") + } + + func testCancelledAuthorizationIsReportedAsCancelled() async { + inspections = [Self.installed101] + batchResult = .success((1, "execution error: User canceled. (-128)")) + let outcome = await makeCleanup().run() + + XCTAssertFalse(outcome.success) + XCTAssertFalse(outcome.operationSucceeded) + XCTAssertTrue(outcome.failureReason?.hasPrefix("Administrator authorization was cancelled.") == true, + outcome.failureReason ?? "") + XCTAssertEqual(sleeps, 0, "A refused batch is not polled as if it were tearing down") + XCTAssertEqual(batches.count, 1) + } + + func testFailedBatchIsNeverSuccessEvenIfNothingIsLeft() async { + inspections = [Self.installed101, Self.clean] + batchResult = .success((2, "rm: permission denied")) + let outcome = await makeCleanup().run() + XCTAssertFalse(outcome.success) + XCTAssertTrue(outcome.failureReason?.contains("status 2") == true) + } + + func testThrownExecutorIsFailure() async { + inspections = [Self.installed101] + batchResult = .failure(InstallerError.unknownRecipe("Administrator commands are not configured")) + let outcome = await makeCleanup().run() + XCTAssertFalse(outcome.success) + XCTAssertTrue(outcome.failureReason?.contains("not configured") == true) + } + + func testDaemonStillLoadedAfterTheBatchFailsVerification() async { + inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(loadedServices: ["com.keypath.kanata"])] + let outcome = await makeCleanup().run() + + XCTAssertFalse(outcome.success) + XCTAssertTrue(outcome.operationSucceeded) + XCTAssertTrue(outcome.failureReason?.contains("com.keypath.kanata") == true, outcome.failureReason ?? "") + XCTAssertEqual(inspectCount, 1 + LegacyPrivilegedRuntimeCleanup.verificationAttempts) + } + + func testUnreadableServiceStateIsNeverTreatedAsGone() async { + inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(unreadableServices: ["com.keypath.kanata"])] + let outcome = await makeCleanup().run() + XCTAssertFalse(outcome.success) + XCTAssertTrue(outcome.failureReason?.contains("could not check") == true) + } + + func testVirtualHIDDriverIsReportedButNeverRemoved() async { + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [4242]).script + XCTAssertFalse(script.contains("systemextensionsctl")) + XCTAssertFalse(script.contains("org.pqrs")) + XCTAssertFalse(script.contains("Karabiner-VirtualHIDDevice-Manager")) + + XCTAssertNil(LegacyVirtualHIDRemnants.none.notice) + let alone = LegacyVirtualHIDRemnants(driverInstalled: true, karabinerElementsInstalled: false) + XCTAssertTrue(alone.notice?.contains("Upgrading from KeyPath 1.0.1") == true) + let shared = LegacyVirtualHIDRemnants(driverInstalled: true, karabinerElementsInstalled: true) + XCTAssertTrue(shared.notice?.contains("Karabiner-Elements uses it") == true) + + inspections = [Self.installed101, Self.clean] + let outcome = await makeCleanup().run() + XCTAssertEqual(outcome.virtualHIDNotice, alone.notice) + } + + func testBatchWithoutRootProcessesHasNoSignalStep() { + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [0, -1]).script + XCTAssertFalse(script.contains("kp_pid")) + } + + // MARK: - Live probes + + func testRootProcessFilterTreatsEPERMAsAliveAndSkipsSignalableProcesses() { + let matches = [101, 102, 103, 0].map { SystemProcessMatch(pid: pid_t($0), command: "x") } + let root = LegacyPrivilegedRuntimeCleanup.rootProcesses( + in: matches, + isAlive: { $0 != 103 }, + canSignal: { $0 == 102 } + ) + XCTAssertEqual(root.map(\.pid), [101]) + } + + /// ADR-040: ground the real primitives once. launchd (PID 1) is root and + /// alive, so probing it returns EPERM; this test process is signalable. + func testRootProcessFilterAgainstRealProcesses() { + let matches = [SystemProcessMatch(pid: 1, command: "launchd"), + SystemProcessMatch(pid: getpid(), command: "test")] + let root = LegacyPrivilegedRuntimeCleanup.rootProcesses( + in: matches, + isAlive: { SystemStateProvider.isProcessAlive(pid: $0) }, + canSignal: { SystemStateProvider.canSignalProcess(pid: $0) } + ) + XCTAssertEqual(root.map(\.pid), [1]) + } + + func testLiveInspectionReadsLaunchctlExitCodesAndFiles() async { + let probes = SystemProbeClient( + processIDs: { _ in [] }, + processMatches: { _ in [SystemProcessMatch(pid: 77, command: "/Library/KeyPath/bin/kanata --cfg x")] }, + launchctlPrint: { target in + let code: Int32 = switch target { + case "system/com.keypath.kanata": 0 + case "system/com.keypath.helper": 1 + default: 113 + } + return LaunchctlPrintEvidence(target: target, exitCode: code, stdout: "", stderr: "") + }, + processIDsSynchronously: { _ in [] }, + isProcessAlive: { _ in true }, + probeTCPPort: { _, _ in false } + ) + let present: Set = ["/Library/KeyPath/bin/kanata", LegacyPrivilegedRuntime.karabinerElementsApp, + LegacyPrivilegedRuntime.driverSupportDirectory] + let evidence = await LegacyPrivilegedRuntimeCleanup.inspectLive( + provider: SystemStateProvider(probes: probes), + fileExists: { present.contains($0) }, + isAlive: { _ in true }, + canSignal: { _ in false } + ) + XCTAssertEqual(evidence.loadedServices, ["com.keypath.kanata"]) + XCTAssertEqual(evidence.unreadableServices, ["com.keypath.helper"]) + XCTAssertEqual(evidence.rootProcesses.map(\.pid), [77]) + XCTAssertEqual(evidence.leftoverFiles, ["/Library/KeyPath/bin/kanata"]) + XCTAssertEqual(evidence.virtualHID, LegacyVirtualHIDRemnants(driverInstalled: true, karabinerElementsInstalled: true)) + } + + // MARK: - InstallerEngine routing + + private func makeEngine(context: SystemContext) -> InstallerEngine { + InstallerEngine( + processLifecycleManager: ProcessLifecycleManager(), + systemValidator: StubSystemValidator(context: context), + legacyRuntimeCleanup: makeCleanup() + ) + } + + func testFixActionRoutesThroughInstallerEngineWithFinalEvidence() async { + inspections = [Self.installed101, Self.clean] + let context = SystemContextBuilder(componentsInstalled: true).build() + let report = await makeEngine(context: context) + .runSingleAction(.removeLegacyPrivilegedRuntime, using: PrivilegeBroker()) + + XCTAssertTrue(report.success, report.failureReason ?? "") + XCTAssertEqual(report.completionState, .completed) + XCTAssertEqual(report.finalContext?.snapshotID, context.snapshotID) + XCTAssertEqual(report.executedRecipes.map(\.recipeID), [InstallerRecipeID.removeLegacyPrivilegedRuntime]) + XCTAssertEqual(batches.count, 1) + } + + func testEngineReportsVerifiedNoOpWithoutPrompting() async { + inspections = [Self.clean] + let result = await makeEngine(context: SystemContextBuilder(componentsInstalled: true).build()) + .removeLegacyPrivilegedRuntime(using: PrivilegeBroker()) + XCTAssertTrue(result.report.success) + XCTAssertEqual(result.report.completionState, .verifiedNoOp) + XCTAssertTrue(result.report.executedRecipes.isEmpty) + XCTAssertTrue(batches.isEmpty) + } + + func testEngineReportsVerificationFailure() async { + inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(loadedServices: ["com.keypath.kanata"])] + let result = await makeEngine(context: SystemContextBuilder(componentsInstalled: true).build()) + .removeLegacyPrivilegedRuntime(using: PrivilegeBroker()) + XCTAssertFalse(result.report.success) + XCTAssertEqual(result.report.completionState, .verificationFailed) + XCTAssertEqual(result.report.repairTelemetry.first?.postconditionResult, .failed) + } + + /// The removal is not a recipe: a forged plan naming it still cannot run. + func testRemovalCannotBeSmuggledInAsARecipe() async { + let engine = makeEngine(context: SystemContextBuilder(componentsInstalled: true).build()) + let plan = InstallPlan( + recipes: [ServiceRecipe(id: InstallerRecipeID.removeLegacyPrivilegedRuntime, type: .installComponent)], + status: .ready, intent: .repair + ) + let report = await engine.execute(plan: plan, using: PrivilegeBroker()) + XCTAssertFalse(report.success) + XCTAssertTrue(report.failureReason?.contains("unavailable in the driverless build") == true) + XCTAssertTrue(batches.isEmpty) + } + + // MARK: - ServiceManagement unregistration + + func testUnregistersOnlyRegisteredServicesAndToleratesFailure() async { + let kanata = RecordingSMAppService(failsUnregister: true) + let helper = RecordingSMAppService(failsUnregister: false) + let logs = await LegacyServiceRegistrations.unregister( + statusFor: { $0 == "com.keypath.kanata.plist" ? .enabled : .notFound }, + service: { $0 == "com.keypath.kanata.plist" ? kanata : helper } + ) + XCTAssertEqual(kanata.unregisterCalls, 1) + XCTAssertEqual(helper.unregisterCalls, 0) + XCTAssertTrue(logs.contains { $0.contains("com.keypath.kanata.plist: unregister failed") }) + XCTAssertTrue(logs.contains { $0.contains("com.keypath.helper.plist: not registered") }) + } +} + +private final class RecordingSMAppService: SMAppServiceProtocol, @unchecked Sendable { + let failsUnregister: Bool + private(set) var unregisterCalls = 0 + + init(failsUnregister: Bool) { + self.failsUnregister = failsUnregister + } + + var status: SMAppService.Status { + .notFound + } + + func register() throws {} + func unregister() async throws { + unregisterCalls += 1 + if failsUnregister { + throw NSError(domain: "test", code: 1) + } + } +} diff --git a/Tests/KeyPathTests/MainAppStateControllerTests.swift b/Tests/KeyPathTests/MainAppStateControllerTests.swift index d814bd2d6..567850a2c 100644 --- a/Tests/KeyPathTests/MainAppStateControllerTests.swift +++ b/Tests/KeyPathTests/MainAppStateControllerTests.swift @@ -718,7 +718,9 @@ struct MainAppStateControllerBehaviorTests { let lifecycle = RuntimeCoordinator().serviceLifecycleCoordinator lifecycle.testSessionConfigurationValidation = { .valid } lifecycle.testSessionCapsRecovery = { throw SessionCapsMappingLease.Refusal.mutationUncertain } - if !duringHealth { #expect(await lifecycle.restoreSessionCaps(for: nil) == false) } + if !duringHealth { + #expect(await lifecycle.restoreSessionCaps(for: nil) == false) + } controller.configure(serviceLifecycle: lifecycle, onSystemHealthy: {}) controller.setValidator(StubSystemValidator(context: SystemContextBuilder(componentsInstalled: true).build())) var healthChecks = 0, permissionChecks = 0 @@ -762,7 +764,9 @@ struct MainAppStateControllerBehaviorTests { SystemContextBuilder(servicesHealthy: true, kanataTCPConfigured: true, componentsInstalled: true).build() ]) validator.beforeCheck = { [weak validator] in - if validator?.checkCount == 1 { lifecycle.sessionWakeRecoveryRevision &+= 2 } + if validator?.checkCount == 1 { + lifecycle.sessionWakeRecoveryRevision &+= 2 + } } controller.setValidator(validator) controller.configureStartupGateTestingState( @@ -803,22 +807,22 @@ struct MainAppStateControllerBehaviorTests { #expect(controller.runtimeValidationNeedsRefresh(isHealthy: false, recoveryRevision: 1, legacyDaemonRefused: false)) } - @Test("Legacy daemon refusal reaches the issue list with no automatic fix") + @Test("Legacy daemon refusal reaches the issue list with a user-run removal action") func legacyDaemonRefusalPublishes() async { let controller = MainAppStateController() let lifecycle = RuntimeCoordinator().serviceLifecycleCoordinator lifecycle.testSessionConfigurationValidation = { .valid } lifecycle.testSessionCurrentReport = { nil } - lifecycle.sessionLegacyDaemonRefusal = "Stop the existing keyboard service before trying driverless mode." + lifecycle.sessionLegacyDaemonRefusal = ServiceLifecycleCoordinator.legacyDaemonRefusalMessage lifecycle.testSessionLegacyDaemonRunning = { true } controller.configure(serviceLifecycle: lifecycle, onSystemHealthy: {}) controller.setValidator(StubSystemValidator(context: SystemContextBuilder(componentsInstalled: true).build())) await controller.revalidate() #expect(controller.validationState?.hasCriticalIssues == true) #expect(controller.issues.count == 1) - #expect(controller.issues.first?.title == "Another KeyPath keyboard service is running") - #expect(controller.issues.first?.autoFixAction == nil) - #expect(controller.issues.first?.userAction?.contains("will not stop it") == true) + #expect(controller.issues.first?.title == "The KeyPath 1.0.1 keyboard service is still running") + #expect(controller.issues.first?.autoFixAction == .removeLegacyPrivilegedRuntime) + #expect(controller.issues.first?.userAction?.contains("Remove Old Service") == true) } private func sessionPermissions( diff --git a/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift b/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift index a4f77876a..6e5bf70de 100644 --- a/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift +++ b/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift @@ -1,5 +1,6 @@ @testable import KeyPathAppKit import KeyPathCore +import KeyPathWizardCore import XCTest @MainActor @@ -72,4 +73,14 @@ final class SessionFailureRecoveryMessageTests: XCTestCase { XCTAssertEqual(StatusSettingsTabView.stoppedMessage(lastError: "Driverless remapping stopped."), "Runtime is stopped. Driverless remapping stopped.") } + + func testRemovalIsOfferedOnlyForTheLegacyDaemonRefusal() { + func issue(_ action: AutoFixAction?) -> WizardIssue { + WizardIssue(identifier: .component(.keyPathRuntime), severity: .error, category: .systemRequirements, + title: "t", description: "d", autoFixAction: action, userAction: nil) + } + XCTAssertTrue(StatusSettingsTabView.offersLegacyRuntimeRemoval(issues: [issue(.removeLegacyPrivilegedRuntime)])) + XCTAssertFalse(StatusSettingsTabView.offersLegacyRuntimeRemoval(issues: [issue(nil)])) + XCTAssertFalse(StatusSettingsTabView.offersLegacyRuntimeRemoval(issues: [])) + } } diff --git a/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift index 0addca15c..af4c1f3be 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift @@ -7,7 +7,7 @@ /// unit tests, so that failure is the proof that a start got past the check. @MainActor final class SessionLegacyDaemonRefusalTests: KeyPathTestCase { - private let refusal = "Stop the existing keyboard service before trying driverless mode." + private let refusal = ServiceLifecycleCoordinator.legacyDaemonRefusalMessage private var errors: [String] = [] private var daemonChecks = 0 private var publishedError: String? @@ -38,7 +38,9 @@ final class SessionLegacyDaemonRefusalTests: KeyPathTestCase { coordinator.testSessionCapsPreparation = {} coordinator.testSessionCapsRecovery = {} coordinator.onError = { [weak self] message in - if let message { self?.errors.append(message) } + if let message { + self?.errors.append(message) + } } return coordinator } @@ -109,6 +111,20 @@ final class SessionLegacyDaemonRefusalTests: KeyPathTestCase { XCTAssertNotEqual(reason, refusal) } + func testRefreshAfterCleanupClearsTheRefusalOnlyWhenTheDaemonIsGone() async { + let coordinator = makeCoordinator() + _ = await start(daemonRunning: true, using: coordinator) + + let stillRefused = await coordinator.refreshLegacyDaemonRefusalAfterCleanup() + XCTAssertTrue(stillRefused, "A daemon that is still running must keep blocking the start") + XCTAssertEqual(coordinator.sessionLegacyDaemonRefusal, refusal) + + coordinator.testSessionLegacyDaemonRunning = { false } + let refusedAfterRemoval = await coordinator.refreshLegacyDaemonRefusalAfterCleanup() + XCTAssertFalse(refusedAfterRemoval) + XCTAssertNil(coordinator.sessionLegacyDaemonRefusal) + } + func testRunningLegacyDaemonRefusesStartWithoutLaunching() async { let started = await start(daemonRunning: true) XCTAssertFalse(started) diff --git a/docs/adr/adr-045-retire-privileged-runtime-stack.md b/docs/adr/adr-045-retire-privileged-runtime-stack.md index 29eaed06b..fd0ab57a6 100644 --- a/docs/adr/adr-045-retire-privileged-runtime-stack.md +++ b/docs/adr/adr-045-retire-privileged-runtime-stack.md @@ -47,6 +47,20 @@ It must be tested on a clean VM that starts from 1.0.1. Tracked in Keep legacy detection after the rest is removed for as long as 1.0.1 installs may upgrade directly. +**Upgrade path (2026-11, #1353).** When the refusal stands, Settings → Status +offers **Remove Old Service**, which calls +`InstallerEngine.removeLegacyPrivilegedRuntime` (also reachable as +`AutoFixAction.removeLegacyPrivilegedRuntime`). It runs +`LegacyPrivilegedRuntimeCleanup`: best-effort SMAppService unregistration, then +one administrator batch that boots out every `com.keypath.*` label 1.0.1 used, +stops root KeyPath processes the app cannot signal (ADR-040), and deletes +KeyPath's own system files. It reports success only after a fresh read-only +observation finds no loaded legacy service, no root KeyPath process, and no +legacy file. The Karabiner driver is reported, never removed. It is not a +recipe: privileged recipes stay unavailable. Its live dependency on the retired +stack is limited to the label, plist, and path lists in that file. The clean-VM +run is still outstanding: `docs/testing/upgrade-from-1-0-1.md`. + ## Consequences - `AGENTS.md` and `docs/architecture/overview.md` describe the session runtime. diff --git a/docs/testing/upgrade-from-1-0-1.md b/docs/testing/upgrade-from-1-0-1.md new file mode 100644 index 000000000..4082943d5 --- /dev/null +++ b/docs/testing/upgrade-from-1-0-1.md @@ -0,0 +1,68 @@ +# Manual test: upgrade from public 1.0.1 to the session runtime + +Issue [#1353](https://github.com/malpern/KeyPath/issues/1353), ADR-045 Upgrade Constraint. + +**Status: not yet run.** It needs a clean macOS VM from `vm-lab` on the mini. +Record each run at the bottom, with the build under test and the lease. + +## Setup + +1. Create a clean macOS 26 desktop lease (and repeat on 27): + `vm-lab keypath create --macos 26 --lane unmanaged-ui --desktop --ttl 2h --commit --installer `. + Follow `docs/testing/remote-installer-lab.md` for admission. +2. Install public **1.0.1** from its GitHub release DMG. Finish its wizard: + helper, Karabiner driver, Input Monitoring, Accessibility, kanata daemon. +3. Add one simple rule (for example `caps → esc`) and confirm it works. +4. Record the starting state (all should show the 1.0.1 stack): + ``` + sudo launchctl print system/com.keypath.kanata | head -5 + sudo launchctl print system/com.keypath.helper | head -5 + ps -axo user,pid,command | grep -E 'Library/(KeyPath|HelperTools)/' | grep -v grep + ls -l /Library/PrivilegedHelperTools/com.keypath.helper /Library/LaunchDaemons/com.keypath.* + systemextensionsctl list | grep -i karabiner + ``` + +## Upgrade + +5. Install the build under test over 1.0.1 (drag to Applications, replace). Do + not reboot. Open it. +6. Expect: remapping does not start. **Settings → Status** shows + "The keyboard service from KeyPath 1.0.1 is still running" with a + **Remove Old Service** button. The 1.0.1 rule may still work through the old + daemon; that is expected until removal. +7. Choose **Remove Old Service**, then **cancel** the password prompt. + Expect: an error toast "Administrator authorization was cancelled."; the + refusal and button remain; the daemon is still loaded (step 4 commands). +8. Choose **Remove Old Service** again and authenticate. Count prompts: exactly **one**. +9. Expect: a success toast, KeyPath running, and a sentence about the Karabiner + driver (it is still installed). The `caps → esc` rule works through the + session runtime. + +## Verify + +10. Re-run the step 4 commands. Expect: + - both `launchctl print` commands report "Could not find service" + - no root process with a KeyPath bundle path + - no `/Library/PrivilegedHelperTools/com.keypath.helper`, no `/Library/LaunchDaemons/com.keypath.*` + - the Karabiner driver still listed by `systemextensionsctl` (not removed by design) +11. `~/Library/Logs/KeyPath/keypath-debug.log` contains `[legacy-cleanup] Verified: nothing left`. +12. Reboot. Log in. Expect: no 1.0.1 daemon or helper comes back, and KeyPath + starts with no refusal. Check System Settings → General → Login Items: + record whether a stale KeyPath background item is still listed (a + ServiceManagement record the app could not unregister; cosmetic, but note it). +13. Follow the guide's driver removal command once (`Karabiner-VirtualHIDDevice-Manager deactivate`) + and record whether it works as written. Update `guides/upgrading-from-1-0-1.md` if not. + +## Variations + +- **Karabiner-Elements installed** before the upgrade: the toast says + Karabiner-Elements uses the driver; Karabiner-Elements still works afterwards. +- **Sparkle update** from 1.0.1 instead of a manual replace, if 1.0.1's appcast + can reach the build under test. +- **Already removed**: run step 8 a second time. Expect no password prompt. + +## Results + +| Date | Build | macOS | Lease | Result | Notes | +| ---- | ----- | ----- | ----- | ------ | ----- | +| — | — | — | — | not run | mini offline (Nov 2026) | diff --git a/guides/upgrading-from-1-0-1.md b/guides/upgrading-from-1-0-1.md new file mode 100644 index 000000000..7d484b22f --- /dev/null +++ b/guides/upgrading-from-1-0-1.md @@ -0,0 +1,53 @@ +--- +layout: default +title: "Upgrading from KeyPath 1.0.1" +description: "Remove the old background keyboard service after updating from KeyPath 1.0.1" +permalink: /guides/upgrading-from-1-0-1/ +--- + +# Upgrading from KeyPath 1.0.1 + +KeyPath 1.0.1 ran its keyboard engine as a background system service, with a +helper and the Karabiner virtual keyboard driver. Newer versions run while you +are signed in and need none of that. Both cannot change your keyboard at the +same time, so the new version will not start while the old service is running. + +## Remove the old service + +1. Open **Settings → Status**. It shows + **The keyboard service from KeyPath 1.0.1 is still running.** +2. Choose **Remove Old Service**. +3. Enter your administrator password when macOS asks. You are asked once. + +``` +┌ Keyboard remapping ──────────────────────────────────────────┐ +│ Runtime is stopped. The keyboard service from KeyPath 1.0.1 │ +│ is still running. Remove it to start KeyPath. │ +│ │ +│ [Remove Old Service] [Open System Settings] [Open Wizard] │ +└───────────────────────────────────────────────────────────────┘ +``` + +KeyPath stops the old service, removes it and its helper, checks that neither +is still running, and then starts. Your rules are kept. + +If you cancel the password prompt, nothing is removed. Choose +**Remove Old Service** again when you are ready. + +If KeyPath says the service was removed but it still detects it, restart your +Mac and open KeyPath again. + +## The Karabiner virtual keyboard driver + +KeyPath does not remove the Karabiner driver. Karabiner-Elements uses the same +driver, and removing it would break that app. After the cleanup, KeyPath tells +you whether the driver is still installed. + +If you do not use Karabiner-Elements and want the driver gone, open Terminal and run: + +``` +/Applications/.Karabiner-VirtualHIDDevice-Manager.app/Contents/MacOS/Karabiner-VirtualHIDDevice-Manager deactivate +``` + +macOS may ask you to approve the change. To remove the driver's remaining files, follow the uninstall steps in the +[Karabiner-DriverKit-VirtualHIDDevice instructions](https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice). From 41a682f8ca8a1fbbb95ff939e368ff890dd40ecb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 20:36:49 -0800 Subject: [PATCH 02/14] Address review: GUI domain, helper path, root UID, testable flow - Probe and boot out gui// as well as system/ for every legacy label. The refusal's own detector reads both, so the cleanup must agree with it. - Match /Library/PrivilegedHelperTools/com.keypath.helper in the process pattern; it was missed. A test now checks every 1.0.1 process path. - Count a process as legacy only when its owner UID is 0, read with sysctl, and re-check the UID as root before signalling. EPERM alone also matches another signed-in user's KeyPath. The real-process test grounds both PID 1 and this process. - Parse the real root script with bash -n in a test. - Move the Settings flow into LegacyRuntimeUpgradeFlow and test each branch. Drop Open Wizard from that state, since the wizard cannot detect 1.0.1. - Correct ADR-045: Settings is the only entry point today. - Fix misleading log and comments; document the turned-off and two-user cases. Co-Authored-By: Claude Opus 5.5 --- .../Managers/LegacyServiceRegistrations.swift | 11 +-- .../Services/Kanata/KanataDaemonService.swift | 4 +- .../Services/LegacyRuntimeUpgradeFlow.swift | 58 +++++++++++++++ .../Settings/SettingsView+StatusDetails.swift | 18 +++-- .../UI/Settings/SettingsView.swift | 42 ++++------- .../Core/LegacyPrivilegedRuntimeCleanup.swift | 74 ++++++++++++------- .../LegacyPrivilegedRuntimeCleanupTests.swift | 72 +++++++++++++----- .../LegacyRuntimeUpgradeFlowTests.swift | 69 +++++++++++++++++ ...adr-045-retire-privileged-runtime-stack.md | 11 ++- docs/testing/upgrade-from-1-0-1.md | 5 ++ guides/upgrading-from-1-0-1.md | 5 +- 11 files changed, 277 insertions(+), 92 deletions(-) create mode 100644 Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift create mode 100644 Tests/KeyPathTests/Services/LegacyRuntimeUpgradeFlowTests.swift diff --git a/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift b/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift index 9d595eb88..5a8ca9f6b 100644 --- a/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift +++ b/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift @@ -5,10 +5,11 @@ import ServiceManagement /// Drops the SMAppService registrations KeyPath 1.0.1 made for its kanata daemon /// and helper (#1353). Migration only: this never registers anything. /// -/// Best effort, and it needs no administrator prompt. Current builds no longer -/// ship those plists, so ServiceManagement often cannot resolve them; the root -/// cleanup batch boots the jobs out regardless. Success is judged by that -/// batch's verification, never by these calls. +/// Best effort, and it needs no administrator prompt. Release builds no longer +/// ship those plists, so ServiceManagement usually reports them not found and +/// this does nothing; a stale Login Items entry may then remain until macOS +/// drops it. The root batch boots the jobs out regardless, and success is +/// judged by that batch's verification, never by these calls. @MainActor enum LegacyServiceRegistrations { static func unregister() async -> [String] { @@ -33,7 +34,7 @@ enum LegacyServiceRegistrations { try await service(plistName).unregister() logs.append("[legacy-cleanup] \(plistName): unregistered") } catch { - logs.append("[legacy-cleanup] \(plistName): unregister failed (\(error.localizedDescription)); removing as administrator") + logs.append("[legacy-cleanup] \(plistName): unregister failed (\(error.localizedDescription)); the administrator step boots the job out") } await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: plistName) } diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 2f3492790..aeb7593ec 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -412,8 +412,8 @@ final class KanataDaemonService { func invalidateEvidenceCaches() async { await pidCache.invalidateCache() await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: Constants.daemonPlistName) - // A lingering registration with no process otherwise holds a prior - // `.running` for a few samples, which would refuse the next start. + // If a registration lingers with no process, the status logic holds a + // prior `.running` for a few samples, which would refuse the next start. enabledWithoutProcessSampleCount = 0 lastObservedState = .stopped } diff --git a/Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift b/Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift new file mode 100644 index 000000000..ab726f25c --- /dev/null +++ b/Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift @@ -0,0 +1,58 @@ +import KeyPathInstallationWizard + +/// What happens when a user upgrading from KeyPath 1.0.1 chooses Remove Old +/// Service (#1353): remove the privileged stack, confirm the refusal cleared on +/// fresh evidence, then start the session runtime. +@MainActor +enum LegacyRuntimeUpgradeFlow { + enum Outcome: Equatable { + case removalFailed(String) + /// Removal verified, yet the refusal's own detector still sees a daemon. + case stillDetected + case started(virtualHIDNotice: String?) + case removedButNotStarted(reason: String?, virtualHIDNotice: String?) + + var message: String { + switch self { + case let .removalFailed(reason): + reason + case .stillDetected: + "The old KeyPath service was removed, but KeyPath still detects it. Restart your Mac, then open KeyPath again." + case let .started(notice): + Self.join("Old KeyPath service removed. KeyPath is running.", notice) + case let .removedButNotStarted(reason, notice): + Self.join("Old KeyPath service removed. \(reason ?? "Turn KeyPath on with the switch above.")", notice) + } + } + + var isFailure: Bool { + switch self { + case .removalFailed, .stillDetected: true + case .started, .removedButNotStarted: false + } + } + + private static func join(_ message: String, _ notice: String?) -> String { + notice.map { "\(message) \($0)" } ?? message + } + } + + static func run( + remove: () async -> InstallerEngine.LegacyRuntimeRemovalResult, + refusalStillStands: () async -> Bool, + start: () async -> Bool, + lastError: () -> String? + ) async -> Outcome { + let result = await remove() + guard result.report.success else { + return .removalFailed(result.report.failureReason ?? "The old KeyPath service could not be removed.") + } + if await refusalStillStands() { + return .stillDetected + } + if await start() { + return .started(virtualHIDNotice: result.virtualHIDNotice) + } + return .removedButNotStarted(reason: lastError(), virtualHIDNotice: result.virtualHIDNotice) + } +} diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index 5dc3c5bc0..bbd2c1ca7 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift @@ -226,7 +226,8 @@ extension StatusSettingsTabView { } var actions: [StatusDetailAction] = [] - if Self.offersLegacyRuntimeRemoval(issues: wizardIssues) { + let offersLegacyRemoval = Self.offersLegacyRuntimeRemoval(issues: wizardIssues) + if offersLegacyRemoval { actions.append(StatusDetailAction( title: removingLegacyRuntime ? "Removing Old Service…" : "Remove Old Service", icon: "trash", @@ -235,14 +236,15 @@ extension StatusSettingsTabView { accessibilityTitle: "Remove the KeyPath 1.0.1 keyboard service" )) } - actions += [ - StatusDetailAction(title: isDriverless ? "Open System Settings" : "Open Login Items", icon: "list.bullet") { - SystemDiagnostics.open(isDriverless ? .systemSettings : .loginItems) - }, - StatusDetailAction(title: "Open Wizard", icon: "wand.and.stars") { + actions.append(StatusDetailAction(title: isDriverless ? "Open System Settings" : "Open Login Items", icon: "list.bullet") { + SystemDiagnostics.open(isDriverless ? .systemSettings : .loginItems) + }) + // The wizard does not detect the 1.0.1 service, so it cannot help here. + if !offersLegacyRemoval { + actions.append(StatusDetailAction(title: "Open Wizard", icon: "wand.and.stars") { wizardInitialPage = .summary - } - ] + }) + } return StatusDetail( title: isDriverless ? "Keyboard remapping" : "KeyPath Runtime", message: Self.stoppedMessage(lastError: kanataManager.lastError), diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index 0c751194b..06d1ebf93 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift @@ -491,37 +491,21 @@ struct StatusSettingsTabView: View { defer { removingLegacyRuntime = false } settingsToastManager.showInfo("Removing the old KeyPath service…") - let result = await InstallerEngine().removeLegacyPrivilegedRuntime(using: PrivilegeBroker()) - guard result.report.success else { - await refreshStatus() - settingsToastManager.showError( - result.report.failureReason ?? "The old KeyPath service could not be removed.", duration: 10 - ) - return - } - let lifecycle = kanataManager.underlyingManager.serviceLifecycleCoordinator - if await lifecycle.refreshLegacyDaemonRefusalAfterCleanup() { - await refreshStatus() - settingsToastManager.showError( - "The old KeyPath service was removed, but KeyPath still detects it. Restart your Mac, then open KeyPath again.", - duration: 10 - ) - return - } - - let started = await kanataManager.startKanata(reason: "KeyPath 1.0.1 service removed") + let outcome = await LegacyRuntimeUpgradeFlow.run( + remove: { await InstallerEngine().removeLegacyPrivilegedRuntime(using: PrivilegeBroker()) }, + refusalStillStands: { await lifecycle.refreshLegacyDaemonRefusalAfterCleanup() }, + start: { await kanataManager.startKanata(reason: "KeyPath 1.0.1 service removed") }, + lastError: { kanataManager.lastError } + ) await refreshStatus() - var message = started - ? "Old KeyPath service removed. KeyPath is running." - : "Old KeyPath service removed. \(kanataManager.lastError ?? "Turn KeyPath on with the switch above.")" - if let notice = result.virtualHIDNotice { - message += " \(notice)" - } - if started { - settingsToastManager.showSuccess(message, duration: 10) - } else { - settingsToastManager.showInfo(message, duration: 10) + switch outcome { + case .removalFailed, .stillDetected: + settingsToastManager.showError(outcome.message, duration: 10) + case .started: + settingsToastManager.showSuccess(outcome.message, duration: 10) + case .removedButNotStarted: + settingsToastManager.showInfo(outcome.message, duration: 10) } } diff --git a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift index 9ba8626c7..dd8758e75 100644 --- a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift +++ b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift @@ -10,7 +10,8 @@ import KeyPathWizardCore /// This is migration code, not a backend: it only stops and removes these /// items. Nothing here installs, registers, or repairs them. public enum LegacyPrivilegedRuntime { - /// Launchd labels 1.0.1 loaded into the system domain. + /// Launchd labels 1.0.1 used. Each is checked in the system domain and in the + /// signed-in user's GUI domain, as the refusal's own detector does. public static let serviceLabels = [ "com.keypath.kanata", "com.keypath.helper", @@ -41,9 +42,15 @@ public enum LegacyPrivilegedRuntime { "/var/log/com.keypath.kanata.stderr.log" ] - /// Matches the 1.0.1 kanata launcher, kanata, and the helper, by bundle path. - /// `pgrep -f` takes extended regular expressions. - public static let processPattern = "Library/(KeyPath|HelperTools)/" + /// Matches the 1.0.1 kanata launcher and kanata (in the bundle or + /// `/Library/KeyPath/bin`), the bundled helper, and the SMJobBless helper copy. + /// `pgrep -f` and `grep -E` both take this extended regular expression. + public static let processPattern = #"Library/(KeyPath|HelperTools)/|PrivilegedHelperTools/com\.keypath\.helper"# + + /// Every launchd target to check and boot out, for the user who runs the app. + public static func launchdTargets(userID: uid_t) -> [String] { + serviceLabels.flatMap { ["system/\($0)", "gui/\(userID)/\($0)"] } + } /// The Karabiner driver is shared with Karabiner-Elements, so KeyPath never /// removes it. These paths only decide what to tell the user. @@ -79,11 +86,11 @@ public struct LegacyVirtualHIDRemnants: Equatable, Sendable { /// Read-only observation of the 1.0.1 stack. Taken without administrator rights. public struct LegacyPrivilegedRuntimeEvidence: Equatable, Sendable { - /// Labels launchd reports as loaded in the system domain. + /// Launchd targets (`system/…` or `gui//…`) reported as loaded. public var loadedServices: [String] /// Labels whose state could not be read. Never treated as absent. public var unreadableServices: [String] - /// KeyPath processes that are alive but that this user cannot signal: root (ADR-040). + /// Live KeyPath processes owned by root. Another user's session is never included. public var rootProcesses: [SystemProcessMatch] public var leftoverFiles: [String] public var virtualHID: LegacyVirtualHIDRemnants @@ -133,7 +140,7 @@ public struct LegacyPrivilegedRuntimeEvidence: Equatable, Sendable { /// /// Order: best-effort SMAppService unregistration (no prompt), then a single /// root batch that boots out every legacy label, stops any root KeyPath process -/// the app cannot signal itself (ADR-040), and deletes KeyPath's own system +/// (which the app cannot signal itself, ADR-040), and deletes KeyPath's own system /// files. The VirtualHID driver is reported, never removed. @MainActor public struct LegacyPrivilegedRuntimeCleanup { @@ -178,9 +185,12 @@ public struct LegacyPrivilegedRuntimeCleanup { static let verificationInterval: Duration = .milliseconds(500) private let dependencies: Dependencies + /// The signed-in user whose GUI domain is also cleaned. + private let userID: uid_t - public init(dependencies: Dependencies) { + public init(dependencies: Dependencies, userID: uid_t = getuid()) { self.dependencies = dependencies + self.userID = userID } public func run() async -> Outcome { @@ -195,7 +205,7 @@ public struct LegacyPrivilegedRuntimeCleanup { logs += await dependencies.unregisterRegistrations() - let batch = Self.batch(stopping: before.rootProcesses.map(\.pid)) + let batch = Self.batch(stopping: before.rootProcesses.map(\.pid), userID: userID) var operationFailure: String? do { let result = try await dependencies.executeBatch(batch) @@ -237,9 +247,9 @@ public struct LegacyPrivilegedRuntimeCleanup { /// The single root script. Every step tolerates an already-absent item so a /// partial earlier run, or a Mac that never had one of them, still succeeds. - static func batch(stopping pids: [pid_t]) -> PrivilegedCommandRunner.Batch { - var commands = LegacyPrivilegedRuntime.serviceLabels.map { - "/bin/launchctl bootout system/\($0) 2>/dev/null || true" + static func batch(stopping pids: [pid_t], userID: uid_t) -> PrivilegedCommandRunner.Batch { + var commands = LegacyPrivilegedRuntime.launchdTargets(userID: userID).map { + "/bin/launchctl bootout \($0) 2>/dev/null || true" } let positive = pids.filter { $0 > 0 } if !positive.isEmpty { @@ -247,7 +257,8 @@ public struct LegacyPrivilegedRuntimeCleanup { // was recycled since the read-only probe is never killed. commands.append(""" for kp_pid in \(positive.map(String.init).joined(separator: " ")); do - if /bin/ps -o command= -p "$kp_pid" 2>/dev/null | /usr/bin/grep -qE '\(LegacyPrivilegedRuntime.processPattern)'; then + if [ "$(/bin/ps -o uid= -p "$kp_pid" 2>/dev/null | /usr/bin/tr -d ' ')" = "0" ] \\ + && /bin/ps -o command= -p "$kp_pid" 2>/dev/null | /usr/bin/grep -qE '\(LegacyPrivilegedRuntime.processPattern)'; then /bin/kill -TERM "$kp_pid" 2>/dev/null || true fi done @@ -284,8 +295,8 @@ public extension LegacyPrivilegedRuntimeCleanup { static let launchctlServiceNotFound: Int32 = 113 /// Live dependencies. Unregistration and the admin batch come from the app - /// via `WizardDependencies`; without them the cleanup fails rather than - /// silently skipping work. + /// via `WizardDependencies`. Without the batch the cleanup fails; without + /// unregistration it only logs, since that step is best effort anyway. static var live: Self { Self(dependencies: Dependencies( inspect: { await inspectLive() }, @@ -304,25 +315,26 @@ public extension LegacyPrivilegedRuntimeCleanup { static func inspectLive( provider: SystemStateProvider = .shared, + userID: uid_t = getuid(), fileExists: @escaping (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }, isAlive: @escaping (pid_t) -> Bool = { SystemStateProvider.isProcessAlive(pid: $0) }, - canSignal: @escaping (pid_t) -> Bool = { SystemStateProvider.canSignalProcess(pid: $0) } + ownerUID: @escaping (pid_t) -> uid_t? = { processOwnerUID($0) } ) async -> LegacyPrivilegedRuntimeEvidence { var loaded: [String] = [] var unreadable: [String] = [] - for label in LegacyPrivilegedRuntime.serviceLabels { - let evidence = await provider.launchctlPrint(target: "system/\(label)") + for target in LegacyPrivilegedRuntime.launchdTargets(userID: userID) { + let evidence = await provider.launchctlPrint(target: target) switch evidence.exitCode { - case 0: loaded.append(label) + case 0: loaded.append(target) case launchctlServiceNotFound: break - default: unreadable.append(label) + default: unreadable.append(target) } } let matches = await provider.processMatches(matching: LegacyPrivilegedRuntime.processPattern) return LegacyPrivilegedRuntimeEvidence( loadedServices: loaded, unreadableServices: unreadable, - rootProcesses: rootProcesses(in: matches, isAlive: isAlive, canSignal: canSignal), + rootProcesses: rootProcesses(in: matches, isAlive: isAlive, ownerUID: ownerUID), leftoverFiles: LegacyPrivilegedRuntime.systemFiles.filter(fileExists), virtualHID: LegacyVirtualHIDRemnants( driverInstalled: fileExists(LegacyPrivilegedRuntime.driverManagerApp) @@ -332,14 +344,24 @@ public extension LegacyPrivilegedRuntimeCleanup { ) } - /// Alive but not signalable means another user, in practice root (ADR-040: - /// only ESRCH means gone; EPERM means alive). The app's own session worker - /// is signalable and so never counted. + /// Root-owned and alive. Liveness admits EPERM (ADR-040: only ESRCH means + /// gone), but EPERM alone also matches another signed-in user's KeyPath, so + /// the owner is read explicitly. An unreadable owner is not root. static func rootProcesses( in matches: [SystemProcessMatch], isAlive: (pid_t) -> Bool, - canSignal: (pid_t) -> Bool + ownerUID: (pid_t) -> uid_t? ) -> [SystemProcessMatch] { - matches.filter { $0.pid > 0 && isAlive($0.pid) && !canSignal($0.pid) } + matches.filter { $0.pid > 0 && isAlive($0.pid) && ownerUID($0.pid) == 0 } + } + + /// The real user ID of a process, readable for any process without privileges. + static func processOwnerUID(_ pid: pid_t) -> uid_t? { + guard pid > 0 else { return nil } + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0, size > 0 else { return nil } + return info.kp_eproc.e_pcred.p_ruid } } diff --git a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift index 39c43444c..ae68deed1 100644 --- a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift @@ -19,7 +19,7 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { private var batchResult: Result<(exitCode: Int32, output: String), Error> = .success((0, "")) private static let installed101 = LegacyPrivilegedRuntimeEvidence( - loadedServices: ["com.keypath.kanata", "com.keypath.helper"], + loadedServices: ["system/com.keypath.kanata", "system/com.keypath.helper"], rootProcesses: [SystemProcessMatch( pid: 4242, command: "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher --port 37001" @@ -47,7 +47,7 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { return try batchResult.get() }, sleep: { [unowned self] _ in sleeps += 1 } - )) + ), userID: 501) } // MARK: - Cleanup @@ -62,7 +62,7 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { } func testOneAuthorizationRemovesTheStackAndWaitsForLaunchdTeardown() async { - let stillTearingDown = LegacyPrivilegedRuntimeEvidence(loadedServices: ["com.keypath.kanata"]) + let stillTearingDown = LegacyPrivilegedRuntimeEvidence(loadedServices: ["system/com.keypath.kanata"]) inspections = [Self.installed101, stillTearingDown, Self.clean] let outcome = await makeCleanup().run() @@ -77,6 +77,8 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { let script = batches[0].script for label in LegacyPrivilegedRuntime.serviceLabels { XCTAssertTrue(script.contains("/bin/launchctl bootout system/\(label)"), label) + XCTAssertTrue(script.contains("/bin/launchctl bootout gui/501/\(label)"), + "The refusal's detector also reads the GUI domain, so the cleanup must clean it") } for file in LegacyPrivilegedRuntime.systemFiles { XCTAssertTrue(script.contains("/bin/rm -f '\(file)'"), file) @@ -84,6 +86,8 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { XCTAssertTrue(script.contains("for kp_pid in 4242;")) XCTAssertTrue(script.contains("/bin/ps -o command= -p \"$kp_pid\""), "The PID must be re-checked as root before it is signalled") + XCTAssertTrue(script.contains("/bin/ps -o uid= -p \"$kp_pid\""), + "Only a root-owned PID may be signalled") } func testCancelledAuthorizationIsReportedAsCancelled() async { @@ -116,7 +120,7 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { } func testDaemonStillLoadedAfterTheBatchFailsVerification() async { - inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(loadedServices: ["com.keypath.kanata"])] + inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(loadedServices: ["system/com.keypath.kanata"])] let outcome = await makeCleanup().run() XCTAssertFalse(outcome.success) @@ -126,14 +130,14 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { } func testUnreadableServiceStateIsNeverTreatedAsGone() async { - inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(unreadableServices: ["com.keypath.kanata"])] + inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(unreadableServices: ["system/com.keypath.kanata"])] let outcome = await makeCleanup().run() XCTAssertFalse(outcome.success) XCTAssertTrue(outcome.failureReason?.contains("could not check") == true) } func testVirtualHIDDriverIsReportedButNeverRemoved() async { - let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [4242]).script + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [4242], userID: 501).script XCTAssertFalse(script.contains("systemextensionsctl")) XCTAssertFalse(script.contains("org.pqrs")) XCTAssertFalse(script.contains("Karabiner-VirtualHIDDevice-Manager")) @@ -149,32 +153,65 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { XCTAssertEqual(outcome.virtualHIDNotice, alone.notice) } + /// Parses the real root script without running it (`bash -n`). + func testBatchScriptIsValidShell() throws { + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [4242, 4343], userID: 501).script + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-n", "-c", script] + let errors = Pipe() + process.standardError = errors + try process.run() + process.waitUntilExit() + let message = String(data: errors.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + XCTAssertEqual(process.terminationStatus, 0, message) + } + func testBatchWithoutRootProcessesHasNoSignalStep() { - let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [0, -1]).script + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [0, -1], userID: 501).script XCTAssertFalse(script.contains("kp_pid")) } // MARK: - Live probes - func testRootProcessFilterTreatsEPERMAsAliveAndSkipsSignalableProcesses() { - let matches = [101, 102, 103, 0].map { SystemProcessMatch(pid: pid_t($0), command: "x") } + func testProcessPatternMatchesEvery101ProcessPath() throws { + let regex = try NSRegularExpression(pattern: LegacyPrivilegedRuntime.processPattern) + func matches(_ command: String) -> Bool { + regex.firstMatch(in: command, range: NSRange(command.startIndex..., in: command)) != nil + } + XCTAssertTrue(matches("/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher --port 37001")) + XCTAssertTrue(matches("/Applications/KeyPath.app/Contents/Library/KeyPath/Kanata Engine.app/Contents/MacOS/kanata --cfg x")) + XCTAssertTrue(matches("/Applications/KeyPath.app/Contents/Library/HelperTools/KeyPathHelper")) + XCTAssertTrue(matches("/Library/PrivilegedHelperTools/com.keypath.helper")) + XCTAssertTrue(matches("/Library/KeyPath/bin/kanata --cfg /Users/x/.config/keypath/keypath.kbd")) + XCTAssertFalse(matches("/Applications/KeyPath.app/Contents/MacOS/KeyPath")) + XCTAssertFalse(matches("/opt/homebrew/bin/kanata --cfg x")) + } + + func testOnlyLiveRootProcessesCountNotAnotherUsersSession() { + let matches = [101, 102, 103, 104, 0].map { SystemProcessMatch(pid: pid_t($0), command: "x") } + let owners: [pid_t: uid_t] = [101: 0, 102: 501, 103: 0] let root = LegacyPrivilegedRuntimeCleanup.rootProcesses( in: matches, isAlive: { $0 != 103 }, - canSignal: { $0 == 102 } + ownerUID: { owners[$0] } ) + // 102 is another signed-in user's KeyPath; 103 is gone; 104's owner is unreadable. XCTAssertEqual(root.map(\.pid), [101]) } /// ADR-040: ground the real primitives once. launchd (PID 1) is root and - /// alive, so probing it returns EPERM; this test process is signalable. + /// alive even though probing it returns EPERM; this test process is not root. func testRootProcessFilterAgainstRealProcesses() { + XCTAssertEqual(LegacyPrivilegedRuntimeCleanup.processOwnerUID(1), 0) + XCTAssertEqual(LegacyPrivilegedRuntimeCleanup.processOwnerUID(getpid()), getuid()) + XCTAssertFalse(SystemStateProvider.canSignalProcess(pid: 1), "PID 1 must be the EPERM case") let matches = [SystemProcessMatch(pid: 1, command: "launchd"), SystemProcessMatch(pid: getpid(), command: "test")] let root = LegacyPrivilegedRuntimeCleanup.rootProcesses( in: matches, isAlive: { SystemStateProvider.isProcessAlive(pid: $0) }, - canSignal: { SystemStateProvider.canSignalProcess(pid: $0) } + ownerUID: { LegacyPrivilegedRuntimeCleanup.processOwnerUID($0) } ) XCTAssertEqual(root.map(\.pid), [1]) } @@ -185,7 +222,7 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { processMatches: { _ in [SystemProcessMatch(pid: 77, command: "/Library/KeyPath/bin/kanata --cfg x")] }, launchctlPrint: { target in let code: Int32 = switch target { - case "system/com.keypath.kanata": 0 + case "system/com.keypath.kanata", "gui/501/com.keypath.kanata": 0 case "system/com.keypath.helper": 1 default: 113 } @@ -199,12 +236,13 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { LegacyPrivilegedRuntime.driverSupportDirectory] let evidence = await LegacyPrivilegedRuntimeCleanup.inspectLive( provider: SystemStateProvider(probes: probes), + userID: 501, fileExists: { present.contains($0) }, isAlive: { _ in true }, - canSignal: { _ in false } + ownerUID: { _ in 0 } ) - XCTAssertEqual(evidence.loadedServices, ["com.keypath.kanata"]) - XCTAssertEqual(evidence.unreadableServices, ["com.keypath.helper"]) + XCTAssertEqual(evidence.loadedServices, ["system/com.keypath.kanata", "gui/501/com.keypath.kanata"]) + XCTAssertEqual(evidence.unreadableServices, ["system/com.keypath.helper"]) XCTAssertEqual(evidence.rootProcesses.map(\.pid), [77]) XCTAssertEqual(evidence.leftoverFiles, ["/Library/KeyPath/bin/kanata"]) XCTAssertEqual(evidence.virtualHID, LegacyVirtualHIDRemnants(driverInstalled: true, karabinerElementsInstalled: true)) @@ -244,7 +282,7 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { } func testEngineReportsVerificationFailure() async { - inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(loadedServices: ["com.keypath.kanata"])] + inspections = [Self.installed101, LegacyPrivilegedRuntimeEvidence(loadedServices: ["system/com.keypath.kanata"])] let result = await makeEngine(context: SystemContextBuilder(componentsInstalled: true).build()) .removeLegacyPrivilegedRuntime(using: PrivilegeBroker()) XCTAssertFalse(result.report.success) diff --git a/Tests/KeyPathTests/Services/LegacyRuntimeUpgradeFlowTests.swift b/Tests/KeyPathTests/Services/LegacyRuntimeUpgradeFlowTests.swift new file mode 100644 index 000000000..56e394766 --- /dev/null +++ b/Tests/KeyPathTests/Services/LegacyRuntimeUpgradeFlowTests.swift @@ -0,0 +1,69 @@ +@testable import KeyPathAppKit +@testable import KeyPathInstallationWizard +import XCTest + +/// Every branch of Remove Old Service in Settings → Status (#1353). +@MainActor +final class LegacyRuntimeUpgradeFlowTests: KeyPathTestCase { + private var refreshes = 0 + private var starts = 0 + + private func result(success: Bool, reason: String? = nil, notice: String? = nil) + -> InstallerEngine.LegacyRuntimeRemovalResult + { + .init(report: InstallerReport(success: success, failureReason: reason), virtualHIDNotice: notice) + } + + private func run( + _ removal: InstallerEngine.LegacyRuntimeRemovalResult, + stillDetected: Bool = false, + started: Bool = true, + lastError: String? = nil + ) async -> LegacyRuntimeUpgradeFlow.Outcome { + await LegacyRuntimeUpgradeFlow.run( + remove: { removal }, + refusalStillStands: { [unowned self] in + refreshes += 1 + return stillDetected + }, + start: { [unowned self] in + starts += 1 + return started + }, + lastError: { lastError } + ) + } + + func testFailedRemovalNeverStarts() async { + let outcome = await run(result(success: false, reason: "Administrator authorization was cancelled.")) + XCTAssertEqual(outcome, .removalFailed("Administrator authorization was cancelled.")) + XCTAssertTrue(outcome.isFailure) + XCTAssertEqual(refreshes, 0) + XCTAssertEqual(starts, 0) + } + + func testStillDetectedDaemonNeverStarts() async { + let outcome = await run(result(success: true), stillDetected: true) + XCTAssertEqual(outcome, .stillDetected) + XCTAssertTrue(outcome.message.contains("Restart your Mac")) + XCTAssertEqual(starts, 0) + } + + func testVerifiedRemovalStartsAndNamesTheDriver() async { + let outcome = await run(result(success: true, notice: "The driver is still installed.")) + XCTAssertEqual(outcome, .started(virtualHIDNotice: "The driver is still installed.")) + XCTAssertEqual(outcome.message, "Old KeyPath service removed. KeyPath is running. The driver is still installed.") + XCTAssertFalse(outcome.isFailure) + XCTAssertEqual(refreshes, 1) + XCTAssertEqual(starts, 1) + } + + func testRemovedButNotStartedSaysWhy() async { + let outcome = await run(result(success: true), started: false, lastError: "Accessibility is off.") + XCTAssertEqual(outcome.message, "Old KeyPath service removed. Accessibility is off.") + XCTAssertFalse(outcome.isFailure, "The removal itself succeeded") + + let unexplained = await run(result(success: true), started: false) + XCTAssertEqual(unexplained.message, "Old KeyPath service removed. Turn KeyPath on with the switch above.") + } +} diff --git a/docs/adr/adr-045-retire-privileged-runtime-stack.md b/docs/adr/adr-045-retire-privileged-runtime-stack.md index fd0ab57a6..e7ca9ca09 100644 --- a/docs/adr/adr-045-retire-privileged-runtime-stack.md +++ b/docs/adr/adr-045-retire-privileged-runtime-stack.md @@ -49,11 +49,14 @@ upgrade directly. **Upgrade path (2026-11, #1353).** When the refusal stands, Settings → Status offers **Remove Old Service**, which calls -`InstallerEngine.removeLegacyPrivilegedRuntime` (also reachable as -`AutoFixAction.removeLegacyPrivilegedRuntime`). It runs +`InstallerEngine.removeLegacyPrivilegedRuntime`. The published refusal issue +carries `AutoFixAction.removeLegacyPrivilegedRuntime`, and `runSingleAction` +routes it, but the wizard does not detect the refusal yet (its snapshot has no +legacy evidence), so Settings is the only entry point today. It runs `LegacyPrivilegedRuntimeCleanup`: best-effort SMAppService unregistration, then -one administrator batch that boots out every `com.keypath.*` label 1.0.1 used, -stops root KeyPath processes the app cannot signal (ADR-040), and deletes +one administrator batch that boots out every `com.keypath.*` label 1.0.1 used +(system and GUI domains), stops root-owned KeyPath processes (the app cannot +signal them, ADR-040), and deletes KeyPath's own system files. It reports success only after a fresh read-only observation finds no loaded legacy service, no root KeyPath process, and no legacy file. The Karabiner driver is reported, never removed. It is not a diff --git a/docs/testing/upgrade-from-1-0-1.md b/docs/testing/upgrade-from-1-0-1.md index 4082943d5..b6bcdcd2c 100644 --- a/docs/testing/upgrade-from-1-0-1.md +++ b/docs/testing/upgrade-from-1-0-1.md @@ -60,6 +60,11 @@ Record each run at the bottom, with the build under test and the lease. - **Sparkle update** from 1.0.1 instead of a manual replace, if 1.0.1's appcast can reach the build under test. - **Already removed**: run step 8 a second time. Expect no password prompt. +- **Switched off in 1.0.1**: turn remapping off in 1.0.1 before upgrading. + Expect "Turned off" after the upgrade; turning it on shows the refusal and the + button. Note whether the 1.0.1 daemon kept remapping while KeyPath said off. +- **Two macOS users**: with a second account signed in (fast user switching) + running KeyPath, the cleanup must not stop that account's processes. ## Results diff --git a/guides/upgrading-from-1-0-1.md b/guides/upgrading-from-1-0-1.md index 7d484b22f..58e53403a 100644 --- a/guides/upgrading-from-1-0-1.md +++ b/guides/upgrading-from-1-0-1.md @@ -24,13 +24,16 @@ same time, so the new version will not start while the old service is running. │ Runtime is stopped. The keyboard service from KeyPath 1.0.1 │ │ is still running. Remove it to start KeyPath. │ │ │ -│ [Remove Old Service] [Open System Settings] [Open Wizard] │ +│ [Remove Old Service] [Open System Settings] │ └───────────────────────────────────────────────────────────────┘ ``` KeyPath stops the old service, removes it and its helper, checks that neither is still running, and then starts. Your rules are kept. +If **Settings → Status** says KeyPath is **Turned off**, turn it on with the +switch first. The **Remove Old Service** button appears once KeyPath tries to start. + If you cancel the password prompt, nothing is removed. Choose **Remove Old Service** again when you are ready. From 21c84611f74a1ff261e972e9fd5e5a036e8caf81 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 20:51:11 -0800 Subject: [PATCH 03/14] Render Remove Old Service where Settings actually shows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Status never renders StatusDetail.actions, so the button added earlier did not exist on screen. While the 1.0.1 refusal stands, the hero's primary button is now Remove Old Service (in place of Fix it, which opens a wizard that cannot detect 1.0.1), and the hero message is the refusal. Also from review: - Drop the SMAppService unregistration step. Release builds do not ship 1.0.1's plists, so it could never run; the ADR now says the record is left. - Bound each launchctl bootout with kp_timeout (#927). - Remove 1.0.1's other KeyPath-owned logs. - Delete an unused constant. Co-Authored-By: Claude Opus 5.5 --- .../Managers/LegacyServiceRegistrations.swift | 43 ----------------- .../Settings/SettingsView+StatusDetails.swift | 35 +++++--------- .../UI/Settings/SettingsView.swift | 18 ++++++- .../WizardProtocolConformances.swift | 3 -- .../Core/LegacyPrivilegedRuntimeCleanup.swift | 46 ++++++++---------- .../WizardDependencies.swift | 4 -- .../LegacyPrivilegedRuntimeCleanupTests.swift | 48 +------------------ ...adr-045-retire-privileged-runtime-stack.md | 8 ++-- guides/upgrading-from-1-0-1.md | 13 ++--- 9 files changed, 64 insertions(+), 154 deletions(-) delete mode 100644 Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift diff --git a/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift b/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift deleted file mode 100644 index 5a8ca9f6b..000000000 --- a/Sources/KeyPathAppKit/Managers/LegacyServiceRegistrations.swift +++ /dev/null @@ -1,43 +0,0 @@ -import KeyPathCore -import KeyPathInstallationWizard -import ServiceManagement - -/// Drops the SMAppService registrations KeyPath 1.0.1 made for its kanata daemon -/// and helper (#1353). Migration only: this never registers anything. -/// -/// Best effort, and it needs no administrator prompt. Release builds no longer -/// ship those plists, so ServiceManagement usually reports them not found and -/// this does nothing; a stale Login Items entry may then remain until macOS -/// drops it. The root batch boots the jobs out regardless, and success is -/// judged by that batch's verification, never by these calls. -@MainActor -enum LegacyServiceRegistrations { - static func unregister() async -> [String] { - await unregister( - statusFor: { await SystemStateProvider.shared.freshSMAppServiceStatus(for: $0) }, - service: { HelperManager.smServiceFactory($0) } - ) - } - - static func unregister( - statusFor: (String) async -> SMAppService.Status, - service: (String) -> SMAppServiceProtocol - ) async -> [String] { - var logs: [String] = [] - for plistName in LegacyPrivilegedRuntime.serviceManagementPlists { - let initial = await statusFor(plistName) - guard initial == .enabled || initial == .requiresApproval else { - logs.append("[legacy-cleanup] \(plistName): not registered with ServiceManagement") - continue - } - do { - try await service(plistName).unregister() - logs.append("[legacy-cleanup] \(plistName): unregistered") - } catch { - logs.append("[legacy-cleanup] \(plistName): unregister failed (\(error.localizedDescription)); the administrator step boots the job out") - } - await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: plistName) - } - return logs - } -} diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index bbd2c1ca7..f6719c45b 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift @@ -128,7 +128,11 @@ extension StatusSettingsTabView { } var primaryIssueDetail: StatusDetail? { - statusDetails + // The 1.0.1 refusal explains the Remove Old Service button; nothing outranks it. + if Self.offersLegacyRuntimeRemoval(issues: wizardIssues) { + return serviceStatusDetail + } + return statusDetails .sorted { $0.level.rawValue > $1.level.rawValue } .first(where: { $0.level.isIssue }) } @@ -225,32 +229,19 @@ extension StatusSettingsTabView { ) } - var actions: [StatusDetailAction] = [] - let offersLegacyRemoval = Self.offersLegacyRuntimeRemoval(issues: wizardIssues) - if offersLegacyRemoval { - actions.append(StatusDetailAction( - title: removingLegacyRuntime ? "Removing Old Service…" : "Remove Old Service", - icon: "trash", - handler: { Task { await removeLegacyRuntimeAndStart() } }, - identifier: "status-action-remove-legacy-service", - accessibilityTitle: "Remove the KeyPath 1.0.1 keyboard service" - )) - } - actions.append(StatusDetailAction(title: isDriverless ? "Open System Settings" : "Open Login Items", icon: "list.bullet") { - SystemDiagnostics.open(isDriverless ? .systemSettings : .loginItems) - }) - // The wizard does not detect the 1.0.1 service, so it cannot help here. - if !offersLegacyRemoval { - actions.append(StatusDetailAction(title: "Open Wizard", icon: "wand.and.stars") { - wizardInitialPage = .summary - }) - } return StatusDetail( title: isDriverless ? "Keyboard remapping" : "KeyPath Runtime", message: Self.stoppedMessage(lastError: kanataManager.lastError), icon: "pause.circle", level: .warning, - actions: actions + actions: [ + StatusDetailAction(title: isDriverless ? "Open System Settings" : "Open Login Items", icon: "list.bullet") { + SystemDiagnostics.open(isDriverless ? .systemSettings : .loginItems) + }, + StatusDetailAction(title: "Open Wizard", icon: "wand.and.stars") { + wizardInitialPage = .summary + } + ] ) } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index 06d1ebf93..9b80f7746 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift @@ -131,7 +131,23 @@ struct StatusSettingsTabView: View { // Action button when there are problems. Suppressed when the // user has intentionally turned the service off — nothing is // broken, so there's nothing to fix. - if !isSystemHealthy, !isIntentionallyDisabled { + // The wizard cannot detect KeyPath 1.0.1's service, so its + // removal replaces "Fix it" while that refusal stands (#1353). + if Self.offersLegacyRuntimeRemoval(issues: wizardIssues) { + Button(action: { Task { await removeLegacyRuntimeAndStart() } }) { + Label( + removingLegacyRuntime ? "Removing Old Service…" : "Remove Old Service", + systemImage: "trash" + ) + .font(.body.weight(.semibold)) + } + .settingsPrimaryAction() + .controlSize(.large) + .tint(.orange) + .disabled(removingLegacyRuntime) + .accessibilityIdentifier("status-remove-legacy-service-button") + .accessibilityLabel("Remove the KeyPath 1.0.1 keyboard service") + } else if !isSystemHealthy, !isIntentionallyDisabled { Button(action: { wizardInitialPage = .summary }) { Label("Fix it", systemImage: "wand.and.stars") .font(.body.weight(.semibold)) diff --git a/Sources/KeyPathAppKit/WizardProtocolConformances.swift b/Sources/KeyPathAppKit/WizardProtocolConformances.swift index e75e2be48..7ccdd9e0a 100644 --- a/Sources/KeyPathAppKit/WizardProtocolConformances.swift +++ b/Sources/KeyPathAppKit/WizardProtocolConformances.swift @@ -169,9 +169,6 @@ public func configureWizardDependencies(runtimeCoordinator: RuntimeCoordinator) let result = try await AdminCommandExecutorHolder.shared.execute(batch: batch) return (exitCode: result.exitCode, output: result.output) } - WizardDependencies.unregisterLegacyServiceRegistrations = { - await LegacyServiceRegistrations.unregister() - } WizardDependencies.resourceBundle = KeyPathAppKitResources.bundle // ExternalKanataService static methods diff --git a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift index dd8758e75..95850e212 100644 --- a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift +++ b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift @@ -20,13 +20,6 @@ public enum LegacyPrivilegedRuntime { "com.keypath.logrotate" ] - /// Only these two keep the session runtime from starting. The others are - /// removed because they are KeyPath's own, not because they conflict. - public static let blockingServiceLabels: Set = ["com.keypath.kanata", "com.keypath.helper"] - - /// SMAppService plist names 1.0.1 registered from its app bundle. - public static let serviceManagementPlists = ["com.keypath.kanata.plist", "com.keypath.helper.plist"] - /// Root-owned files that only KeyPath creates. Each is removed with `rm -f`. public static let systemFiles = [ "/Library/LaunchDaemons/com.keypath.kanata.plist", @@ -39,7 +32,14 @@ public enum LegacyPrivilegedRuntime { "/etc/newsyslog.d/com.keypath.conf", "/usr/local/bin/keypath-logrotate.sh", "/var/log/com.keypath.kanata.stdout.log", - "/var/log/com.keypath.kanata.stderr.log" + "/var/log/com.keypath.kanata.stderr.log", + "/var/log/com.keypath.helper.stdout.log", + "/var/log/com.keypath.helper.stderr.log", + "/var/log/com.keypath.output-bridge.stdout.log", + "/var/log/com.keypath.output-bridge.stderr.log", + "/var/log/karabiner-vhid-daemon.log", + "/var/log/karabiner-vhid-manager.log", + "/var/log/keypath-logrotate.log" ] /// Matches the 1.0.1 kanata launcher and kanata (in the bundle or @@ -138,27 +138,27 @@ public struct LegacyPrivilegedRuntimeEvidence: Equatable, Sendable { /// Removes the KeyPath 1.0.1 privileged stack with one administrator prompt and /// reports success only after a fresh observation finds none of it left. /// -/// Order: best-effort SMAppService unregistration (no prompt), then a single -/// root batch that boots out every legacy label, stops any root KeyPath process -/// (which the app cannot signal itself, ADR-040), and deletes KeyPath's own system -/// files. The VirtualHID driver is reported, never removed. +/// One root batch boots out every legacy label, stops any root KeyPath process +/// (which the app cannot signal itself, ADR-040), and deletes KeyPath's own +/// system files. The VirtualHID driver is reported, never removed. +/// +/// 1.0.1's ServiceManagement records are not unregistered: release builds no +/// longer ship those plists, so `SMAppService` cannot resolve them. The record +/// cannot load a job once the bundle lacks its plist and program; at worst a +/// stale Login Items entry remains until macOS drops it. @MainActor public struct LegacyPrivilegedRuntimeCleanup { public struct Dependencies { public var inspect: () async -> LegacyPrivilegedRuntimeEvidence - /// Asks ServiceManagement to drop 1.0.1's registrations. Returns log lines. - public var unregisterRegistrations: () async -> [String] public var executeBatch: (PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String) public var sleep: (Duration) async -> Void public init( inspect: @escaping () async -> LegacyPrivilegedRuntimeEvidence, - unregisterRegistrations: @escaping () async -> [String], executeBatch: @escaping (PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String), sleep: @escaping (Duration) async -> Void = { try? await Task.sleep(for: $0) } ) { self.inspect = inspect - self.unregisterRegistrations = unregisterRegistrations self.executeBatch = executeBatch self.sleep = sleep } @@ -203,8 +203,6 @@ public struct LegacyPrivilegedRuntimeCleanup { failureReason: nil, logs: logs) } - logs += await dependencies.unregisterRegistrations() - let batch = Self.batch(stopping: before.rootProcesses.map(\.pid), userID: userID) var operationFailure: String? do { @@ -249,7 +247,8 @@ public struct LegacyPrivilegedRuntimeCleanup { /// partial earlier run, or a Mac that never had one of them, still succeeds. static func batch(stopping pids: [pid_t], userID: uid_t) -> PrivilegedCommandRunner.Batch { var commands = LegacyPrivilegedRuntime.launchdTargets(userID: userID).map { - "/bin/launchctl bootout \($0) 2>/dev/null || true" + // `bootout` can block on a job that will not exit (#927); bound it. + "kp_timeout 15 /bin/launchctl bootout \($0) 2>/dev/null || true" } let positive = pids.filter { $0 > 0 } if !positive.isEmpty { @@ -294,16 +293,11 @@ public extension LegacyPrivilegedRuntimeCleanup { /// launchd's "Could not find service" status for `launchctl print`. static let launchctlServiceNotFound: Int32 = 113 - /// Live dependencies. Unregistration and the admin batch come from the app - /// via `WizardDependencies`. Without the batch the cleanup fails; without - /// unregistration it only logs, since that step is best effort anyway. + /// Live dependencies. The admin batch comes from the app via + /// `WizardDependencies`; without it the cleanup fails. static var live: Self { Self(dependencies: Dependencies( inspect: { await inspectLive() }, - unregisterRegistrations: { - await WizardDependencies.unregisterLegacyServiceRegistrations?() - ?? ["[legacy-cleanup] ServiceManagement unregistration not configured"] - }, executeBatch: { batch in guard let execute = WizardDependencies.executePrivilegedBatch else { throw InstallerError.unknownRecipe("Administrator commands are not configured") diff --git a/Sources/KeyPathWizardCore/WizardDependencies.swift b/Sources/KeyPathWizardCore/WizardDependencies.swift index 98d0448e0..32f76a7a3 100644 --- a/Sources/KeyPathWizardCore/WizardDependencies.swift +++ b/Sources/KeyPathWizardCore/WizardDependencies.swift @@ -66,9 +66,6 @@ public enum WizardDependencies { /// AdminCommandExecutor - execute(batch:) for privileged commands public static var executePrivilegedBatch: ((PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String))? - /// Drops KeyPath 1.0.1's SMAppService registrations (migration only, #1353). Returns log lines. - public static var unregisterLegacyServiceRegistrations: (() async -> [String])? - /// KeyPathAppKitResources bundle accessor public static var resourceBundle: Bundle? @@ -108,7 +105,6 @@ public enum WizardDependencies { helperNeedsApproval = nil createUninstallCoordinator = nil executePrivilegedBatch = nil - unregisterLegacyServiceRegistrations = nil resourceBundle = nil getExternalKanataInfo = nil stopExternalKanata = nil diff --git a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift index ae68deed1..3b7e6c128 100644 --- a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift @@ -3,7 +3,6 @@ import KeyPathCore import KeyPathDaemonLifecycle @testable import KeyPathInstallationWizard import KeyPathWizardCore -import ServiceManagement import XCTest /// The upgrade path from public 1.0.1 (#1353): one administrator prompt removes @@ -14,7 +13,6 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { private var inspections: [LegacyPrivilegedRuntimeEvidence] = [] private var inspectCount = 0 private var batches: [PrivilegedCommandRunner.Batch] = [] - private var unregisterCount = 0 private var sleeps = 0 private var batchResult: Result<(exitCode: Int32, output: String), Error> = .success((0, "")) @@ -38,10 +36,6 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { // Repeat the last reading once the script runs out. return inspections.isEmpty ? Self.clean : (inspections.count > 1 ? inspections.removeFirst() : inspections[0]) }, - unregisterRegistrations: { [unowned self] in - unregisterCount += 1 - return ["unregistered"] - }, executeBatch: { [unowned self] batch in batches.append(batch) return try batchResult.get() @@ -58,7 +52,6 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { XCTAssertTrue(outcome.success) XCTAssertFalse(outcome.requestedAuthorization) XCTAssertTrue(batches.isEmpty, "A Mac without 1.0.1 must never see an administrator prompt") - XCTAssertEqual(unregisterCount, 0) } func testOneAuthorizationRemovesTheStackAndWaitsForLaunchdTeardown() async { @@ -69,15 +62,14 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { XCTAssertTrue(outcome.success, outcome.failureReason ?? "") XCTAssertTrue(outcome.requestedAuthorization) XCTAssertEqual(batches.count, 1, "The whole cleanup must take one administrator prompt") - XCTAssertEqual(unregisterCount, 1) XCTAssertEqual(inspectCount, 3) XCTAssertEqual(sleeps, 1) XCTAssertTrue(outcome.after.isClean) let script = batches[0].script for label in LegacyPrivilegedRuntime.serviceLabels { - XCTAssertTrue(script.contains("/bin/launchctl bootout system/\(label)"), label) - XCTAssertTrue(script.contains("/bin/launchctl bootout gui/501/\(label)"), + XCTAssertTrue(script.contains("kp_timeout 15 /bin/launchctl bootout system/\(label)"), label) + XCTAssertTrue(script.contains("kp_timeout 15 /bin/launchctl bootout gui/501/\(label)"), "The refusal's detector also reads the GUI domain, so the cleanup must clean it") } for file in LegacyPrivilegedRuntime.systemFiles { @@ -302,40 +294,4 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { XCTAssertTrue(report.failureReason?.contains("unavailable in the driverless build") == true) XCTAssertTrue(batches.isEmpty) } - - // MARK: - ServiceManagement unregistration - - func testUnregistersOnlyRegisteredServicesAndToleratesFailure() async { - let kanata = RecordingSMAppService(failsUnregister: true) - let helper = RecordingSMAppService(failsUnregister: false) - let logs = await LegacyServiceRegistrations.unregister( - statusFor: { $0 == "com.keypath.kanata.plist" ? .enabled : .notFound }, - service: { $0 == "com.keypath.kanata.plist" ? kanata : helper } - ) - XCTAssertEqual(kanata.unregisterCalls, 1) - XCTAssertEqual(helper.unregisterCalls, 0) - XCTAssertTrue(logs.contains { $0.contains("com.keypath.kanata.plist: unregister failed") }) - XCTAssertTrue(logs.contains { $0.contains("com.keypath.helper.plist: not registered") }) - } -} - -private final class RecordingSMAppService: SMAppServiceProtocol, @unchecked Sendable { - let failsUnregister: Bool - private(set) var unregisterCalls = 0 - - init(failsUnregister: Bool) { - self.failsUnregister = failsUnregister - } - - var status: SMAppService.Status { - .notFound - } - - func register() throws {} - func unregister() async throws { - unregisterCalls += 1 - if failsUnregister { - throw NSError(domain: "test", code: 1) - } - } } diff --git a/docs/adr/adr-045-retire-privileged-runtime-stack.md b/docs/adr/adr-045-retire-privileged-runtime-stack.md index e7ca9ca09..eda3af5ea 100644 --- a/docs/adr/adr-045-retire-privileged-runtime-stack.md +++ b/docs/adr/adr-045-retire-privileged-runtime-stack.md @@ -53,13 +53,15 @@ offers **Remove Old Service**, which calls carries `AutoFixAction.removeLegacyPrivilegedRuntime`, and `runSingleAction` routes it, but the wizard does not detect the refusal yet (its snapshot has no legacy evidence), so Settings is the only entry point today. It runs -`LegacyPrivilegedRuntimeCleanup`: best-effort SMAppService unregistration, then -one administrator batch that boots out every `com.keypath.*` label 1.0.1 used +`LegacyPrivilegedRuntimeCleanup`: one administrator batch that boots out every `com.keypath.*` label 1.0.1 used (system and GUI domains), stops root-owned KeyPath processes (the app cannot signal them, ADR-040), and deletes KeyPath's own system files. It reports success only after a fresh read-only observation finds no loaded legacy service, no root KeyPath process, and no -legacy file. The Karabiner driver is reported, never removed. It is not a +legacy file. The Karabiner driver is reported, never removed. 1.0.1's +ServiceManagement records are left: release builds do not ship those plists, so +`SMAppService` cannot resolve them, and without plist and program the record +cannot load a job (a stale Login Items entry may remain). It is not a recipe: privileged recipes stay unavailable. Its live dependency on the retired stack is limited to the label, plist, and path lists in that file. The clean-VM run is still outstanding: `docs/testing/upgrade-from-1-0-1.md`. diff --git a/guides/upgrading-from-1-0-1.md b/guides/upgrading-from-1-0-1.md index 58e53403a..e3e6b4d4c 100644 --- a/guides/upgrading-from-1-0-1.md +++ b/guides/upgrading-from-1-0-1.md @@ -20,12 +20,13 @@ same time, so the new version will not start while the old service is running. 3. Enter your administrator password when macOS asks. You are asked once. ``` -┌ Keyboard remapping ──────────────────────────────────────────┐ -│ Runtime is stopped. The keyboard service from KeyPath 1.0.1 │ -│ is still running. Remove it to start KeyPath. │ -│ │ -│ [Remove Old Service] [Open System Settings] │ -└───────────────────────────────────────────────────────────────┘ + ( ! ) + Service Stopped + Runtime is stopped. The keyboard service from + KeyPath 1.0.1 is still running. Remove it to + start KeyPath. + + [ Remove Old Service ] ``` KeyPath stops the old service, removes it and its helper, checks that neither From 7ee0b276250cb24888b5e183d61eba8a294c4692 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 21:11:33 -0800 Subject: [PATCH 04/14] Match only KeyPath executables; list the upgrade guide in Help The process pattern matched any Contents/Library/HelperTools/ path, so a root helper from another app could be counted as legacy and sent SIGTERM. Anchor it on KeyPath.app, /Library/KeyPath/bin, and com.keypath.helper, with tests for another vendor's helper. The driver notice points to "Upgrading from KeyPath 1.0.1" in KeyPath Help, but Help only lists HelpTopic.allTopics. Add the guide there. Co-Authored-By: Claude Opus 5.5 --- .../UI/Help/HelpBrowserView.swift | 23 +++++++++++++++---- .../Core/LegacyPrivilegedRuntimeCleanup.swift | 8 ++++--- .../LegacyPrivilegedRuntimeCleanupTests.swift | 3 +++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift index 54bce5e32..57cc5da86 100644 --- a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift +++ b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift @@ -32,6 +32,7 @@ extension HelpTopic { HelpTopic(id: "installation", title: "Installation Guide", icon: "shippingbox", resource: "installation-wizard", group: .gettingStarted), HelpTopic(id: "concepts", title: "Keyboard Concepts", icon: "keyboard", resource: "concepts", group: .gettingStarted), HelpTopic(id: "use-cases", title: "What You Can Build", icon: "hammer", resource: "use-cases", group: .gettingStarted), + HelpTopic(id: "upgrading-from-1-0-1", title: "Upgrading from KeyPath 1.0.1", icon: "arrow.up.circle", resource: "upgrading-from-1-0-1", group: .gettingStarted), // Core Features HelpTopic(id: "home-row-mods", title: "Shortcuts Without Reaching", icon: "hand.raised", resource: "home-row-mods", group: .coreFeatures), HelpTopic(id: "vim-navigation", title: "Navigate Like a Keyboard Ninja", icon: "arrow.up.and.down.and.arrow.left.and.right", resource: "vim-navigation", group: .coreFeatures), @@ -62,7 +63,9 @@ extension HelpTopic { static func topic(forResource resource: String) -> HelpTopic? { let resource = resource == "installation" ? "installation-wizard" : resource - if let topic = allTopics.first(where: { $0.resource == resource }) { return topic } + if let topic = allTopics.first(where: { $0.resource == resource }) { + return topic + } guard !resource.isEmpty, resource.utf8.allSatisfy({ (97 ... 122).contains($0) || (48 ... 57).contains($0) || $0 == 45 }) else { return nil } return HelpTopic(id: resource, title: resource.replacingOccurrences(of: "-", with: " ").capitalized, icon: "doc.text", resource: resource, group: .reference) @@ -444,7 +447,11 @@ private struct CardHoverStyle: ButtonStyle { .animation(.easeOut(duration: 0.2), value: isHovered) .onHover { hovering in isHovered = hovering - if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } } } @@ -465,7 +472,11 @@ private struct ToolbarButtonStyle: ButtonStyle { .animation(.easeOut(duration: 0.15), value: isHovered) .onHover { hovering in isHovered = hovering - if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } } } @@ -488,7 +499,11 @@ private struct HomeButtonStyle: ButtonStyle { .animation(.easeOut(duration: 0.15), value: isHovered) .onHover { hovering in isHovered = hovering - if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } } } diff --git a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift index 95850e212..13aa4be8e 100644 --- a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift +++ b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift @@ -42,10 +42,12 @@ public enum LegacyPrivilegedRuntime { "/var/log/keypath-logrotate.log" ] - /// Matches the 1.0.1 kanata launcher and kanata (in the bundle or - /// `/Library/KeyPath/bin`), the bundled helper, and the SMJobBless helper copy. + /// Matches only KeyPath executables: the 1.0.1 kanata launcher and kanata + /// inside `KeyPath.app` or `/Library/KeyPath/bin`, the bundled helper, and the + /// SMJobBless helper copy. Never a bare `HelperTools/`, which other vendors use. /// `pgrep -f` and `grep -E` both take this extended regular expression. - public static let processPattern = #"Library/(KeyPath|HelperTools)/|PrivilegedHelperTools/com\.keypath\.helper"# + public static let processPattern = + #"KeyPath\.app/Contents/Library/(KeyPath|HelperTools)/|/Library/KeyPath/bin/|/Library/PrivilegedHelperTools/com\.keypath\.helper"# /// Every launchd target to check and boot out, for the user who runs the app. public static func launchdTargets(userID: uid_t) -> [String] { diff --git a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift index 3b7e6c128..f1800708a 100644 --- a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift @@ -178,6 +178,9 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { XCTAssertTrue(matches("/Library/KeyPath/bin/kanata --cfg /Users/x/.config/keypath/keypath.kbd")) XCTAssertFalse(matches("/Applications/KeyPath.app/Contents/MacOS/KeyPath")) XCTAssertFalse(matches("/opt/homebrew/bin/kanata --cfg x")) + XCTAssertFalse(matches("/Applications/Other.app/Contents/Library/HelperTools/OtherHelper"), + "Another vendor's root helper must never be stopped") + XCTAssertFalse(matches("/Library/PrivilegedHelperTools/com.example.helper")) } func testOnlyLiveRootProcessesCountNotAnotherUsersSession() { From 4585e47dec3db8ec77c34c87cfd4b61bbcdd8848 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 21:15:24 -0800 Subject: [PATCH 05/14] Follow TERM with a re-checked KILL; explain the stale Login Items entry A root 1.0.1 process that ignores SIGTERM now gets SIGKILL 2s later, in the same administrator batch, after its owner and command line are re-checked as root. A test also asserts the script defines kp_timeout, which every bootout uses. The guide says the old Login Items entry can be ignored. Co-Authored-By: Claude Opus 5.5 --- .../Core/LegacyPrivilegedRuntimeCleanup.swift | 21 ++++++++++++------- .../LegacyPrivilegedRuntimeCleanupTests.swift | 7 +++++-- guides/upgrading-from-1-0-1.md | 4 ++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift index 13aa4be8e..abea1056f 100644 --- a/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift +++ b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift @@ -254,14 +254,21 @@ public struct LegacyPrivilegedRuntimeCleanup { } let positive = pids.filter { $0 > 0 } if !positive.isEmpty { - // Re-check the command line as root before signalling, so a PID that - // was recycled since the read-only probe is never killed. + // Re-check owner and command line as root before every signal, so a + // PID recycled since the read-only probe is never killed. TERM first; + // whatever is still a root KeyPath process 2s later gets KILL. + let list = positive.map(String.init).joined(separator: " ") commands.append(""" - for kp_pid in \(positive.map(String.init).joined(separator: " ")); do - if [ "$(/bin/ps -o uid= -p "$kp_pid" 2>/dev/null | /usr/bin/tr -d ' ')" = "0" ] \\ - && /bin/ps -o command= -p "$kp_pid" 2>/dev/null | /usr/bin/grep -qE '\(LegacyPrivilegedRuntime.processPattern)'; then - /bin/kill -TERM "$kp_pid" 2>/dev/null || true - fi + kp_is_legacy() { + [ "$(/bin/ps -o uid= -p "$1" 2>/dev/null | /usr/bin/tr -d ' ')" = "0" ] \\ + && /bin/ps -o command= -p "$1" 2>/dev/null | /usr/bin/grep -qE '\(LegacyPrivilegedRuntime.processPattern)' + } + for kp_pid in \(list); do + if kp_is_legacy "$kp_pid"; then /bin/kill -TERM "$kp_pid" 2>/dev/null || true; fi + done + /bin/sleep 2 + for kp_pid in \(list); do + if kp_is_legacy "$kp_pid"; then /bin/kill -KILL "$kp_pid" 2>/dev/null || true; fi done """) } diff --git a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift index f1800708a..eb3aaa1f8 100644 --- a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift @@ -75,10 +75,13 @@ final class LegacyPrivilegedRuntimeCleanupTests: KeyPathTestCase { for file in LegacyPrivilegedRuntime.systemFiles { XCTAssertTrue(script.contains("/bin/rm -f '\(file)'"), file) } + XCTAssertTrue(script.contains("kp_timeout() {"), + "Every bootout is wrapped in kp_timeout, so the script must define it") XCTAssertTrue(script.contains("for kp_pid in 4242;")) - XCTAssertTrue(script.contains("/bin/ps -o command= -p \"$kp_pid\""), + XCTAssertTrue(script.contains("/bin/kill -KILL"), "A root process that ignores TERM must still be stopped") + XCTAssertTrue(script.contains("/bin/ps -o command= -p \"$1\""), "The PID must be re-checked as root before it is signalled") - XCTAssertTrue(script.contains("/bin/ps -o uid= -p \"$kp_pid\""), + XCTAssertTrue(script.contains("/bin/ps -o uid= -p \"$1\""), "Only a root-owned PID may be signalled") } diff --git a/guides/upgrading-from-1-0-1.md b/guides/upgrading-from-1-0-1.md index e3e6b4d4c..f9093ae04 100644 --- a/guides/upgrading-from-1-0-1.md +++ b/guides/upgrading-from-1-0-1.md @@ -41,6 +41,10 @@ If you cancel the password prompt, nothing is removed. Choose If KeyPath says the service was removed but it still detects it, restart your Mac and open KeyPath again. +**System Settings → General → Login Items** may still list an old KeyPath +background item afterwards. It is a leftover record from 1.0.1. It no longer +runs anything, and you can ignore it. + ## The Karabiner virtual keyboard driver KeyPath does not remove the Karabiner driver. Karabiner-Elements uses the same From 048fd660762892c4c71927fecede10061601d5cf Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 21:31:48 -0800 Subject: [PATCH 06/14] Revert formatting from the wrong SwiftFormat version Earlier commits were formatted with Homebrew's swiftformat 0.64.5 instead of the pinned 0.61.1. That expanded one-line bodies in files this PR touches, which broke SessionTerminalDiagnosticPlacementTests (it matches a one-line `if sessionStartIsCurrent(generation) { onError?`) and added unrelated churn. Rebuilt each touched file as master plus only this PR's changes, then formatted with the pinned 0.61.1 (`mise exec swiftformat`). `--lint` passes. Co-Authored-By: Claude Opus 5.5 --- ...eLifecycleCoordinator+SessionRuntime.swift | 102 +++++------------- .../Services/MainAppStateController.swift | 44 ++------ .../UI/Help/HelpBrowserView.swift | 22 +--- .../Settings/SettingsView+StatusDetails.swift | 6 +- .../UI/Settings/SettingsView.swift | 4 +- Sources/KeyPathWizardCore/WizardTypes.swift | 20 +--- .../MainAppStateControllerTests.swift | 8 +- .../SessionLegacyDaemonRefusalTests.swift | 4 +- 8 files changed, 53 insertions(+), 157 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index f70188c04..b7d46ebf4 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -6,9 +6,7 @@ import KeyPathPermissions extension ServiceLifecycleCoordinator { func currentSessionReport() -> SessionRuntimeReport? { #if DEBUG - if let report = testSessionCurrentReport { - return report() - } + if let report = testSessionCurrentReport { return report() } #endif guard let application = sessionApplication, !application.isTerminated, let url = sessionReportURL, let nonce = sessionNonce, @@ -25,9 +23,7 @@ extension ServiceLifecycleCoordinator { } func sessionCapabilities() async -> PermissionOracle.PermissionSet? { - if let report = currentSessionReport() { - return Self.permissionSet(report) - } + if let report = currentSessionReport() { return Self.permissionSet(report) } guard !startsSuppressed else { return nil } // Capability checks launch a separate copy of the app too. Serialize // them with stop so uninstall waits for existing probes before trashing @@ -43,18 +39,14 @@ extension ServiceLifecycleCoordinator { // An owned runtime may finish starting while the independent probe is // suspended. Revalidate ownership/freshness before publishing the older // probe result (including a missing result) to the permission cache. - if let report = currentSessionReport() { - return Self.permissionSet(report) - } + if let report = currentSessionReport() { return Self.permissionSet(report) } return probedPermissions } private func probeSessionCapabilities() async -> PermissionOracle.PermissionSet? { guard !startsSuppressed, !Task.isCancelled else { return nil } #if DEBUG - if let probe = testSessionCapabilityProbe { - return await probe() - } + if let probe = testSessionCapabilityProbe { return await probe() } #endif do { let (application, url, nonce) = try await launchSessionProcess(capabilitiesOnly: true) @@ -72,9 +64,7 @@ extension ServiceLifecycleCoordinator { // Never let that become a process-group/broadcast signal target. // Signal only the independently launched process owned by this call. let probePID = application.processIdentifier - if probePID > 0, !application.isTerminated { - kill(probePID, SIGTERM) - } + if probePID > 0, !application.isTerminated { kill(probePID, SIGTERM) } } catch { AppLogger.shared.warn("Session permission report unavailable") } @@ -86,9 +76,7 @@ extension ServiceLifecycleCoordinator { private func isLegacyDaemonRunning() async -> Bool { #if DEBUG - if let probe = testSessionLegacyDaemonRunning { - return await probe() - } + if let probe = testSessionLegacyDaemonRunning { return await probe() } #endif return await kanataDaemonService.isDaemonRunning() } @@ -97,15 +85,11 @@ extension ServiceLifecycleCoordinator { /// does not keep blocking the UI until the next start. func legacyDaemonRefusalForStartup() async -> String? { guard sessionLegacyDaemonRefusal != nil else { return nil } - if let report = currentSessionReport(), report.state == .running, report.tapActive { - return nil - } + if let report = currentSessionReport(), report.state == .running, report.tapActive { return nil } let generation = sessionIntentGeneration let legacyRunning = await isLegacyDaemonRunning() // A newer request owns the refusal now, and this reading predates it. - if !legacyRunning, generation == sessionIntentGeneration { - clearLegacyDaemonRefusal() - } + if !legacyRunning, generation == sessionIntentGeneration { clearLegacyDaemonRefusal() } return sessionLegacyDaemonRefusal } @@ -220,13 +204,9 @@ extension ServiceLifecycleCoordinator { ) return true } - if report.state == .failed || report.state == .secureInput { - break - } - } - if application.isTerminated { - break + if report.state == .failed || report.state == .secureInput { break } } + if application.isTerminated { break } try await Task.sleep(for: .milliseconds(100)) } let terminalReport = sessionReportURL.flatMap(Self.readSessionReport) @@ -263,21 +243,12 @@ extension ServiceLifecycleCoordinator { } if scheduleInterruptedStartupRecovery(terminalReport, nonce: nonce, pid: application.processIdentifier, - generation: generation, cleanedUp: cleanedUp) - { - return false - } + generation: generation, cleanedUp: cleanedUp) { return false } let failure = terminalReport?.failure.map(Self.sessionFailureRecoveryMessage) - if sessionStartIsCurrent(generation) { - onError?("Driverless runtime could not start: \(failure ?? "no current tap and TCP evidence")") - } + if sessionStartIsCurrent(generation) { onError?("Driverless runtime could not start: \(failure ?? "no current tap and TCP evidence")") } } catch { - if didLaunch { - _ = await stopSessionRuntime() - } - if sessionStartIsCurrent(generation) { - onError?("Driverless runtime launch failed: \(error.localizedDescription)") - } + if didLaunch { _ = await stopSessionRuntime() } + if sessionStartIsCurrent(generation) { onError?("Driverless runtime launch failed: \(error.localizedDescription)") } } onStateChanged?() return false @@ -339,13 +310,9 @@ extension ServiceLifecycleCoordinator { // The worker is dead: release exclusion before normal journal recovery. sessionTerminationGuard?.release() sessionTerminationGuard = nil - if let application { - _ = recoverSessionOutputs(for: application) - } + if let application { _ = recoverSessionOutputs(for: application) } guard await restoreSessionCaps(for: application) else { - if let application { - scheduleSessionCleanup(application, nonce: nonce) - } + if let application { scheduleSessionCleanup(application, nonce: nonce) } return false } if let reportURL { @@ -382,9 +349,7 @@ extension ServiceLifecycleCoordinator { guard !application.isTerminated else { return } _ = Self.signalSessionProcess(pid: application.processIdentifier, signal: SIGKILL) for _ in 0 ..< 40 { - if application.isTerminated { - return - } + if application.isTerminated { return } await waitForSessionTermination() } } @@ -406,9 +371,7 @@ extension ServiceLifecycleCoordinator { await (try? sessionOperationGate.withOperation { @MainActor [self] _ in guard sessionNonce == nonce, let application = sessionApplication, application.processIdentifier == pid else { return false } - if !application.isTerminated { - await terminateIdleSessionWorker(application, nonce: nonce) - } + if !application.isTerminated { await terminateIdleSessionWorker(application, nonce: nonce) } guard application.isTerminated else { return true } return await !stopSessionRuntime() }) ?? true @@ -431,14 +394,10 @@ extension ServiceLifecycleCoordinator { sendTermination: () -> Void, wait: () async -> Void) async -> Bool { - if isTerminated() { - return true - } + if isTerminated() { return true } sendTermination() for _ in 0 ..< 600 { - if isTerminated() { - return true - } + if isTerminated() { return true } await wait() } return isTerminated() @@ -658,9 +617,7 @@ extension ServiceLifecycleCoordinator { if blocker == nil { AppLogger.shared.info("[SessionRuntime] Resuming after keyboard readiness") sessionWakeRetryGeneration = nil - if await resumeSessionRuntime(expectedGeneration: expectedGeneration, reason: "System wake") { - return true - } + if await resumeSessionRuntime(expectedGeneration: expectedGeneration, reason: "System wake") { return true } guard sessionStartIsCurrent(expectedGeneration), sessionWakeRetryGeneration == expectedGeneration else { return false } AppLogger.shared.info("[SessionRuntime] Wake startup interrupted; retrying after readiness checks") @@ -693,10 +650,7 @@ extension ServiceLifecycleCoordinator { // startup has installed its supervisor. Preserve the same recovery loop // after verified cleanup, just as for a held-key activation race. if report.state == .stopped || report.state == .failed, - report.recoveryReason == "system-wake-observed" - { - return true - } + report.recoveryReason == "system-wake-observed" { return true } return report.state == .failed && report.failure == "managed-caps-activation-refused: physicalKeysHeld" } @@ -706,18 +660,14 @@ extension ServiceLifecycleCoordinator { static func capsConflictMessage(conflict: SessionCapsConflict?, raycast: SessionCapsConflict.RaycastHyperKey?) -> String { - if let conflict { - return conflict.message - } + if let conflict { return conflict.message } return (raycast?.enabled == true && raycast?.keyCode == 57 ? SessionCapsConflict.raycastHyperKey : .unknown(destination: 0)).message } private func sessionWakeReadinessBlocker() -> String? { #if DEBUG - if let ready = testSessionWakeReady { - return ready() ? nil : "test-not-ready" - } + if let ready = testSessionWakeReady { return ready() ? nil : "test-not-ready" } #endif guard !IsSecureEventInputEnabled() else { return "secure-input" } guard let console = SessionRuntimeEnvironmentObserver.currentConsole(), @@ -804,9 +754,7 @@ extension ServiceLifecycleCoordinator { let owner: SessionCapsMappingPolicy.Owner? = if let nonce, let pid { try .init(uid: uid, parentPID: parentPID, workerPID: pid, nonce: nonce, generation: nonce, bootSessionUUID: SessionCapsRuntimeSupport.bootSessionUUID()) - } else { - nil - } + } else { nil } try SessionCapsRuntimeSupport.recoverPending(directory: directory, expectedOwner: owner) }.value } diff --git a/Sources/KeyPathAppKit/Services/MainAppStateController.swift b/Sources/KeyPathAppKit/Services/MainAppStateController.swift index c8527f866..8b0d75b37 100644 --- a/Sources/KeyPathAppKit/Services/MainAppStateController.swift +++ b/Sources/KeyPathAppKit/Services/MainAppStateController.swift @@ -45,16 +45,12 @@ class MainAppStateController { case failed(blockingCount: Int, totalCount: Int) var isSuccess: Bool { - if case .success = self { - return true - } + if case .success = self { return true } return false } var hasCriticalIssues: Bool { - if case let .failed(blockingCount, _) = self, blockingCount > 0 { - return true - } + if case let .failed(blockingCount, _) = self, blockingCount > 0 { return true } return false } } @@ -501,9 +497,7 @@ class MainAppStateController { private func performValidation(requiresFreshResult: Bool = false) async { if let inProgressValidation { - if requiresFreshResult { - validationRefreshPending = true - } + if requiresFreshResult { validationRefreshPending = true } AppLogger.shared.log( "🔍 [MainAppStateController] Validation already in progress - waiting for published result" ) @@ -518,9 +512,7 @@ class MainAppStateController { await performValidationBody() // An explicit refresh may arrive after this capture started. Invalidate // again because the completed capture can have repopulated caches. - if validationRefreshPending { - validator?.invalidateCaches() - } + if validationRefreshPending { validator?.invalidateCaches() } } while validationRefreshPending inProgressValidation = nil } @@ -873,9 +865,7 @@ class MainAppStateController { if let refusal = await serviceLifecycle?.legacyDaemonRefusalForStartup() { return .legacyDaemonRefused(refusal) } - if serviceLifecycle?.isRecoveringFromSystemWake == true { - return .recoveringFromWake - } + if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } let timing = startupGateTiming() let start = startupGateNow() let definitiveDeadline = start.addingTimeInterval(timing.definitiveGrace) @@ -889,9 +879,7 @@ class MainAppStateController { if let refusal = await serviceLifecycle?.legacyDaemonRefusalForStartup() { return .legacyDaemonRefused(refusal) } - if serviceLifecycle?.isRecoveringFromSystemWake == true { - return .recoveringFromWake - } + if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } if KanataRuntimeBackend.selected == .session { let permissions: PermissionOracle.Snapshot #if DEBUG @@ -903,9 +891,7 @@ class MainAppStateController { #else permissions = await SystemStateProvider.shared.currentPermissionSnapshot() #endif - if serviceLifecycle?.isRecoveringFromSystemWake == true { - return .recoveringFromWake - } + if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } if Self.hasDefinitiveSessionPermissionFailure(permissions) { return .missingSessionPermissions } @@ -916,12 +902,8 @@ class MainAppStateController { if let refusal = serviceLifecycle?.recoveryRefusalForStartup { return .sessionRecoveryRefused(refusal) } - if serviceLifecycle?.isRecoveringFromSystemWake == true { - return .recoveringFromWake - } - if recoveryRevision != serviceLifecycle?.sessionWakeRecoveryRevision { - continue - } + if serviceLifecycle?.isRecoveringFromSystemWake == true { return .recoveringFromWake } + if recoveryRevision != serviceLifecycle?.sessionWakeRecoveryRevision { continue } let isReady = health.isReady if isReady { if checks > 0 { @@ -949,9 +931,7 @@ class MainAppStateController { private func startupGateNow() -> Date { #if DEBUG - if let override = startupGateNowOverride { - return override() - } + if let override = startupGateNowOverride { return override() } #endif return Date() } @@ -1076,9 +1056,7 @@ class MainAppStateController { /// Check if validation is currently running var isValidating: Bool { - if case .checking = validationState { - return true - } + if case .checking = validationState { return true } return false } } diff --git a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift index 57cc5da86..4a49bb837 100644 --- a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift +++ b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift @@ -63,9 +63,7 @@ extension HelpTopic { static func topic(forResource resource: String) -> HelpTopic? { let resource = resource == "installation" ? "installation-wizard" : resource - if let topic = allTopics.first(where: { $0.resource == resource }) { - return topic - } + if let topic = allTopics.first(where: { $0.resource == resource }) { return topic } guard !resource.isEmpty, resource.utf8.allSatisfy({ (97 ... 122).contains($0) || (48 ... 57).contains($0) || $0 == 45 }) else { return nil } return HelpTopic(id: resource, title: resource.replacingOccurrences(of: "-", with: " ").capitalized, icon: "doc.text", resource: resource, group: .reference) @@ -447,11 +445,7 @@ private struct CardHoverStyle: ButtonStyle { .animation(.easeOut(duration: 0.2), value: isHovered) .onHover { hovering in isHovered = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } } } } @@ -472,11 +466,7 @@ private struct ToolbarButtonStyle: ButtonStyle { .animation(.easeOut(duration: 0.15), value: isHovered) .onHover { hovering in isHovered = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } } } } @@ -499,11 +489,7 @@ private struct HomeButtonStyle: ButtonStyle { .animation(.easeOut(duration: 0.15), value: isHovered) .onHover { hovering in isHovered = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } } } } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index f6719c45b..28fadade6 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift @@ -325,7 +325,8 @@ extension StatusSettingsTabView { } var tcpDetail: StatusDetail? { - guard !isDriverless, systemContext?.services.kanataRunning == true else { return nil } + guard !isDriverless else { return nil } + guard systemContext?.services.kanataRunning == true else { return nil } guard let tcpConfigured else { return StatusDetail( title: "TCP Communication", @@ -404,7 +405,8 @@ extension StatusSettingsTabView { // Show logs affordance when service isn't healthy or has daemon issues. // Not when the user simply turned the service off — there's nothing to // troubleshoot. - guard !isIntentionallyDisabled, serviceStatusDetail.level != .success else { return nil } + guard !isIntentionallyDisabled else { return nil } + guard serviceStatusDetail.level != .success else { return nil } return StatusDetail( title: isDriverless ? "KeyPath Logs" : "Kanata Logs", message: isDriverless ? "Open KeyPath’s log for remapping startup errors and permission failures." : "Open the daemon stderr log for startup errors and permission failures.", diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index 9b80f7746..b05525b55 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift @@ -488,9 +488,7 @@ struct StatusSettingsTabView: View { /// install is not yet reflected; that brief warm-up is intended. Validation /// publishes roughly every minute, so rescans are throttled to match. private func refreshDuplicateAppCopies() { - if let last = lastDuplicateScan, Date().timeIntervalSince(last) < 60 { - return - } + if let last = lastDuplicateScan, Date().timeIntervalSince(last) < 60 { return } lastDuplicateScan = Date() Task { @MainActor in let duplicates = await HelperMaintenance.shared.detectDuplicateAppCopies() diff --git a/Sources/KeyPathWizardCore/WizardTypes.swift b/Sources/KeyPathWizardCore/WizardTypes.swift index 4fab2a351..082b4fc6d 100644 --- a/Sources/KeyPathWizardCore/WizardTypes.swift +++ b/Sources/KeyPathWizardCore/WizardTypes.swift @@ -227,33 +227,25 @@ public enum IssueIdentifier: Equatable, Sendable { /// Check if this identifier represents a conflict public var isConflict: Bool { - if case .conflict = self { - return true - } + if case .conflict = self { return true } return false } /// Check if this identifier represents a permission issue public var isPermission: Bool { - if case .permission = self { - return true - } + if case .permission = self { return true } return false } /// Check if this identifier represents a component issue public var isComponent: Bool { - if case .component = self { - return true - } + if case .component = self { return true } return false } /// Check if this identifier represents a daemon issue public var isDaemon: Bool { - if case .daemon = self { - return true - } + if case .daemon = self { return true } return false } @@ -561,9 +553,7 @@ public extension [WizardIssue] { guard !isEmpty else { return "" } func truncate(_ s: String, limit: Int = 90) -> String { - if s.count <= limit { - return s - } + if s.count <= limit { return s } let end = s.index(s.startIndex, offsetBy: limit) return String(s[s.startIndex ..< end]).trimmingCharacters(in: .whitespacesAndNewlines) + "…" } diff --git a/Tests/KeyPathTests/MainAppStateControllerTests.swift b/Tests/KeyPathTests/MainAppStateControllerTests.swift index 567850a2c..2c7d9af8a 100644 --- a/Tests/KeyPathTests/MainAppStateControllerTests.swift +++ b/Tests/KeyPathTests/MainAppStateControllerTests.swift @@ -718,9 +718,7 @@ struct MainAppStateControllerBehaviorTests { let lifecycle = RuntimeCoordinator().serviceLifecycleCoordinator lifecycle.testSessionConfigurationValidation = { .valid } lifecycle.testSessionCapsRecovery = { throw SessionCapsMappingLease.Refusal.mutationUncertain } - if !duringHealth { - #expect(await lifecycle.restoreSessionCaps(for: nil) == false) - } + if !duringHealth { #expect(await lifecycle.restoreSessionCaps(for: nil) == false) } controller.configure(serviceLifecycle: lifecycle, onSystemHealthy: {}) controller.setValidator(StubSystemValidator(context: SystemContextBuilder(componentsInstalled: true).build())) var healthChecks = 0, permissionChecks = 0 @@ -764,9 +762,7 @@ struct MainAppStateControllerBehaviorTests { SystemContextBuilder(servicesHealthy: true, kanataTCPConfigured: true, componentsInstalled: true).build() ]) validator.beforeCheck = { [weak validator] in - if validator?.checkCount == 1 { - lifecycle.sessionWakeRecoveryRevision &+= 2 - } + if validator?.checkCount == 1 { lifecycle.sessionWakeRecoveryRevision &+= 2 } } controller.setValidator(validator) controller.configureStartupGateTestingState( diff --git a/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift index af4c1f3be..bb77f8fb0 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift @@ -38,9 +38,7 @@ final class SessionLegacyDaemonRefusalTests: KeyPathTestCase { coordinator.testSessionCapsPreparation = {} coordinator.testSessionCapsRecovery = {} coordinator.onError = { [weak self] message in - if let message { - self?.errors.append(message) - } + if let message { self?.errors.append(message) } } return coordinator } From b6badee8ec3c0ea85bf1e6a103063be4a463bbe6 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 4 Nov 2026 21:36:00 -0800 Subject: [PATCH 07/14] Treat a missing GUI launchd domain as absent; test the script's quoting `launchctl print gui//