diff --git a/Sources/KeyPathApp/Info.plist b/Sources/KeyPathApp/Info.plist
index 3540791c0..15a5286f0 100644
--- a/Sources/KeyPathApp/Info.plist
+++ b/Sources/KeyPathApp/Info.plist
@@ -11,9 +11,9 @@
CFBundleDisplayName
KeyPath
CFBundleVersion
- 4
+ 9
CFBundleShortVersionString
- 1.0.0
+ 1.0.1
CFBundlePackageType
APPL
CFBundleIconFile
diff --git a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift
index fe2b4cd27..6de8773fa 100644
--- a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift
+++ b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift
@@ -480,7 +480,15 @@ public final class PrivilegedOperationsRouter {
return
}
#endif
- let success = await ServiceBootstrapper.shared.installAllServices()
+ // This operation is selected when the runtime is missing, stopped, or
+ // stale relative to the current app bundle. An active SMAppService job
+ // is not sufficient evidence: launchd may still be enforcing the
+ // previous bundle's launch constraint after a KeyPath update. Request
+ // a refresh; ServiceBootstrapper independently rechecks the active
+ // runtime identity and leaves an already-fresh registration alone.
+ let success = await ServiceBootstrapper.shared.installAllServices(
+ refreshActiveKanataRegistration: true
+ )
if !success {
throw PrivilegedOperationError.installationFailed("Required runtime service installation failed")
}
diff --git a/Sources/KeyPathAppKit/WizardProtocolConformances.swift b/Sources/KeyPathAppKit/WizardProtocolConformances.swift
index 98dab95c6..b232c84fe 100644
--- a/Sources/KeyPathAppKit/WizardProtocolConformances.swift
+++ b/Sources/KeyPathAppKit/WizardProtocolConformances.swift
@@ -65,6 +65,21 @@ extension KanataDaemonManager: WizardDaemonManaging {
await WizardServiceManagementState(refreshManagementStateInternal())
}
+ public func activeRuntimeFreshness() async -> RuntimeFreshness {
+ let snapshot = await ServiceHealthChecker.shared.checkKanataServiceRuntimeSnapshotFresh()
+ let actualIdentity = snapshot.activeProgramIdentity.map {
+ RuntimeIdentity(
+ programIdentifier: $0.programIdentifier,
+ parentBundleIdentifier: $0.parentBundleIdentifier,
+ parentBundleVersion: $0.parentBundleVersion
+ )
+ }
+ return RuntimeFreshness.classify(
+ actual: actualIdentity,
+ expected: RuntimeIdentity.expectedKeyPathKanata()
+ )
+ }
+
public nonisolated var kanataServiceID: String {
Self.kanataServiceID
}
diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerDecisionPipeline.swift b/Sources/KeyPathInstallationWizard/Core/InstallerDecisionPipeline.swift
index 853072cf2..030d65b74 100644
--- a/Sources/KeyPathInstallationWizard/Core/InstallerDecisionPipeline.swift
+++ b/Sources/KeyPathInstallationWizard/Core/InstallerDecisionPipeline.swift
@@ -67,7 +67,9 @@ public enum InstallerDecisionPipeline {
// Every repair action below may need privileged XPC. Establish a working
// helper before planning any operation that routes through the broker.
- if !context.helper.isReady {
+ if !context.helper.isReady ||
+ context.helper.freshness(expectedVersion: WizardHelperConstants.expectedHelperVersion) != .fresh
+ {
actions.append(
context.helper.isInstalled ? .reinstallPrivilegedHelper : .installPrivilegedHelper
)
@@ -109,6 +111,16 @@ public enum InstallerDecisionPipeline {
actions.append(.installRequiredRuntimeServices)
}
+ // A Sparkle update can leave the previous app bundle's already-running
+ // Kanata service healthy but stale. Refresh the managed runtime even
+ // when it still responds so the installed app and active executable
+ // identities converge after an update.
+ if context.services.kanataServiceFreshness == .stale,
+ !actions.contains(.installRequiredRuntimeServices)
+ {
+ actions.append(.installRequiredRuntimeServices)
+ }
+
appendVHIDActivationRepairIfNeeded(context: context, actions: &actions)
// Matrix row: stopped Kanata plus non-approval stale input-capture
@@ -141,6 +153,10 @@ public enum InstallerDecisionPipeline {
// component, activation, or service recipe attempts privileged work.
if !context.helper.isReady {
actions.append(.installPrivilegedHelper)
+ } else if context.helper.freshness(
+ expectedVersion: WizardHelperConstants.expectedHelperVersion
+ ) != .fresh {
+ actions.append(.reinstallPrivilegedHelper)
}
// Check conflicts first
@@ -189,6 +205,12 @@ public enum InstallerDecisionPipeline {
actions.append(.installRequiredRuntimeServices)
}
+ if context.services.kanataServiceFreshness == .stale,
+ !actions.contains(.installRequiredRuntimeServices)
+ {
+ actions.append(.installRequiredRuntimeServices)
+ }
+
appendVHIDActivationRepairIfNeeded(context: context, actions: &actions)
// Matrix row: stopped Kanata plus non-approval stale input-capture
diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift b/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift
index b56d9c2ab..8561db4f9 100644
--- a/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift
+++ b/Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift
@@ -41,7 +41,11 @@ public extension InstallerEngine {
id: InstallerRecipeID.installRequiredRuntimeServices,
type: .installComponent,
serviceID: nil,
- expectedPostconditions: [.runtimeReadyOrApprovalPending, .vhidServicesHealthy]
+ expectedPostconditions: [
+ .runtimeReadyOrApprovalPending,
+ .runtimeFreshOrApprovalPending,
+ .vhidServicesHealthy,
+ ]
)
case .installCorrectVHIDDriver:
@@ -64,7 +68,10 @@ public extension InstallerEngine {
id: InstallerRecipeID.installPrivilegedHelper,
type: .repairPrivilegedHelper,
serviceID: KeyPathConstants.Bundle.helperID,
- expectedPostconditions: [.helperReadyOrApprovalPending]
+ expectedPostconditions: [
+ .helperReadyOrApprovalPending,
+ .helperFreshOrApprovalPending
+ ]
)
case .reinstallPrivilegedHelper:
@@ -72,7 +79,10 @@ public extension InstallerEngine {
id: InstallerRecipeID.reinstallPrivilegedHelper,
type: .repairPrivilegedHelper,
serviceID: KeyPathConstants.Bundle.helperID,
- expectedPostconditions: [.helperReadyOrApprovalPending]
+ expectedPostconditions: [
+ .helperReadyOrApprovalPending,
+ .helperFreshOrApprovalPending
+ ]
)
case .startKarabinerDaemon:
diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerEngineTypes.swift b/Sources/KeyPathInstallationWizard/Core/InstallerEngineTypes.swift
index b049c2c12..780e65f8b 100644
--- a/Sources/KeyPathInstallationWizard/Core/InstallerEngineTypes.swift
+++ b/Sources/KeyPathInstallationWizard/Core/InstallerEngineTypes.swift
@@ -223,6 +223,8 @@ public enum InstallerPostcondition: String, Codable, Sendable, Equatable, CaseIt
case vhidServicesHealthy = "vhid-services-healthy"
case karabinerDaemonRunning = "karabiner-daemon-running"
case helperReadyOrApprovalPending = "helper-ready-or-approval-pending"
+ case helperFreshOrApprovalPending = "helper-fresh-or-approval-pending"
+ case runtimeFreshOrApprovalPending = "runtime-fresh-or-approval-pending"
case virtualHIDDriverInstalled = "virtualhid-driver-installed"
case virtualHIDDeviceActivated = "virtualhid-device-activated"
case conflictsResolved = "conflicts-resolved"
@@ -240,6 +242,13 @@ public enum InstallerPostcondition: String, Codable, Sendable, Equatable, CaseIt
return context.services.karabinerDaemonRunning
case .helperReadyOrApprovalPending:
return context.helper.isReady || context.helper.requiresApproval
+ case .helperFreshOrApprovalPending:
+ return context.helper.freshness(
+ expectedVersion: WizardHelperConstants.expectedHelperVersion
+ ) == .fresh || context.helper.requiresApproval
+ case .runtimeFreshOrApprovalPending:
+ return context.services.kanataServiceFreshness == .fresh
+ || context.services.loginItemsApprovalRequired == true
case .virtualHIDDriverInstalled:
return context.components.karabinerDriverInstalled
case .virtualHIDDeviceActivated:
diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift
index b697ac575..0b32b65e7 100644
--- a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift
+++ b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift
@@ -718,7 +718,17 @@ public final class ServiceBootstrapper {
AppLogger.shared.log("🔄 Attempt \(attempt)/\(maxRetries)")
try await daemonManager.unregister()
- let bootedOut = await bootOutStaleKanataSMAppServiceJob(attempt: attempt)
+ // A successful SMAppService unregister normally removes the
+ // launchd job asynchronously. Give that normal path a brief
+ // chance to settle before asking for administrator privileges
+ // to force a bootout. The privileged fallback is only needed
+ // when macOS actually leaves a stale registration behind.
+ let removedByUnregister = await waitForKanataSMAppServiceRemoval()
+ let bootedOut: Bool = if removedByUnregister {
+ true
+ } else {
+ await bootOutStaleKanataSMAppServiceJob(attempt: attempt)
+ }
if !bootedOut {
AppLogger.shared.log("❌ Attempt \(attempt) failed: stale launchd job could not be booted out")
continue
@@ -757,6 +767,32 @@ public final class ServiceBootstrapper {
return false
}
+ @MainActor
+ private func waitForKanataSMAppServiceRemoval(
+ maxAttempts: Int = 10,
+ delayMilliseconds: Int = 100
+ ) async -> Bool {
+ guard let daemonManager else { return false }
+
+ for attempt in 1 ... maxAttempts {
+ let state = await daemonManager.refreshManagementState()
+ if state == .uninstalled {
+ AppLogger.shared.log(
+ "✅ [ServiceBootstrapper] SMAppService unregister removed the Kanata job"
+ )
+ return true
+ }
+ if attempt < maxAttempts {
+ _ = await WizardSleep.ms(delayMilliseconds)
+ }
+ }
+
+ AppLogger.shared.log(
+ "⚠️ [ServiceBootstrapper] Kanata job remained after SMAppService unregister; using privileged bootout fallback"
+ )
+ return false
+ }
+
static func staleKanataSMAppServiceBootoutCommands(userID: uid_t = getuid()) -> [String] {
[
"/bin/launchctl bootout gui/\(userID)/\(kanataServiceID) 2>/dev/null || true",
@@ -797,6 +833,24 @@ public final class ServiceBootstrapper {
func _testBootOutStaleKanataSMAppServiceJob(attempt: Int = 1) async -> Bool {
await bootOutStaleKanataSMAppServiceJob(attempt: attempt)
}
+
+ func _testWaitForKanataSMAppServiceRemoval(
+ maxAttempts: Int,
+ delayMilliseconds: Int = 0
+ ) async -> Bool {
+ await waitForKanataSMAppServiceRemoval(
+ maxAttempts: maxAttempts,
+ delayMilliseconds: delayMilliseconds
+ )
+ }
+
+ func _testRegisterKanataWithSMAppService(
+ refreshActiveRegistration: Bool
+ ) async -> Bool {
+ await registerKanataWithSMAppService(
+ refreshActiveRegistration: refreshActiveRegistration
+ )
+ }
#endif
// MARK: - VHID Service Repair
@@ -907,7 +961,9 @@ public final class ServiceBootstrapper {
ServiceHealthChecker.shared.invalidateHealthCache()
daemonLoaded = await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidDaemonServiceID)
managerLoaded = await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidManagerServiceID)
- if daemonLoaded, managerLoaded { break }
+ if daemonLoaded, managerLoaded {
+ break
+ }
_ = await WizardSleep.ms(500)
}
let configured = ServiceHealthChecker.shared.isVHIDDaemonConfiguredCorrectly()
@@ -966,21 +1022,27 @@ public final class ServiceBootstrapper {
///
/// - Returns: `true` if all services were installed successfully
@MainActor
- public func installAllServices() async -> Bool {
+ public func installAllServices(
+ refreshActiveKanataRegistration: Bool = false
+ ) async -> Bool {
await installAllServices(
+ refreshActiveKanataRegistration: refreshActiveKanataRegistration,
repairVHIDServices: {
try await PrivilegeBroker().repairVHIDDaemonServices()
},
- registerKanataService: {
- await self.registerKanataWithSMAppService()
+ registerKanataService: { refreshActiveRegistration in
+ await self.registerKanataWithSMAppService(
+ refreshActiveRegistration: refreshActiveRegistration
+ )
}
)
}
@MainActor
func installAllServices(
+ refreshActiveKanataRegistration: Bool = false,
repairVHIDServices: () async throws -> Void,
- registerKanataService: () async -> Bool
+ registerKanataService: (Bool) async -> Bool
) async -> Bool {
AppLogger.shared.log("🔧 [ServiceBootstrapper] Installing all services (VHID + Kanata)")
@@ -1009,7 +1071,7 @@ public final class ServiceBootstrapper {
// Step 2: Install Kanata via SMAppService
AppLogger.shared.log("📱 [ServiceBootstrapper] Step 2: Installing Kanata via SMAppService")
- let kanataSuccess = await registerKanataService()
+ let kanataSuccess = await registerKanataService(refreshActiveKanataRegistration)
if !kanataSuccess {
AppLogger.shared.log("⚠️ [ServiceBootstrapper] SMAppService registration failed")
@@ -1033,7 +1095,9 @@ public final class ServiceBootstrapper {
///
/// - Returns: `true` if registration succeeded or already registered
@MainActor
- private func registerKanataWithSMAppService() async -> Bool {
+ private func registerKanataWithSMAppService(
+ refreshActiveRegistration: Bool
+ ) async -> Bool {
AppLogger.shared.log("📱 [ServiceBootstrapper] Registering Kanata daemon via SMAppService")
guard #available(macOS 13, *) else {
@@ -1097,6 +1161,30 @@ public final class ServiceBootstrapper {
AppLogger.shared.log("✅ [ServiceBootstrapper] Recovered active-but-not-loaded SMAppService state")
return true
}
+
+ if refreshActiveRegistration {
+ let runtimeFreshness = await daemonManager.activeRuntimeFreshness()
+ if runtimeFreshness == .stale {
+ AppLogger.shared.log(
+ "🔄 [ServiceBootstrapper] Refreshing stale active SMAppService registration for the current KeyPath bundle"
+ )
+ let refreshed = await fixBrokenSMAppServiceState()
+ if !refreshed {
+ AppLogger.shared.log(
+ "❌ [ServiceBootstrapper] Active SMAppService registration refresh failed"
+ )
+ return false
+ }
+ AppLogger.shared.log(
+ "✅ [ServiceBootstrapper] Active SMAppService registration refreshed"
+ )
+ return true
+ }
+
+ AppLogger.shared.log(
+ "✅ [ServiceBootstrapper] Active SMAppService registration does not require refresh (freshness: \(runtimeFreshness.rawValue))"
+ )
+ }
AppLogger.shared.log("✅ [ServiceBootstrapper] Already managed by SMAppService - healthy")
return true
}
diff --git a/Sources/KeyPathWizardCore/WizardDaemonManaging.swift b/Sources/KeyPathWizardCore/WizardDaemonManaging.swift
index 6cadf070e..c2e9fcc6f 100644
--- a/Sources/KeyPathWizardCore/WizardDaemonManaging.swift
+++ b/Sources/KeyPathWizardCore/WizardDaemonManaging.swift
@@ -39,6 +39,7 @@ public protocol WizardDaemonManaging: AnyObject, Sendable {
func refreshManagementState() async -> WizardServiceManagementState
func isRegisteredButNotLoaded() async -> Bool
+ func activeRuntimeFreshness() async -> RuntimeFreshness
func register() async throws
func unregister() async throws
diff --git a/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift b/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift
index 4062f182f..63be75745 100644
--- a/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift
+++ b/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift
@@ -80,6 +80,103 @@ final class InstallerEnginePlanTests: KeyPathAsyncTestCase {
)
}
+ func testRepairRefreshesWorkingButStaleHelperAfterAppUpdate() async throws {
+ let context = SystemContextBuilder(
+ helperReady: true,
+ helperVersion: "1.1.0",
+ servicesHealthy: true,
+ componentsInstalled: true
+ ).build()
+
+ let plan = await InstallerEngine().makePlan(for: .repair, context: context)
+ let recipe = try XCTUnwrap(
+ plan.recipes.first { $0.id == InstallerRecipeID.reinstallPrivilegedHelper }
+ )
+
+ XCTAssertEqual(
+ recipe.expectedPostconditions,
+ [.helperReadyOrApprovalPending, .helperFreshOrApprovalPending]
+ )
+ }
+
+ func testInstallRefreshesWorkingButStaleHelperAfterAppUpdate() async {
+ let context = SystemContextBuilder(
+ helperReady: true,
+ helperVersion: "1.1.0",
+ servicesHealthy: true,
+ componentsInstalled: true
+ ).build()
+
+ let plan = await InstallerEngine().makePlan(for: .install, context: context)
+
+ XCTAssertTrue(
+ plan.recipes.contains { $0.id == InstallerRecipeID.reinstallPrivilegedHelper }
+ )
+ XCTAssertFalse(
+ plan.recipes.contains { $0.id == InstallerRecipeID.installPrivilegedHelper }
+ )
+ }
+
+ func testHelperFreshPostconditionRejectsWorkingHelperWithUnknownVersion() {
+ let context = SystemContextBuilder(
+ helperReady: true,
+ helperVersion: nil
+ ).build()
+
+ XCTAssertTrue(InstallerPostcondition.helperReadyOrApprovalPending.isSatisfied(by: context))
+ XCTAssertFalse(InstallerPostcondition.helperFreshOrApprovalPending.isSatisfied(by: context))
+ }
+
+ func testHelperFreshPostconditionAcceptsExactVersionOrPendingApproval() {
+ let freshContext = SystemContextBuilder(
+ helperReady: true,
+ helperVersion: WizardHelperConstants.expectedHelperVersion
+ ).build()
+ let approvalContext = SystemContextBuilder(
+ helperReady: false,
+ helperRequiresApproval: true,
+ helperVersion: nil
+ ).build()
+
+ XCTAssertTrue(InstallerPostcondition.helperFreshOrApprovalPending.isSatisfied(by: freshContext))
+ XCTAssertTrue(InstallerPostcondition.helperFreshOrApprovalPending.isSatisfied(by: approvalContext))
+ }
+
+ func testHelperInstallPostconditionsRejectExactVersionWhenHelperIsNotWorking() async throws {
+ let context = SystemContextBuilder(
+ helperReady: false,
+ helperVersion: WizardHelperConstants.expectedHelperVersion,
+ servicesHealthy: true,
+ componentsInstalled: true
+ ).build()
+ let plan = await InstallerEngine().makePlan(for: .repair, context: context)
+ let recipe = try XCTUnwrap(
+ plan.recipes.first { $0.id == InstallerRecipeID.installPrivilegedHelper }
+ )
+
+ XCTAssertFalse(
+ recipe.expectedPostconditions.allSatisfy { $0.isSatisfied(by: context) }
+ )
+ XCTAssertFalse(InstallerPostcondition.helperReadyOrApprovalPending.isSatisfied(by: context))
+ XCTAssertFalse(InstallerPostcondition.helperFreshOrApprovalPending.isSatisfied(by: context))
+ }
+
+ func testRepairRefreshesHealthyButStaleKanataRuntimeAfterAppUpdate() async throws {
+ let context = SystemContextBuilder(
+ servicesHealthy: true,
+ kanataServiceFreshness: .stale,
+ componentsInstalled: true
+ ).build()
+
+ let plan = await InstallerEngine().makePlan(for: .repair, context: context)
+ let recipe = try XCTUnwrap(
+ plan.recipes.first { $0.id == InstallerRecipeID.installRequiredRuntimeServices }
+ )
+
+ XCTAssertTrue(recipe.expectedPostconditions.contains(.runtimeReadyOrApprovalPending))
+ XCTAssertTrue(recipe.expectedPostconditions.contains(.runtimeFreshOrApprovalPending))
+ }
+
func testMissingVHIDDeviceProducesExplicitActivationBeforeServiceWork() async throws {
let base = SystemContextBuilder(
servicesHealthy: false,
diff --git a/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift b/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift
index 279e28489..a639fce04 100644
--- a/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift
+++ b/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift
@@ -2,6 +2,7 @@ import Foundation
@testable import KeyPathAppKit
@testable import KeyPathCore
@testable import KeyPathInstallationWizard
+@testable import KeyPathWizardCore
@preconcurrency import XCTest
/// Unit tests for ServiceBootstrapper service.
@@ -285,7 +286,8 @@ final class ServiceBootstrapperTests: XCTestCase {
repairVHIDServices: {
operations.append("repair-vhid")
},
- registerKanataService: {
+ registerKanataService: { refreshActiveRegistration in
+ XCTAssertFalse(refreshActiveRegistration)
operations.append("register-kanata")
return true
}
@@ -306,7 +308,7 @@ final class ServiceBootstrapperTests: XCTestCase {
repairVHIDServices: {
throw ExpectedFailure()
},
- registerKanataService: {
+ registerKanataService: { _ in
registrationCalled = true
return true
}
@@ -316,6 +318,24 @@ final class ServiceBootstrapperTests: XCTestCase {
XCTAssertFalse(registrationCalled)
}
+ func testInstallAllServicesForwardsActiveKanataRefreshRequest() async {
+ TestEnvironment.allowAdminOperationsInTests = true
+ defer { TestEnvironment.allowAdminOperationsInTests = false }
+
+ var receivedRefreshRequest: Bool?
+ let result = await ServiceBootstrapper.shared.installAllServices(
+ refreshActiveKanataRegistration: true,
+ repairVHIDServices: {},
+ registerKanataService: { refreshActiveRegistration in
+ receivedRefreshRequest = refreshActiveRegistration
+ return true
+ }
+ )
+
+ XCTAssertTrue(result)
+ XCTAssertEqual(receivedRefreshRequest, true)
+ }
+
func testStaleSMAppServiceBootoutCommandsTargetGuiAndSystemDomains() {
let commands = ServiceBootstrapper.staleKanataSMAppServiceBootoutCommands(userID: 501)
@@ -341,6 +361,72 @@ final class ServiceBootstrapperTests: XCTestCase {
XCTAssertEqual(calls, 1)
}
+ func testSuccessfulUnregisterSettlesBeforePrivilegedBootoutFallback() async {
+ let originalDaemonManager = WizardDependencies.daemonManager
+ let daemonManager = SequencedWizardDaemonManager(
+ states: [.unknown, .uninstalled]
+ )
+ WizardDependencies.daemonManager = daemonManager
+ defer { WizardDependencies.daemonManager = originalDaemonManager }
+
+ let result = await ServiceBootstrapper.shared
+ ._testWaitForKanataSMAppServiceRemoval(maxAttempts: 3)
+
+ XCTAssertTrue(result)
+ XCTAssertEqual(daemonManager.refreshCount, 2)
+ }
+
+ func testPersistentRegistrationRequiresPrivilegedBootoutFallback() async {
+ let originalDaemonManager = WizardDependencies.daemonManager
+ let daemonManager = SequencedWizardDaemonManager(
+ states: [.smappserviceActive]
+ )
+ WizardDependencies.daemonManager = daemonManager
+ defer { WizardDependencies.daemonManager = originalDaemonManager }
+
+ let result = await ServiceBootstrapper.shared
+ ._testWaitForKanataSMAppServiceRemoval(maxAttempts: 3)
+
+ XCTAssertFalse(result)
+ XCTAssertEqual(daemonManager.refreshCount, 3)
+ }
+
+ func testFreshActiveRegistrationIsNotRefreshedForCompositeRuntimeRepair() async {
+ let originalDaemonManager = WizardDependencies.daemonManager
+ let daemonManager = SequencedWizardDaemonManager(
+ states: [.smappserviceActive],
+ runtimeFreshness: .fresh
+ )
+ WizardDependencies.daemonManager = daemonManager
+ defer { WizardDependencies.daemonManager = originalDaemonManager }
+
+ let result = await ServiceBootstrapper.shared
+ ._testRegisterKanataWithSMAppService(refreshActiveRegistration: true)
+
+ XCTAssertTrue(result)
+ XCTAssertEqual(daemonManager.freshnessProbeCount, 1)
+ XCTAssertEqual(daemonManager.unregisterCount, 0)
+ XCTAssertEqual(daemonManager.registerCount, 0)
+ }
+
+ func testStaleActiveRegistrationIsRefreshedForRuntimeUpdate() async {
+ let originalDaemonManager = WizardDependencies.daemonManager
+ let daemonManager = SequencedWizardDaemonManager(
+ states: [.smappserviceActive, .uninstalled],
+ runtimeFreshness: .stale
+ )
+ WizardDependencies.daemonManager = daemonManager
+ defer { WizardDependencies.daemonManager = originalDaemonManager }
+
+ let result = await ServiceBootstrapper.shared
+ ._testRegisterKanataWithSMAppService(refreshActiveRegistration: true)
+
+ XCTAssertTrue(result)
+ XCTAssertEqual(daemonManager.freshnessProbeCount, 1)
+ XCTAssertEqual(daemonManager.unregisterCount, 1)
+ XCTAssertEqual(daemonManager.registerCount, 1)
+ }
+
func testRepairVHIDDaemonServicesInTestModeSetsOutput() async {
let bootstrapper = ServiceBootstrapper.shared
let result = await withAdminOperationsSkippedForTesting {
@@ -383,3 +469,54 @@ final class ServiceBootstrapperTests: XCTestCase {
return await body()
}
}
+
+@MainActor
+private final class SequencedWizardDaemonManager: WizardDaemonManaging {
+ private let states: [WizardServiceManagementState]
+ private let runtimeFreshness: RuntimeFreshness
+ private var index = 0
+ private(set) var refreshCount = 0
+ private(set) var freshnessProbeCount = 0
+ private(set) var registerCount = 0
+ private(set) var unregisterCount = 0
+
+ init(
+ states: [WizardServiceManagementState],
+ runtimeFreshness: RuntimeFreshness = .unknown
+ ) {
+ self.states = states
+ self.runtimeFreshness = runtimeFreshness
+ }
+
+ func refreshManagementState() async -> WizardServiceManagementState {
+ refreshCount += 1
+ guard !states.isEmpty else { return .unknown }
+ let state = states[min(index, states.count - 1)]
+ index += 1
+ return state
+ }
+
+ func isRegisteredButNotLoaded() async -> Bool {
+ false
+ }
+
+ func activeRuntimeFreshness() async -> RuntimeFreshness {
+ freshnessProbeCount += 1
+ return runtimeFreshness
+ }
+
+ func register() async throws {
+ registerCount += 1
+ }
+
+ func unregister() async throws {
+ unregisterCount += 1
+ }
+
+ nonisolated let kanataServiceID = "com.keypath.kanata"
+ nonisolated let legacyPlistPath = "/Library/LaunchDaemons/com.keypath.kanata.plist"
+
+ func preferredLaunchctlTargets(for _: WizardServiceManagementState) -> [String] {
+ []
+ }
+}
diff --git a/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift b/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift
index d8fd76e03..c91e1c6db 100644
--- a/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift
+++ b/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift
@@ -112,6 +112,10 @@ final class ServiceHealthCheckerTests: XCTestCase {
registeredButNotLoaded
}
+ func activeRuntimeFreshness() async -> RuntimeFreshness {
+ .unknown
+ }
+
func register() async throws {}
func unregister() async throws {}
diff --git a/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift b/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift
index 8f0c1d3ef..57cfa66d5 100644
--- a/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift
+++ b/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift
@@ -10,6 +10,7 @@ struct SystemContextBuilder {
var permissionsStatus: PermissionOracle.Status = .granted
var helperReady: Bool = true
var helperRequiresApproval: Bool = false
+ var helperVersion: String? = WizardHelperConstants.expectedHelperVersion
var servicesHealthy: Bool = false
var kanataLaunchdLoaded: Bool?
var kanataProcessRunning: Bool?
@@ -21,6 +22,7 @@ struct SystemContextBuilder {
var vhidHealthy: Bool?
var loginItemsApprovalRequired: Bool?
var kanataInputCaptureReady: Bool = true
+ var kanataServiceFreshness: RuntimeFreshness = .unknown
/// The input-capture failure reason surfaced when not ready (#624 attribution).
/// Defaults to the built-in-keyboard permission reason; set to a grab-failure
/// reason, or explicitly nil, to exercise the other branches.
@@ -46,7 +48,7 @@ struct SystemContextBuilder {
let helper = HelperStatus(
isInstalled: helperReady || helperRequiresApproval,
- version: WizardHelperConstants.expectedHelperVersion,
+ version: helperVersion,
isWorking: helperReady,
requiresApproval: helperRequiresApproval
)
@@ -75,6 +77,7 @@ struct SystemContextBuilder {
vhidHealthy: vhidHealthy ?? servicesHealthy,
kanataInputCaptureReady: kanataInputCaptureReady,
kanataInputCaptureIssue: kanataInputCaptureReady ? nil : kanataInputCaptureIssue,
+ kanataServiceFreshness: kanataServiceFreshness,
kanataSMAppServiceRegistered: kanataSMAppServiceRegistered,
loginItemsApprovalRequired: loginItemsApprovalRequired
)