diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index 953e9b98d..98336357b 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -74,6 +74,9 @@ extension ServiceLifecycleCoordinator { return nil } + static let legacyDaemonRefusalMessage = + "The keyboard service from KeyPath 1.0.1 is still running. Remove it to start KeyPath." + /// The worker's argv. Uninstall's sweep recognizes workers by these flags, /// so a test pins them to SessionWorkerProcess.parse. nonisolated static func sessionLaunchArguments( @@ -126,6 +129,14 @@ extension ServiceLifecycleCoordinator { 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 } @@ -152,7 +163,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?() diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 7f3f4484f..c7c144625 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -406,6 +406,19 @@ 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. Call it + /// only after the removal was verified: it forgets a previously running state. + func invalidateEvidenceCaches() async { + await pidCache.invalidateCache() + await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: Constants.daemonPlistName) + // 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 + } + /// Returns whether the internal recovery daemon is currently active. func isDaemonRunning() async -> Bool { let status = await refreshStatus() diff --git a/Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift b/Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift new file mode 100644 index 000000000..af4373a5f --- /dev/null +++ b/Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift @@ -0,0 +1,60 @@ +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) + } + // The refusal itself is not a reason the start failed once it has cleared. + let reason = lastError().flatMap { $0 == ServiceLifecycleCoordinator.legacyDaemonRefusalMessage ? nil : $0 } + return .removedButNotStarted(reason: reason, virtualHIDNotice: result.virtualHIDNotice) + } +} diff --git a/Sources/KeyPathAppKit/Services/MainAppStateController.swift b/Sources/KeyPathAppKit/Services/MainAppStateController.swift index cc8af2aa9..8b0d75b37 100644 --- a/Sources/KeyPathAppKit/Services/MainAppStateController.swift +++ b/Sources/KeyPathAppKit/Services/MainAppStateController.swift @@ -477,7 +477,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,9 +485,14 @@ 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 { @@ -827,7 +832,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 diff --git a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift index 54bce5e32..4a49bb837 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), diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index 2ecd9e80b..28fadade6 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 }) } @@ -163,6 +167,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." } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index 0c018c8f4..72f3eed6d 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 @@ -130,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)) @@ -480,6 +497,32 @@ 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 lifecycle = kanataManager.underlyingManager.serviceLifecycleCoordinator + let outcome = await LegacyRuntimeUpgradeFlow.run( + remove: { await InstallerEngine().removeLegacyPrivilegedRuntime() }, + refusalStillStands: { await lifecycle.refreshLegacyDaemonRefusalAfterCleanup() }, + start: { await kanataManager.startKanata(reason: "KeyPath 1.0.1 service removed") }, + lastError: { kanataManager.lastError } + ) + await refreshStatus() + 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) + } + } + private func startViaInstallerEngine() async { await MainActor.run { settingsToastManager.showInfo("Starting…") 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..049cf67ee 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,11 @@ public final class InstallerEngine { using broker: PrivilegeBroker ) async -> InstallerReport { AppLogger.shared.log("🔧 [InstallerEngine] runSingleAction(\(action), using:) starting") + // Already inside the installer transaction: `runSingleAction` wraps this + // function in `withInstallerTransaction`. + if action == .removeLegacyPrivilegedRuntime { + return await removeLegacyPrivilegedRuntimeWithinTransaction().report + } let context = await inspectSystem() // Determine which intent would include this action. @@ -867,6 +877,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() 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..279f28f31 --- /dev/null +++ b/Sources/KeyPathInstallationWizard/Core/LegacyPrivilegedRuntimeCleanup.swift @@ -0,0 +1,378 @@ +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 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", + "com.keypath.karabiner-vhiddaemon", + "com.keypath.karabiner-vhidmanager", + "com.keypath.logrotate" + ] + + /// 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", + "/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 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. + /// Anchored at the start of the command line, so only the executable path can + /// match, never an argument (`vim /Library/KeyPath/bin/kanata` running as root). + /// `pgrep -f` and `grep -E` both take this extended regular expression. + 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] { + 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. + 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 { + /// 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] + /// 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 + + 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. +/// +/// 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 + public var executeBatch: (PrivilegedCommandRunner.Batch) async throws -> (exitCode: Int32, output: String) + public var sleep: (Duration) async -> Void + + public init( + inspect: @escaping () async -> LegacyPrivilegedRuntimeEvidence, + 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.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 + /// The signed-in user whose GUI domain is also cleaned. + private let userID: uid_t + + public init(dependencies: Dependencies, userID: uid_t = getuid()) { + self.dependencies = dependencies + self.userID = userID + } + + 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) + } + + let batch = Self.batch(stopping: before.rootProcesses.map(\.pid), userID: userID) + 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)). Choose Remove Old Service to try again. If it keeps failing, restart your Mac first." + 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], userID: uid_t) -> PrivilegedCommandRunner.Batch { + var commands = LegacyPrivilegedRuntime.launchdTargets(userID: userID).map { + // `bootout` can block on a job that will not exit (#927); bound it. + // `kp_timeout` comes from `PrivilegedCommandRunner.Batch.scriptPrelude`. + "kp_timeout 15 /bin/launchctl bootout \($0) 2>/dev/null || true" + } + let positive = pids.filter { $0 > 0 } + if !positive.isEmpty { + // 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(""" + 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 + """) + } + 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 + /// launchd's "Could not find domain" status, e.g. `gui/` with no GUI + /// session. A domain that does not exist holds no job, so it counts as absent. + static let launchctlDomainNotFound: Int32 = 112 + + /// 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() }, + 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, + userID: uid_t = getuid(), + fileExists: @escaping (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }, + isAlive: @escaping (pid_t) -> Bool = { SystemStateProvider.isProcessAlive(pid: $0) }, + ownerUID: @escaping (pid_t) -> uid_t? = { processOwnerUID($0) } + ) async -> LegacyPrivilegedRuntimeEvidence { + var loaded: [String] = [] + var unreadable: [String] = [] + for target in LegacyPrivilegedRuntime.launchdTargets(userID: userID) { + let evidence = await provider.launchctlPrint(target: target) + switch evidence.exitCode { + case 0: loaded.append(target) + case launchctlServiceNotFound, launchctlDomainNotFound: break + default: unreadable.append(target) + } + } + let matches = await provider.processMatches(matching: LegacyPrivilegedRuntime.processPattern) + return LegacyPrivilegedRuntimeEvidence( + loadedServices: loaded, + unreadableServices: unreadable, + rootProcesses: rootProcesses(in: matches, isAlive: isAlive, ownerUID: ownerUID), + leftoverFiles: LegacyPrivilegedRuntime.systemFiles.filter(fileExists), + virtualHID: LegacyVirtualHIDRemnants( + driverInstalled: fileExists(LegacyPrivilegedRuntime.driverManagerApp) + || fileExists(LegacyPrivilegedRuntime.driverSupportDirectory), + karabinerElementsInstalled: fileExists(LegacyPrivilegedRuntime.karabinerElementsApp) + ) + ) + } + + /// 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, + ownerUID: (pid_t) -> uid_t? + ) -> [SystemProcessMatch] { + matches.filter { $0.pid > 0 && isAlive($0.pid) && ownerUID($0.pid) == 0 } + } + + /// The effective user ID of a process, readable for any process without + /// privileges. Effective, to match the root script's `ps -o uid=` re-check. + 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_ucred.cr_uid + } +} 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/WizardTypes.swift b/Sources/KeyPathWizardCore/WizardTypes.swift index 9ad03f740..082b4fc6d 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 diff --git a/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift new file mode 100644 index 000000000..075713478 --- /dev/null +++ b/Tests/KeyPathTests/InstallationEngine/LegacyPrivilegedRuntimeCleanupTests.swift @@ -0,0 +1,339 @@ +@testable import KeyPathAppKit +import KeyPathCore +import KeyPathDaemonLifecycle +@testable import KeyPathInstallationWizard +import KeyPathWizardCore +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 sleeps = 0 + private var batchResult: Result<(exitCode: Int32, output: String), Error> = .success((0, "")) + + private static let installed101 = LegacyPrivilegedRuntimeEvidence( + loadedServices: ["system/com.keypath.kanata", "system/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]) + }, + executeBatch: { [unowned self] batch in + batches.append(batch) + return try batchResult.get() + }, + sleep: { [unowned self] _ in sleeps += 1 } + ), userID: 501) + } + + // 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") + } + + func testOneAuthorizationRemovesTheStackAndWaitsForLaunchdTeardown() async { + let stillTearingDown = LegacyPrivilegedRuntimeEvidence(loadedServices: ["system/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(inspectCount, 3) + XCTAssertEqual(sleeps, 1) + XCTAssertTrue(outcome.after.isClean) + + let script = batches[0].script + for label in LegacyPrivilegedRuntime.serviceLabels { + 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 { + 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/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 \"$1\""), + "Only a root-owned PID may be 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 testCancelDetectionIgnoresAnUnrelatedMinus128() { + XCTAssertEqual(LegacyPrivilegedRuntimeCleanup.describeBatchFailure(exitCode: 1, output: "User canceled. (-128)"), + "Administrator authorization was cancelled.") + XCTAssertFalse(LegacyPrivilegedRuntimeCleanup.describeBatchFailure(exitCode: 1, output: "kill: 4-128: no such process") + .contains("cancelled")) + } + + 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: ["system/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: ["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], userID: 501).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) + } + + /// 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) + } + + /// Runs the script's own `grep -E` argument after shell quoting, so a quoting + /// or escaping regression in the generated pattern fails here. + func testScriptPatternSurvivesShellQuoting() throws { + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [4242], userID: 501).script + let marker = "/usr/bin/grep -qE '" + let start = try XCTUnwrap(script.range(of: marker)).upperBound + let end = try XCTUnwrap(script[start...].firstIndex(of: "'")) + let quotedPattern = String(script[script.index(before: start) ... end]) + func shellMatches(_ command: String) throws -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", "printf '%s\\n' \"$1\" | /usr/bin/grep -qE \(quotedPattern)", "_", command] + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } + XCTAssertTrue(try shellMatches("/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher --port 37001")) + XCTAssertTrue(try shellMatches("/Library/PrivilegedHelperTools/com.keypath.helper")) + XCTAssertFalse(try shellMatches("/Applications/Other.app/Contents/Library/HelperTools/OtherHelper")) + XCTAssertFalse(try shellMatches("/usr/bin/vim /Library/KeyPath/bin/kanata")) + XCTAssertFalse(try shellMatches("/Library/PrivilegedHelperTools/comXkeypathXhelper"), + "Escaped dots must stay literal after quoting") + } + + func testBatchWithoutRootProcessesHasNoSignalStep() { + let script = LegacyPrivilegedRuntimeCleanup.batch(stopping: [0, -1], userID: 501).script + XCTAssertFalse(script.contains("kp_pid")) + } + + // MARK: - Live probes + + 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")) + 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")) + XCTAssertFalse(matches("/usr/bin/vim /Library/KeyPath/bin/kanata"), + "Only the executable path may match, never an argument") + XCTAssertFalse(matches("/usr/bin/tail -f /Applications/KeyPath.app/Contents/Library/KeyPath/log")) + } + + 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 }, + 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 even though probing it returns EPERM; this test process is not root. + func testRootProcessFilterAgainstRealProcesses() throws { + try XCTSkipIf(getuid() == 0, "Run as a normal user: as root, PID 1 is signalable") + 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) }, + ownerUID: { LegacyPrivilegedRuntimeCleanup.processOwnerUID($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", "gui/501/com.keypath.kanata": 0 + case "system/com.keypath.helper": 1 + case "gui/501/com.keypath.helper": 112 // no GUI domain: nothing can be loaded there + 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), + userID: 501, + fileExists: { present.contains($0) }, + isAlive: { _ in true }, + ownerUID: { _ in 0 } + ) + 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)) + } + + // 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() + 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: ["system/com.keypath.kanata"])] + let result = await makeEngine(context: SystemContextBuilder(componentsInstalled: true).build()) + .removeLegacyPrivilegedRuntime() + 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) + } +} diff --git a/Tests/KeyPathTests/MainAppStateControllerTests.swift b/Tests/KeyPathTests/MainAppStateControllerTests.swift index d814bd2d6..2c7d9af8a 100644 --- a/Tests/KeyPathTests/MainAppStateControllerTests.swift +++ b/Tests/KeyPathTests/MainAppStateControllerTests.swift @@ -803,22 +803,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/LegacyRuntimeUpgradeFlowTests.swift b/Tests/KeyPathTests/Services/LegacyRuntimeUpgradeFlowTests.swift new file mode 100644 index 000000000..0b37ed150 --- /dev/null +++ b/Tests/KeyPathTests/Services/LegacyRuntimeUpgradeFlowTests.swift @@ -0,0 +1,74 @@ +@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 stale = await run(result(success: true), started: false, + lastError: ServiceLifecycleCoordinator.legacyDaemonRefusalMessage) + XCTAssertEqual(stale.message, "Old KeyPath service removed. Turn KeyPath on with the switch above.", + "A stale refusal must not be shown as the reason the start failed") + + 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/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..2959404a9 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionLegacyDaemonRefusalTests.swift @@ -1,4 +1,5 @@ @testable import KeyPathAppKit +import ServiceManagement @preconcurrency import XCTest /// A daemon left by public 1.0.1 and a session tap would both modify the @@ -7,7 +8,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? @@ -109,6 +110,43 @@ 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) + } + + /// 1.0.1's ServiceManagement record is left behind on purpose. A lingering + /// `.enabled` record with no process and no TCP must never read as a + /// running daemon after cleanup, or the user would be told to restart. + func testLingeringEnabledRegistrationIsNotARunningDaemonAfterCleanup() async { + let previousProvider = SMAppServiceStatusProvider.shared + let previousTCP = KanataDaemonService.tcpProbeOverride + SMAppServiceStatusProvider.shared = SMAppServiceStatusProvider( + cacheTTL: 0, serviceFactory: { _ in LingeringRegistration() } + ) + KanataDaemonService.tcpProbeOverride = { _, _ in false } + defer { + SMAppServiceStatusProvider.shared = previousProvider + KanataDaemonService.tcpProbeOverride = previousTCP + } + + let service = KanataDaemonService() + await service.invalidateEvidenceCaches() + for sample in 1 ... 5 { + let running = await service.isDaemonRunning() + XCTAssertFalse(running, "Sample \(sample) read a stale registration as a running daemon") + } + } + func testRunningLegacyDaemonRefusesStartWithoutLaunching() async { let started = await start(daemonRunning: true) XCTAssertFalse(started) @@ -126,3 +164,12 @@ final class SessionLegacyDaemonRefusalTests: KeyPathTestCase { "Expected the start to reach launch, got \(errors)") } } + +private struct LingeringRegistration: SMAppServiceProtocol { + var status: SMAppService.Status { + .enabled + } + + func register() throws {} + func unregister() async throws {} +} diff --git a/docs/adr/adr-045-retire-privileged-runtime-stack.md b/docs/adr/adr-045-retire-privileged-runtime-stack.md index 29eaed06b..eda3af5ea 100644 --- a/docs/adr/adr-045-retire-privileged-runtime-stack.md +++ b/docs/adr/adr-045-retire-privileged-runtime-stack.md @@ -47,6 +47,25 @@ 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`. 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`: 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. 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`. + ## 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..b6bcdcd2c --- /dev/null +++ b/docs/testing/upgrade-from-1-0-1.md @@ -0,0 +1,73 @@ +# 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. +- **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 + +| 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..f9093ae04 --- /dev/null +++ b/guides/upgrading-from-1-0-1.md @@ -0,0 +1,61 @@ +--- +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. + +``` + ( ! ) + 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 +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. + +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 +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).