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 )