Skip to content
Merged
4 changes: 2 additions & 2 deletions Sources/KeyPathApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<key>CFBundleDisplayName</key>
<string>KeyPath</string>
<key>CFBundleVersion</key>
<string>4</string>
<string>9</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>1.0.1</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
Expand Down
10 changes: 9 additions & 1 deletion Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
nonisolated(unsafe) static var installAllServicesOverride: (() async throws -> Void)?
nonisolated(unsafe) static var recoverRequiredRuntimeServicesOverride: (() async throws -> Void)?
nonisolated(unsafe) static var killExistingKanataProcessesOverride: (() async throws -> Void)?
nonisolated(unsafe) static var helperRecoverRequiredRuntimeServicesOverride: (() async throws -> Void)?

Check warning on line 43 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Variable name 'helperRecoverRequiredRuntimeServicesOverride' should be between 1 and 40 characters long (identifier_name)

Check warning on line 43 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Variable name 'helperRecoverRequiredRuntimeServicesOverride' should be between 1 and 40 characters long (identifier_name)
nonisolated(unsafe) static var sudoRestartServicesOverride: (() async throws -> Void)?
nonisolated(unsafe) static var helperInstallNewsyslogConfigOverride: (() async throws -> Void)?
nonisolated(unsafe) static var sudoInstallNewsyslogConfigOverride: (() async throws -> Void)?
Expand All @@ -52,7 +52,7 @@
nonisolated(unsafe) static var helperKillAllKanataOverride: (() async throws -> Void)?
nonisolated(unsafe) static var sudoKillAllKanataOverride: (() async throws -> Void)?
nonisolated(unsafe) static var activateVirtualHIDManagerOverride: (() async throws -> Void)?
nonisolated(unsafe) static var downloadAndInstallCorrectVHIDDriverOverride: (() async throws -> Void)?

Check warning on line 55 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Variable name 'downloadAndInstallCorrectVHIDDriverOverride' should be between 1 and 40 characters long (identifier_name)

Check warning on line 55 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Variable name 'downloadAndInstallCorrectVHIDDriverOverride' should be between 1 and 40 characters long (identifier_name)
nonisolated(unsafe) static var kanataReadinessOverride:
((String) async -> KanataReadinessResult)?
nonisolated(unsafe) static var helperRepairVHIDDaemonServicesOverride: (() async throws -> Void)?
Expand Down Expand Up @@ -480,7 +480,15 @@
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")
}
Expand Down Expand Up @@ -826,7 +834,7 @@
{
return true
}
do { try await Task.sleep(for: Self.postconditionPollInterval) }

Check warning on line 837 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 837 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { return false }
} while Date() < deadline

Expand Down Expand Up @@ -884,7 +892,7 @@
if !SystemStateProvider.isProcessAlive(pid: pid) {
return true
}
do { try await Task.sleep(for: Self.postconditionPollInterval) }

Check warning on line 895 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 895 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { return false }
} while Date() < deadline

Expand All @@ -905,7 +913,7 @@
repeat {
let pids = await SystemStateProvider.shared.processIDs(matching: "kanata.*--cfg")
if pids.isEmpty { return true }
do { try await Task.sleep(for: Self.postconditionPollInterval) }

Check warning on line 916 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 916 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { return false }
} while Date() < deadline

Expand Down Expand Up @@ -973,8 +981,8 @@
max(0, deadline.timeIntervalSince(Date()))
)
if sleepSeconds > 0 {
do { try await Task.sleep(for: .seconds(sleepSeconds)) }

Check warning on line 984 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 984 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch is CancellationError { return .timedOut }

Check warning on line 985 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 985 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch {}
}
}
Expand Down Expand Up @@ -1009,15 +1017,15 @@
/bin/launchctl bootout system/\(KanataDaemonManager.kanataServiceID) 2>/dev/null || true && \
/bin/rm -f '\(path)'
"""
do { try await sudoExecuteCommand(cmd, description: "Remove legacy Kanata plist") }

Check warning on line 1020 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 1020 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch {}
}

private func helperRestartKarabinerDaemonVerified() async throws -> Bool {
do { try await helperManager.restartKarabinerDaemon() }

Check warning on line 1025 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)

Check warning on line 1025 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch {}

do { try await helperManager.recoverRequiredRuntimeServices() }

Check warning on line 1028 in Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch {}

let vhidManager = VHIDDeviceManager()
Expand Down
15 changes: 15 additions & 0 deletions Sources/KeyPathAppKit/WizardProtocolConformances.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +70 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify the helper's registered bundle identity

When upgrading from build 4 to this build, the existing BundleProgram helper can still point at the previous app bundle while responding with the unchanged KeyPathHelperContract.version (1.3.0), so this condition classifies it as fresh and skips re-registration. This is concrete for this update because the shipped helper behavior changed to require VirtualHID 8.0 while the helper version remained 1.3.0; compare the registered parent bundle version/path, or force unregister/register after the bundle update, rather than relying only on the XPC version response.

AGENTS.md reference: AGENTS.md:L317-L320

Useful? React with 👍 / 👎.

{
actions.append(
context.helper.isInstalled ? .reinstallPrivilegedHelper : .installPrivilegedHelper
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@
id: InstallerRecipeID.installRequiredRuntimeServices,
type: .installComponent,
serviceID: nil,
expectedPostconditions: [.runtimeReadyOrApprovalPending, .vhidServicesHealthy]
expectedPostconditions: [
.runtimeReadyOrApprovalPending,
.runtimeFreshOrApprovalPending,
.vhidServicesHealthy,

Check warning on line 47 in Sources/KeyPathInstallationWizard/Core/InstallerEngine+Recipes.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
]
)

case .installCorrectVHIDDriver:
Expand All @@ -64,15 +68,21 @@
id: InstallerRecipeID.installPrivilegedHelper,
type: .repairPrivilegedHelper,
serviceID: KeyPathConstants.Bundle.helperID,
expectedPostconditions: [.helperReadyOrApprovalPending]
expectedPostconditions: [
.helperReadyOrApprovalPending,
.helperFreshOrApprovalPending
]
)

case .reinstallPrivilegedHelper:
ServiceRecipe(
id: InstallerRecipeID.reinstallPrivilegedHelper,
type: .repairPrivilegedHelper,
serviceID: KeyPathConstants.Bundle.helperID,
expectedPostconditions: [.helperReadyOrApprovalPending]
expectedPostconditions: [
.helperReadyOrApprovalPending,
.helperFreshOrApprovalPending
]
)

case .startKarabinerDaemon:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down
104 changes: 96 additions & 8 deletions Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)")

Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Sources/KeyPathWizardCore/WizardDaemonManaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading