Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ public final class PrivilegedOperationsRouter {
case .privilegedHelper:
do {
try await helperManager.installRequiredRuntimeServices()
// Stale-helper guard (MAL-57): a resident helper from before
// the ProcessType=Interactive template fix reports success but
// rewrites the old plist — the helper binary only refreshes on
// process restart, so it can lag the app across an update.
// Verify the result and redo the rewrite via the in-app sudo
// path (ServiceBootstrapper + current PlistGenerator, with its
// own configured-postflight). Must NOT route back through the
// helper — sudoInstallRequiredRuntimeServices would, via
// InstallerEngine → PrivilegeBroker → this router's
// helper-first repairVHIDDaemonServices.
if serviceHealthChecker.isVHIDDaemonPlistPresentButMisconfigured() {
AppLogger.shared.warn(
"⚠️ [PrivilegedOperationsRouter] VHID daemon plist still misconfigured after helper install — stale helper template, rewriting via in-app sudo path"
)
try await sudoRepairVHIDServices()
}
} catch {
try await sudoInstallRequiredRuntimeServices()
}
Expand Down
15 changes: 14 additions & 1 deletion Sources/KeyPathAppKit/Services/System/SystemValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
///
/// This replaces StartupValidator + SystemStatusChecker with a single, simple validator.
/// Key design principles:
/// - STATELESS: No @Published properties, no cached results

Check warning on line 13 in Sources/KeyPathAppKit/Services/System/SystemValidator.swift

View workflow job for this annotation

GitHub Actions / code-quality

Use @observable macro instead of @published. Properties on @observable classes are automatically tracked. (no_published)
/// - PULL-BASED: UI explicitly calls checkSystem() when it wants state
/// - NO SIDE EFFECTS: Only inspects system, doesn't change anything
/// - DEFENSIVE: Assertions catch validation spam and other bugs
Expand Down Expand Up @@ -47,6 +47,7 @@
let vhidInstalled: Bool
let vhidVersionMismatch: Bool
let karabinerDriverExtensionEnabled: Bool
let vhidDaemonPlistMisconfigured: Bool
let timestamp: Date

func isFresh(ttl: TimeInterval) -> Bool {
Expand Down Expand Up @@ -432,7 +433,7 @@

AppLogger.shared
.log(
"🔍 [SystemValidator] Components: kanata=\(facts.kanataBinaryInstalled), driver=\(karabinerDriverInstalled), daemon=\(karabinerDaemonRunning), vhid=\(vhidHealthy), vhidServices=\(vhidServicesHealthy), vhidVersionMismatch=\(facts.vhidVersionMismatch)"
"🔍 [SystemValidator] Components: kanata=\(facts.kanataBinaryInstalled), driver=\(karabinerDriverInstalled), daemon=\(karabinerDaemonRunning), vhid=\(vhidHealthy), vhidServices=\(vhidServicesHealthy), vhidPlistMisconfigured=\(facts.vhidDaemonPlistMisconfigured), vhidVersionMismatch=\(facts.vhidVersionMismatch)"

Check warning on line 436 in Sources/KeyPathAppKit/Services/System/SystemValidator.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 327 characters (line_length)
)

return ComponentStatus(
Expand All @@ -442,6 +443,7 @@
vhidDeviceInstalled: facts.vhidInstalled,
vhidDeviceHealthy: vhidHealthy,
vhidServicesHealthy: vhidServicesHealthy,
vhidDaemonPlistMisconfigured: facts.vhidDaemonPlistMisconfigured,
vhidVersionMismatch: facts.vhidVersionMismatch
)
}
Expand Down Expand Up @@ -474,11 +476,22 @@
"⏱️ [TIMING] SystemValidator.checkComponents Karabiner extension check completed in \(String(format: "%.3f", Date().timeIntervalSince(karabinerStart)))s"
)

// A VHID daemon plist from before the MAL-57 fix (missing
// ProcessType=Interactive) keeps the daemon running day-to-day but
// leaves it vulnerable to starvation under CPU load (stuck-key
// autorepeat). Surface it so the wizard offers a one-click repair
// that rewrites the plist — old installs never migrate otherwise.
// Lives in the cached facts so the plist read doesn't run on every
// validation cycle; invalidateCaches() refreshes it after repairs.
let vhidDaemonPlistMisconfigured =
ServiceHealthChecker.shared.isVHIDDaemonPlistPresentButMisconfigured()

let facts = ComponentInstallationFacts(
kanataBinaryInstalled: kanataBinaryInstalled,
vhidInstalled: vhidInstalled,
vhidVersionMismatch: vhidVersionMismatch,
karabinerDriverExtensionEnabled: karabinerDriverExtensionEnabled,
vhidDaemonPlistMisconfigured: vhidDaemonPlistMisconfigured,
timestamp: Date()
)
cachedComponentFacts = facts
Expand Down Expand Up @@ -525,7 +538,7 @@

AppLogger.shared
.log(
"🔍 [SystemValidator] Total conflicts: \(allConflicts.count) (\(conflictResolution.externalProcesses.count) kanata, \(allConflicts.count - conflictResolution.externalProcesses.count) karabiner)"

Check warning on line 541 in Sources/KeyPathAppKit/Services/System/SystemValidator.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 209 characters (line_length)
)

return ConflictStatus(
Expand Down Expand Up @@ -570,7 +583,7 @@
)
let kanataDuration = Date().timeIntervalSince(kanataStart)
AppLogger.shared.log(
"🔍 [SystemValidator] checkHealth() - Kanata service check complete: hostRunning=\(kanataHealth.isRunning), tcpResponding=\(kanataHealth.isResponding), healthy=\(kanataRunning), inputCaptureReady=\(kanataHealth.inputCaptureReady), permRejected=\(stderrDiagnosis.permissionRejected) (took \(String(format: "%.3f", kanataDuration))s)"

Check warning on line 586 in Sources/KeyPathAppKit/Services/System/SystemValidator.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 343 characters (line_length)
)

// Use launchctl-based check instead of unreliable pgrep
Expand All @@ -596,7 +609,7 @@

let totalDuration = Date().timeIntervalSince(startTime)
AppLogger.shared.log(
"🔍 [SystemValidator] checkHealth() EXIT - Health: kanata=\(kanataRunning), daemon=\(karabinerDaemonRunning) (launchctl), vhid=\(vhidHealthy), permRejected=\(stderrDiagnosis.permissionRejected) (total: \(String(format: "%.3f", totalDuration))s)"

Check warning on line 612 in Sources/KeyPathAppKit/Services/System/SystemValidator.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 256 characters (line_length)
)

if stderrDiagnosis.configParseError != nil, configParseError == nil {
Expand Down
6 changes: 4 additions & 2 deletions Sources/KeyPathInstallationWizard/Core/ActionDeterminer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ public enum ActionDeterminer {
}
}

if !context.components.vhidServicesHealthy {
// Covers a pre-MAL-57 plist (missing ProcessType=Interactive) too:
// it needs the rewrite even when the daemon is otherwise healthy.
if context.components.vhidRuntimeServicesNeedRepair {
actions.append(.installRequiredRuntimeServices)
}

Expand Down Expand Up @@ -115,7 +117,7 @@ public enum ActionDeterminer {
actions.append(.installPrivilegedHelper)
}

if !context.components.vhidServicesHealthy {
if context.components.vhidRuntimeServicesNeedRepair {
actions.append(.installRequiredRuntimeServices)
}

Expand Down
44 changes: 34 additions & 10 deletions Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@
let runtimeSnapshot = await checkKanataServiceRuntimeSnapshot()
let decision = Self.decideKanataHealth(for: runtimeSnapshot)
AppLogger.shared.log(
"🔍 [ServiceHealthChecker] Kanata SMAppService decision: \(decision), running=\(runtimeSnapshot.isRunning), responding=\(runtimeSnapshot.isResponding), stale=\(runtimeSnapshot.staleEnabledRegistration), state=\(state.description)"

Check warning on line 319 in Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 249 characters (line_length)
)
return decision.isHealthy
}
Expand Down Expand Up @@ -440,7 +440,7 @@
&& !runtimeSnapshot.staleEnabledRegistration
kanataHealthy = Self.decideKanataHealth(for: runtimeSnapshot).isHealthy
AppLogger.shared.log(
"🔍 [ServiceHealthChecker] Kanata SMAppService-managed: loaded=\(kanataLoaded), healthy=\(kanataHealthy), stale=\(runtimeSnapshot.staleEnabledRegistration), launchctlExit=\(runtimeSnapshot.launchctlExitCode?.description ?? "nil")"

Check warning on line 443 in Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 245 characters (line_length)
)
} else {
// Legacy or unknown - use full checks
Expand Down Expand Up @@ -846,7 +846,7 @@

// Fallback: use the [ERROR] line
for line in lines.reversed() {
if line.contains("[ERROR]") {

Check warning on line 849 in Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift

View workflow job for this annotation

GitHub Actions / code-quality

`where` clauses are preferred over a single `if` inside a `for` (for_where)
if let range = line.range(of: "[ERROR]") {
let raw = String(line[range.upperBound...])
.trimmingCharacters(in: .whitespaces)
Expand Down Expand Up @@ -889,22 +889,46 @@
)
return false
}
return Self.vhidDaemonPlistContentIsValid(dict)
}

let expectedPath =
"/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/Applications/Karabiner-VirtualHIDDevice-Daemon.app/Contents/MacOS/Karabiner-VirtualHIDDevice-Daemon"
/// Whether an installed VHID daemon plist predates the MAL-57 fix and needs rewriting.
///
/// Presence-gated: a missing plist returns `false` — that situation is
/// "services not installed" and is already surfaced via service health.
/// A plist that exists but has stale content (wrong program path or
/// missing ProcessType=Interactive) counts as misconfigured, so existing
/// installs migrate via a wizard repair. A present-but-unparseable plist
/// also counts: repair rewrites it either way.
///
/// - Returns: `true` if the plist exists but is misconfigured
public func isVHIDDaemonPlistPresentButMisconfigured() -> Bool {
let plistPath = getPlistPath(for: Self.vhidDaemonServiceID)
guard let dict = NSDictionary(contentsOfFile: plistPath) as? [String: Any] else {
return Foundation.FileManager().fileExists(atPath: plistPath)
}
return !Self.vhidDaemonPlistContentIsValid(dict)
}

if let args = dict["ProgramArguments"] as? [String], let first = args.first {
let pathOK = first == expectedPath
let processTypeOK = (dict["ProcessType"] as? String) == "Interactive"
/// Shared content check for the VHID daemon plist: DriverKit daemon path
/// in ProgramArguments plus the MAL-57 ProcessType=Interactive key.
/// Logs only when the content is invalid — this runs on every validation
/// cycle via SystemValidator, not just during install/repair.
private static func vhidDaemonPlistContentIsValid(_ dict: [String: Any]) -> Bool {
guard let args = dict["ProgramArguments"] as? [String], let first = args.first else {
AppLogger.shared.log(
"🔍 [ServiceHealthChecker] VHID plist ProgramArguments missing or malformed"
)
return false
}
let pathOK = first == PlistGenerator.vhidDaemonPath
let processTypeOK = (dict["ProcessType"] as? String) == "Interactive"
if !pathOK || !processTypeOK {
AppLogger.shared.log(
"🔍 [ServiceHealthChecker] VHID plist ProgramArguments[0]=\(first) | pathOK=\(pathOK) | processTypeOK=\(processTypeOK)"
)
return pathOK && processTypeOK
}
AppLogger.shared.log(
"🔍 [ServiceHealthChecker] VHID plist ProgramArguments missing or malformed"
)
return false
return pathOK && processTypeOK
}

// MARK: - Private Helpers
Expand Down Expand Up @@ -934,7 +958,7 @@
guard let data = try? fileHandle.readToEnd(), !data.isEmpty else {
return nil
}
return String(decoding: data, as: UTF8.self)

Check warning on line 961 in Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift

View workflow job for this annotation

GitHub Actions / code-quality

Prefer failable `String(bytes:encoding:)` initializer when converting `Data` to `String` (optional_data_string_conversion)
}

/// Get the launchd daemons directory path
Expand Down
18 changes: 18 additions & 0 deletions Sources/KeyPathInstallationWizard/Core/SystemInspector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,24 @@
userAction: nil
))
}
// Gated on healthy services: when they're down, the issue above
// already carries the same fix and the rewrite happens anyway.
// Severity .warning, not .error: the daemon still works day-to-day,
// so this must nudge toward a one-click migration without flipping
// the whole app to a failed state (MainAppStateController counts
// .error issues as blocking). Wizard routing and the fix button are
// identifier-based and severity-agnostic, so the repair path is intact.
if context.components.vhidDaemonPlistMisconfigured, context.components.vhidServicesHealthy {
issues.append(WizardIssue(
identifier: .component(.vhidDaemonMisconfigured),
severity: .warning,
category: .installation,
title: "VHID Daemon Configuration Outdated",
description: "The Karabiner VirtualHID daemon service uses an outdated configuration that can cause stuck or repeating keys under heavy CPU load. Repair reinstalls the service with the corrected settings.",

Check warning on line 272 in Sources/KeyPathInstallationWizard/Core/SystemInspector.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 222 characters (line_length)
autoFixAction: .installRequiredRuntimeServices,
userAction: nil
))
}
}

// MARK: - Conflict Issues
Expand Down
18 changes: 18 additions & 0 deletions Sources/KeyPathWizardCore/SystemSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ public struct ComponentStatus: Sendable {
/// VHID services health (daemon + manager) independent of Kanata service
/// Use for Karabiner Components page which should only care about VHID, not Kanata
public let vhidServicesHealthy: Bool
/// True when the installed VHID daemon plist exists but predates the
/// MAL-57 fix (wrong program path or missing ProcessType=Interactive).
/// The daemon may be running fine, but it is vulnerable to starvation
/// under CPU load and the plist should be rewritten via repair.
public let vhidDaemonPlistMisconfigured: Bool
public let vhidVersionMismatch: Bool

public init(
Expand All @@ -192,6 +197,7 @@ public struct ComponentStatus: Sendable {
vhidDeviceInstalled: Bool,
vhidDeviceHealthy: Bool,
vhidServicesHealthy: Bool,
vhidDaemonPlistMisconfigured: Bool = false,
vhidVersionMismatch: Bool
) {
self.kanataBinaryInstalled = kanataBinaryInstalled
Expand All @@ -200,15 +206,27 @@ public struct ComponentStatus: Sendable {
self.vhidDeviceInstalled = vhidDeviceInstalled
self.vhidDeviceHealthy = vhidDeviceHealthy
self.vhidServicesHealthy = vhidServicesHealthy
self.vhidDaemonPlistMisconfigured = vhidDaemonPlistMisconfigured
self.vhidVersionMismatch = vhidVersionMismatch
}

/// Required components for the normal split-runtime architecture.
/// Deliberately excludes `vhidDaemonPlistMisconfigured`: a stale plist
/// still runs day-to-day, so it surfaces as a repairable wizard issue
/// rather than a missing component that would fail readiness app-wide.
public var hasAllRequired: Bool {
kanataBinaryInstalled && karabinerDriverInstalled && karabinerDaemonRunning && vhidDeviceHealthy
&& vhidServicesHealthy && !vhidVersionMismatch
}

/// The VHID runtime services need (re)installation: either they are
/// unhealthy, or the installed daemon plist predates the MAL-57 fix and
/// must be rewritten. Single source for the installRequiredRuntimeServices
/// trigger so repair and install plans cannot drift apart.
public var vhidRuntimeServicesNeedRepair: Bool {
!vhidServicesHealthy || vhidDaemonPlistMisconfigured
}

/// Convenience factory for empty/fallback state
public static var empty: ComponentStatus {
ComponentStatus(
Expand Down
92 changes: 92 additions & 0 deletions Tests/KeyPathTests/InstallationWizard/WizardPureLogicTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,64 @@ final class WizardPureLogicTests: XCTestCase {
XCTAssertEqual(serviceIssue?.autoFixAction, .installRequiredRuntimeServices)
}

/// A pre-MAL-57 plist (missing ProcessType=Interactive) on an otherwise
/// healthy system must surface proactively with a one-click repair —
/// old installs never migrate otherwise.
func test_inspect_vhidDaemonPlistMisconfigured_producesMisconfiguredIssueWithAutoFix() {
let components = ComponentStatus(
kanataBinaryInstalled: true,
karabinerDriverInstalled: true,
karabinerDaemonRunning: true,
vhidDeviceInstalled: true,
vhidDeviceHealthy: true,
vhidServicesHealthy: true,
vhidDaemonPlistMisconfigured: true,
vhidVersionMismatch: false
)
let context = makeContext(components: components)
let (state, issues) = SystemInspector.inspect(context: context)

let issue = issues.first { $0.identifier == .component(.vhidDaemonMisconfigured) }
XCTAssertNotNil(issue, "Stale VHID daemon plist should generate a misconfigured issue")
XCTAssertEqual(issue?.category, .installation, "Must be .installation so the router sends it to the Karabiner components page")
XCTAssertEqual(
issue?.severity, .warning,
"Daemon still works — warning nudges migration without flipping the app to a failed state"
)
XCTAssertEqual(issue?.autoFixAction, .installRequiredRuntimeServices)

// The daemon is still running — state stays .active; the issue alone
// drives routing and the components-page repair button.
XCTAssertEqual(state, .active)
}

/// When the VHID services are already down, the unhealthy-services issue
/// carries the same fix — don't double-alarm for one root cause.
func test_inspect_vhidDaemonPlistMisconfiguredAndServicesUnhealthy_emitsSingleIssue() {
let components = ComponentStatus(
kanataBinaryInstalled: true,
karabinerDriverInstalled: true,
karabinerDaemonRunning: true,
vhidDeviceInstalled: true,
vhidDeviceHealthy: true,
vhidServicesHealthy: false,
vhidDaemonPlistMisconfigured: true,
vhidVersionMismatch: false
)
let context = makeContext(components: components)
let (_, issues) = SystemInspector.inspect(context: context)

XCTAssertNil(
issues.first { $0.identifier == .component(.vhidDaemonMisconfigured) },
"Misconfigured-plist issue is suppressed while services are unhealthy"
)
let serviceIssue = issues.first { $0.identifier == .component(.vhidDeviceManager) }
XCTAssertEqual(
serviceIssue?.autoFixAction, .installRequiredRuntimeServices,
"The unhealthy-services issue carries the same repair"
)
}

func test_inspect_multipleIssues_allReported() {
let components = ComponentStatus(
kanataBinaryInstalled: true,
Expand Down Expand Up @@ -1025,8 +1083,42 @@ final class WizardPureLogicTests: XCTestCase {
XCTAssertTrue(actions.contains(.installRequiredRuntimeServices))
}

func test_determineRepairActions_vhidDaemonPlistMisconfigured_includesInstallRuntimeServices() {
let components = ComponentStatus(
kanataBinaryInstalled: true,
karabinerDriverInstalled: true,
karabinerDaemonRunning: true,
vhidDeviceInstalled: true,
vhidDeviceHealthy: true,
vhidServicesHealthy: true,
vhidDaemonPlistMisconfigured: true,
vhidVersionMismatch: false
)
let context = makeContext(components: components)
let actions = ActionDeterminer.determineRepairActions(context: context)
XCTAssertTrue(actions.contains(.installRequiredRuntimeServices),
"Stale VHID daemon plist needs the runtime-services rewrite even when the daemon is healthy")
}

// MARK: - ActionDeterminer: Install Actions

func test_determineInstallActions_vhidDaemonPlistMisconfigured_includesInstallRuntimeServices() {
let components = ComponentStatus(
kanataBinaryInstalled: true,
karabinerDriverInstalled: true,
karabinerDaemonRunning: true,
vhidDeviceInstalled: true,
vhidDeviceHealthy: true,
vhidServicesHealthy: true,
vhidDaemonPlistMisconfigured: true,
vhidVersionMismatch: false
)
let context = makeContext(components: components)
let actions = ActionDeterminer.determineInstallActions(context: context)
XCTAssertTrue(actions.contains(.installRequiredRuntimeServices),
"Install plans share the vhidRuntimeServicesNeedRepair trigger with repair plans")
}

func test_determineInstallActions_allHealthy_returnsEmpty() {
let context = makeContext()
let actions = ActionDeterminer.determineInstallActions(context: context)
Expand Down
Loading
Loading