Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
return nil
}

static let legacyDaemonRefusalMessage =
"The keyboard service from KeyPath 1.0.1 is still running. Remove it to start KeyPath."

/// The worker's argv. Uninstall's sweep recognizes workers by these flags,
/// so a test pins them to SessionWorkerProcess.parse.
nonisolated static func sessionLaunchArguments(
Expand Down Expand Up @@ -126,6 +129,14 @@
return sessionLegacyDaemonRefusal
}

/// Re-reads the legacy daemon with fresh evidence after the 1.0.1 stack was
/// removed, clearing the refusal only if it is really gone. Returns whether a
/// refusal still stands.
func refreshLegacyDaemonRefusalAfterCleanup() async -> Bool {
await kanataDaemonService.invalidateEvidenceCaches()
return await legacyDaemonRefusalForStartup() != nil
}

/// The published error goes too, or Settings would keep naming a stopped daemon.
func clearLegacyDaemonRefusal() {
guard sessionLegacyDaemonRefusal != nil else { return }
Expand All @@ -152,7 +163,7 @@
let legacyRunning = await isLegacyDaemonRunning()
guard sessionStartIsCurrent(generation) else { return false }
sessionLegacyDaemonRefusal = legacyRunning
? "Stop the existing keyboard service before trying driverless mode." : nil
? Self.legacyDaemonRefusalMessage : nil
if let sessionLegacyDaemonRefusal {
onError?(sessionLegacyDaemonRefusal)
onStateChanged?()
Expand Down Expand Up @@ -543,7 +554,7 @@
if let terminalReport {
AppLogger.shared
.warnUnlessQuietTest(
"[SessionRuntime] Session ended: state=\(terminalReport.state) failure=\(terminalReport.failure ?? "none") retirementReason=\(terminalReport.retirementReason ?? "none") at=\(terminalReport.timestamp)"

Check warning on line 557 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 220 characters (line_length)
)
} else {
AppLogger.shared.warnUnlessQuietTest("[SessionRuntime] Session ended without a terminal report")
Expand Down Expand Up @@ -653,7 +664,7 @@
continue
}
#endif
do { try await Task.sleep(for: .milliseconds(250)) }

Check warning on line 667 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { return false }
}
return false
Expand Down Expand Up @@ -697,7 +708,7 @@
guard !IsSecureEventInputEnabled() else { return "secure-input" }
guard let console = SessionRuntimeEnvironmentObserver.currentConsole(),
console.uid == getuid(), console.onConsole, console.loginDone else { return "console-not-ready" }
do { try SessionCapsRuntimeSupport.requirePhysicalAllUp(); return nil }

Check warning on line 711 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

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

Expand All @@ -724,7 +735,7 @@
case SessionCapsRuntimeSupport.keyboardDisconnectedReason:
return "The keyboard chosen for Caps Lock was disconnected. Choose a keyboard again in Settings > General."
case SessionCapsRuntimeSupport.ownershipLostReason:
return "KeyPath could no longer verify its Caps Lock mapping on the chosen keyboard, so it stopped and restored it. If another app changed Caps Lock, or a second identical keyboard is connected, fix that, then quit and reopen KeyPath."

Check warning on line 738 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 247 characters (line_length)
case "tap-disabled-by-timeout":
return "macOS stopped keyboard capture because it timed out. Quit and reopen KeyPath to recover."
default:
Expand Down Expand Up @@ -843,7 +854,7 @@
} else if SessionCapsRuntimeSupport.selectedKeyboardOnly(),
let selection = try await chooseCapsSelection()
{
passed = try String(decoding: JSONEncoder().encode(selection), as: UTF8.self)

Check warning on line 857 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Prefer failable `String(bytes:encoding:)` initializer when converting `Data` to `String` (optional_data_string_conversion)
sessionCapsIntermediate = selection.intermediateUsage
}
configuration.environment = [SessionCapsRuntimeSupport.selectionEnvironmentKey: passed]
Expand All @@ -867,7 +878,7 @@
$0.replacingOccurrences(of: $1, with: "[redacted-path]")
}.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
AppLogger.shared.log(
"Session application launch failed parentPID=\(getpid()) nonce=\(nonce) capabilitiesOnly=\(capabilitiesOnly) domain=\(launchError.domain.prefix(128)) code=\(launchError.code) description=\(description.prefix(256))"

Check warning on line 881 in Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 230 characters (line_length)
)
try? FileManager.default.removeItem(at: directory)
throw error
Expand Down
13 changes: 13 additions & 0 deletions Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,19 @@ final class KanataDaemonService {
}
}

/// Drops cached launchd and ServiceManagement evidence so the next read is
/// fresh. Used after the 1.0.1 daemon is removed, which the PID cache (10s)
/// and the cached SMAppService status would otherwise still report. Call it
/// only after the removal was verified: it forgets a previously running state.
func invalidateEvidenceCaches() async {
await pidCache.invalidateCache()
await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: Constants.daemonPlistName)
// If a registration lingers with no process, the status logic holds a
// prior `.running` for a few samples, which would refuse the next start.
enabledWithoutProcessSampleCount = 0
lastObservedState = .stopped
}

/// Returns whether the internal recovery daemon is currently active.
func isDaemonRunning() async -> Bool {
let status = await refreshStatus()
Expand Down
60 changes: 60 additions & 0 deletions Sources/KeyPathAppKit/Services/LegacyRuntimeUpgradeFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import KeyPathInstallationWizard

/// What happens when a user upgrading from KeyPath 1.0.1 chooses Remove Old
/// Service (#1353): remove the privileged stack, confirm the refusal cleared on
/// fresh evidence, then start the session runtime.
@MainActor
enum LegacyRuntimeUpgradeFlow {
enum Outcome: Equatable {
case removalFailed(String)
/// Removal verified, yet the refusal's own detector still sees a daemon.
case stillDetected
case started(virtualHIDNotice: String?)
case removedButNotStarted(reason: String?, virtualHIDNotice: String?)

var message: String {
switch self {
case let .removalFailed(reason):
reason
case .stillDetected:
"The old KeyPath service was removed, but KeyPath still detects it. Restart your Mac, then open KeyPath again."
case let .started(notice):
Self.join("Old KeyPath service removed. KeyPath is running.", notice)
case let .removedButNotStarted(reason, notice):
Self.join("Old KeyPath service removed. \(reason ?? "Turn KeyPath on with the switch above.")", notice)
}
}

var isFailure: Bool {
switch self {
case .removalFailed, .stillDetected: true
case .started, .removedButNotStarted: false
}
}

private static func join(_ message: String, _ notice: String?) -> String {
notice.map { "\(message) \($0)" } ?? message
}
}

static func run(
remove: () async -> InstallerEngine.LegacyRuntimeRemovalResult,
refusalStillStands: () async -> Bool,
start: () async -> Bool,
lastError: () -> String?
) async -> Outcome {
let result = await remove()
guard result.report.success else {
return .removalFailed(result.report.failureReason ?? "The old KeyPath service could not be removed.")
}
if await refusalStillStands() {
return .stillDetected
}
if await start() {
return .started(virtualHIDNotice: result.virtualHIDNotice)
}
// The refusal itself is not a reason the start failed once it has cleared.
let reason = lastError().flatMap { $0 == ServiceLifecycleCoordinator.legacyDaemonRefusalMessage ? nil : $0 }
return .removedButNotStarted(reason: reason, virtualHIDNotice: result.virtualHIDNotice)
}
}
11 changes: 8 additions & 3 deletions Sources/KeyPathAppKit/Services/MainAppStateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -477,17 +477,22 @@
switch self {
case .configuration: "Configuration cannot run in driverless mode"
case .recovery: "Keyboard recovery requires attention"
case .legacyDaemon: "Another KeyPath keyboard service is running"
case .legacyDaemon: "The KeyPath 1.0.1 keyboard service is still running"
}
}

var userAction: String {
switch self {
case .configuration: "Edit the configuration and retry. Existing rules were preserved."
case .recovery: "The recovery record was preserved. Do not clear it to force a restart."
case .legacyDaemon: "KeyPath will not stop it for you. Stop it, then start KeyPath again. Existing rules were preserved."
case .legacyDaemon: "Choose Remove Old Service in Settings → Status. KeyPath asks for your administrator password once, removes the old service, and starts again. Existing rules were preserved."

Check warning on line 488 in Sources/KeyPathAppKit/Services/MainAppStateController.swift

View workflow job for this annotation

GitHub Actions / quality-full

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

/// Only the 1.0.1 daemon has a fix, and it runs only when the user asks.
var autoFixAction: AutoFixAction? {
self == .legacyDaemon ? .removeLegacyPrivilegedRuntime : nil
}
}

private func performValidation(requiresFreshResult: Bool = false) async {
Expand Down Expand Up @@ -794,7 +799,7 @@
}
} else {
AppLogger.shared.info(
"❌ [MainAppStateController] Validation still FAILED - adapter state: \(adapted.state) (repeat #\(repeatCount(forSite: .validationFailure)), \(blockingIssues.count) blocking issues, suppressing detailed log)"

Check warning on line 802 in Sources/KeyPathAppKit/Services/MainAppStateController.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 227 characters (line_length)
)
}
}
Expand Down Expand Up @@ -827,7 +832,7 @@
// snapshot cannot make a proven unsupported configuration start running.
issues = [WizardIssue(identifier: .component(.keyPathRuntime), severity: .error,
category: .systemRequirements, title: kind.title,
description: reason, autoFixAction: nil,
description: reason, autoFixAction: kind.autoFixAction,
userAction: kind.userAction)]
validationState = .failed(blockingCount: 1, totalCount: 1)
lastValidatedSystemContext = nil
Expand Down Expand Up @@ -916,7 +921,7 @@

checks += 1
AppLogger.shared.debug(
"⏳ [MainAppStateController] Waiting for Kanata service (\(checks)) transient=\(inTransientWindow), running=\(health.isRunning), responding=\(health.isResponding), inputCaptureReady=\(health.inputCaptureReady)"

Check warning on line 924 in Sources/KeyPathAppKit/Services/MainAppStateController.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 225 characters (line_length)
)
try? await Task.sleep(for: .seconds(timing.checkInterval))
}
Expand Down
1 change: 1 addition & 0 deletions Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
HelpTopic(id: "installation", title: "Installation Guide", icon: "shippingbox", resource: "installation-wizard", group: .gettingStarted),
HelpTopic(id: "concepts", title: "Keyboard Concepts", icon: "keyboard", resource: "concepts", group: .gettingStarted),
HelpTopic(id: "use-cases", title: "What You Can Build", icon: "hammer", resource: "use-cases", group: .gettingStarted),
HelpTopic(id: "upgrading-from-1-0-1", title: "Upgrading from KeyPath 1.0.1", icon: "arrow.up.circle", resource: "upgrading-from-1-0-1", group: .gettingStarted),
// Core Features
HelpTopic(id: "home-row-mods", title: "Shortcuts Without Reaching", icon: "hand.raised", resource: "home-row-mods", group: .coreFeatures),
HelpTopic(id: "vim-navigation", title: "Navigate Like a Keyboard Ninja", icon: "arrow.up.and.down.and.arrow.left.and.right", resource: "vim-navigation", group: .coreFeatures),
Expand Down Expand Up @@ -324,7 +325,7 @@
AsyncImage(url: bannerURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)

Check warning on line 328 in Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift

View workflow job for this annotation

GitHub Actions / quality-full

Prefer `scaledToFit()` or `scaledToFill()` over `aspectRatio(contentMode:)` with a constant content mode (legacy_swiftui_aspect_ratio)
} placeholder: {
Color.clear.frame(height: 200)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ extension StatusSettingsTabView {
}

var primaryIssueDetail: StatusDetail? {
statusDetails
// The 1.0.1 refusal explains the Remove Old Service button; nothing outranks it.
if Self.offersLegacyRuntimeRemoval(issues: wizardIssues) {
return serviceStatusDetail
}
return statusDetails
.sorted { $0.level.rawValue > $1.level.rawValue }
.first(where: { $0.level.isIssue })
}
Expand Down Expand Up @@ -163,6 +167,11 @@ extension StatusSettingsTabView {
return details
}

/// The removal is offered exactly when the published refusal names the 1.0.1 service.
static func offersLegacyRuntimeRemoval(issues: [WizardIssue]) -> Bool {
issues.contains { $0.autoFixAction == .removeLegacyPrivilegedRuntime }
}

/// A stopped runtime says why when it knows, instead of only how to start it.
static func stoppedMessage(lastError: String?) -> String {
guard let lastError, !lastError.isEmpty else { return "Runtime is stopped. Use the switch above to turn it on." }
Expand Down
45 changes: 44 additions & 1 deletion Sources/KeyPathAppKit/UI/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct StatusSettingsTabView: View {
@State private var launchAtLogin = LaunchAtLoginController.shared
@State private var settingsToastManager = WizardToastManager()
@State var showingPermissionAlert = false
@State var removingLegacyRuntime = false
@State private var localServiceRunning: Bool? // Optimistic local state for instant toggle feedback

/// Persisted record that the user explicitly switched the service off (as
Expand Down Expand Up @@ -130,7 +131,23 @@ struct StatusSettingsTabView: View {
// Action button when there are problems. Suppressed when the
// user has intentionally turned the service off — nothing is
// broken, so there's nothing to fix.
if !isSystemHealthy, !isIntentionallyDisabled {
// The wizard cannot detect KeyPath 1.0.1's service, so its
// removal replaces "Fix it" while that refusal stands (#1353).
if Self.offersLegacyRuntimeRemoval(issues: wizardIssues) {
Button(action: { Task { await removeLegacyRuntimeAndStart() } }) {
Label(
removingLegacyRuntime ? "Removing Old Service…" : "Remove Old Service",
systemImage: "trash"
)
.font(.body.weight(.semibold))
}
.settingsPrimaryAction()
.controlSize(.large)
.tint(.orange)
.disabled(removingLegacyRuntime)
.accessibilityIdentifier("status-remove-legacy-service-button")
.accessibilityLabel("Remove the KeyPath 1.0.1 keyboard service")
} else if !isSystemHealthy, !isIntentionallyDisabled {
Button(action: { wizardInitialPage = .summary }) {
Label("Fix it", systemImage: "wand.and.stars")
.font(.body.weight(.semibold))
Expand Down Expand Up @@ -480,6 +497,32 @@ struct StatusSettingsTabView: View {
}
}

/// Removes the KeyPath 1.0.1 service the session runtime refuses to start
/// beside (#1353), then starts KeyPath. Runs only when the user asks.
func removeLegacyRuntimeAndStart() async {
guard !removingLegacyRuntime else { return }
removingLegacyRuntime = true
defer { removingLegacyRuntime = false }
settingsToastManager.showInfo("Removing the old KeyPath service…")

let lifecycle = kanataManager.underlyingManager.serviceLifecycleCoordinator
let outcome = await LegacyRuntimeUpgradeFlow.run(
remove: { await InstallerEngine().removeLegacyPrivilegedRuntime() },
refusalStillStands: { await lifecycle.refreshLegacyDaemonRefusalAfterCleanup() },
start: { await kanataManager.startKanata(reason: "KeyPath 1.0.1 service removed") },
lastError: { kanataManager.lastError }
)
await refreshStatus()
switch outcome {
case .removalFailed, .stillDetected:
settingsToastManager.showError(outcome.message, duration: 10)
case .started:
settingsToastManager.showSuccess(outcome.message, duration: 10)
case .removedButNotStarted:
settingsToastManager.showInfo(outcome.message, duration: 10)
}
}

private func startViaInstallerEngine() async {
await MainActor.run {
settingsToastManager.showInfo("Starting…")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public enum AutoFixActionDescriptions {
"Fix Karabiner driver version (v6 → v5)"
case .installCorrectVHIDDriver:
"Install Karabiner VirtualHID driver"
case .removeLegacyPrivilegedRuntime:
"Remove the old KeyPath keyboard service"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ public extension InstallerEngine {
"start-session-runtime"
case .synchronizeConfigPaths:
InstallerRecipeID.synchronizeConfigPaths
case .removeLegacyPrivilegedRuntime:
InstallerRecipeID.removeLegacyPrivilegedRuntime
}
}
}
80 changes: 79 additions & 1 deletion Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,17 @@ public final class InstallerEngine {
injectedValidator ?? WizardDependencies.systemValidator
}

/// Removes the KeyPath 1.0.1 privileged stack. Injected only by tests.
private let injectedLegacyCleanup: LegacyPrivilegedRuntimeCleanup?

/// Internal designated initializer to share construction logic
public init(
processLifecycleManager _: ProcessLifecycleManager,
systemValidator injectedValidator: (any WizardSystemValidating)? = nil
systemValidator injectedValidator: (any WizardSystemValidating)? = nil,
legacyRuntimeCleanup: LegacyPrivilegedRuntimeCleanup? = nil
) {
self.injectedValidator = injectedValidator
injectedLegacyCleanup = legacyRuntimeCleanup

AppLogger.shared.log("🔧 [InstallerEngine] Initialized")
}
Expand Down Expand Up @@ -786,6 +791,11 @@ public final class InstallerEngine {
using broker: PrivilegeBroker
) async -> InstallerReport {
AppLogger.shared.log("🔧 [InstallerEngine] runSingleAction(\(action), using:) starting")
// Already inside the installer transaction: `runSingleAction` wraps this
// function in `withInstallerTransaction`.
if action == .removeLegacyPrivilegedRuntime {
return await removeLegacyPrivilegedRuntimeWithinTransaction().report
}
let context = await inspectSystem()

// Determine which intent would include this action.
Expand Down Expand Up @@ -867,6 +877,74 @@ public final class InstallerEngine {
return report
}

// MARK: - KeyPath 1.0.1 migration

/// Result of removing the 1.0.1 privileged stack.
public struct LegacyRuntimeRemovalResult: Sendable {
public let report: InstallerReport
/// What remains of the Karabiner driver, for the user. Nil when nothing does.
public let virtualHIDNotice: String?
}

/// Stops and removes the KeyPath 1.0.1 kanata daemon, helper, and their files
/// with one administrator prompt (#1353). Success means a fresh read-only
/// observation found none of it left: no loaded legacy service, no root
/// KeyPath process, no legacy file. The Karabiner driver is reported, not
/// removed. This does not start the session runtime; the caller does that.
///
/// Not a recipe: privileged recipes stay unavailable (ADR-045). This only
/// tears the retired stack down.
public func removeLegacyPrivilegedRuntime() async -> LegacyRuntimeRemovalResult {
await withInstallerTransaction {
await removeLegacyPrivilegedRuntimeWithinTransaction()
}
}

private func removeLegacyPrivilegedRuntimeWithinTransaction() async -> LegacyRuntimeRemovalResult {
let runID = UUID()
let recipeID = InstallerRecipeID.removeLegacyPrivilegedRuntime
let start = Date()
let cleanup = injectedLegacyCleanup ?? .live
let outcome = await cleanup.run()
let duration = Date().timeIntervalSince(start)
// Wizard callers require final evidence they own; capture it after the work.
let finalContext = await captureFreshContext()
let completionState: InstallerCompletionState = if !outcome.success {
outcome.operationSucceeded ? .verificationFailed : .executionFailed
} else if outcome.requestedAuthorization {
.completed
} else {
.verifiedNoOp
}
let telemetry = InstallerRepairTelemetryEvent(
runID: runID, beforeSnapshotID: nil, afterSnapshotID: finalContext.snapshotID,
trigger: .singleAction, intent: InstallIntent.repair.telemetryValue,
stateMatrixRow: nil, stateMatrixPlan: [],
action: recipeID, recipeID: recipeID, recipeType: nil,
postconditionResult: outcome.success ? .succeeded : .failed,
error: outcome.failureReason
)
let report = InstallerReport(
runID: runID,
afterSnapshotID: finalContext.snapshotID,
success: outcome.success,
completionState: completionState,
failureReason: outcome.failureReason,
executedRecipes: outcome.requestedAuthorization ? [RecipeResult(
recipeID: recipeID, success: outcome.success, error: outcome.failureReason,
duration: duration, logs: outcome.logs
)] : [],
finalContext: finalContext,
logs: outcome.logs,
repairTelemetry: [telemetry]
)
AppLogger.shared.log(
"🧹 [InstallerEngine] Legacy runtime removal - success: \(outcome.success), "
+ "prompted: \(outcome.requestedAuthorization), remaining: \(outcome.after.remainingDescription)"
)
return LegacyRuntimeRemovalResult(report: report, virtualHIDNotice: outcome.virtualHIDNotice)
}

// MARK: - Unavailable compatibility APIs

public func uninstallVirtualHIDDrivers(using _: PrivilegeBroker) async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Foundation
public enum InstallerRecipeID {
public static let installRequiredRuntimeServices = "install-required-runtime-services"
public static let installCorrectVHIDDriver = "install-correct-vhid-driver"
public static let removeLegacyPrivilegedRuntime = "remove-legacy-privileged-runtime"
public static let installLogRotation = "install-log-rotation"
public static let installPrivilegedHelper = "install-privileged-helper"
public static let reinstallPrivilegedHelper = "reinstall-privileged-helper"
Expand Down
Loading
Loading