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
Original file line number Diff line number Diff line change
Expand Up @@ -51,26 +51,62 @@
do {
let (application, url, nonce) = try await launchSessionProcess(capabilitiesOnly: true)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
for _ in 0 ..< 40 {
if let report = Self.readSessionReport(url),
report.state == .capabilities,
report.isCurrent(nonce: nonce, pid: application.processIdentifier, uid: getuid(), now: Date())
{
return Self.permissionSet(report)
let report = try await Self.awaitCapabilityReport(
read: {
guard let report = Self.readSessionReport(url), report.state == .capabilities,
report.isCurrent(nonce: nonce, pid: application.processIdentifier, uid: getuid(), now: Date())
else { return nil }
return report
},
sleep: { try await Task.sleep(for: .milliseconds(50)) },
abandon: {
// NSWorkspace may return an application without a valid PID (e.g. -1).
// Never let that become a process-group/broadcast signal target.
// Signal only the independently launched process owned by this call.
let probePID = application.processIdentifier
if probePID > 0, !application.isTerminated { kill(probePID, SIGTERM) }
}
try await Task.sleep(for: .milliseconds(50))
}
// NSWorkspace may return an application without a valid PID (e.g. -1).
// Never let that become a process-group/broadcast signal target.
// Signal only the independently launched process owned by this call.
let probePID = application.processIdentifier
if probePID > 0, !application.isTerminated { kill(probePID, SIGTERM) }
)
if let report { return Self.permissionSet(report) }
} catch {
AppLogger.shared.warn("Session permission report unavailable")
}
return nil
}

/// The worker's argv. Uninstall's sweep recognizes workers by these flags,
/// so a test pins them to SessionWorkerProcess.parse.
nonisolated static func sessionLaunchArguments(
capabilitiesOnly: Bool, reportPath: String, nonce: String, ownerPID: Int32, port: Int, configPath: String
) -> [String] {
[
capabilitiesOnly ? "--session-capabilities" : "--session-runtime",
"--session-report", reportPath, "--session-nonce", nonce,
"--session-owner", String(ownerPID), "--session-port", String(port),
"--session-config", configPath
]
}

/// Polls about two seconds for the probe's report. A timeout, a cancelled
/// wait, or a thrown error all abandon the probe, and every one of them
/// signals it: on 2026-11-03 a cancelled wait skipped the signal and two
/// probes outlived their app.
static func awaitCapabilityReport(
attempts: Int = 40, read: () -> SessionRuntimeReport?,
sleep: () async throws -> Void, abandon: () -> Void
) async throws -> SessionRuntimeReport? {
var reported = false
defer { if !reported { abandon() } }
for _ in 0 ..< attempts {
if let report = read() {
reported = true
return report
}
try await sleep()
}
return nil
}

private func isLegacyDaemonRunning() async -> Bool {
#if DEBUG
if let probe = testSessionLegacyDaemonRunning { return await probe() }
Expand Down Expand Up @@ -507,7 +543,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 546 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 @@ -617,7 +653,7 @@
continue
}
#endif
do { try await Task.sleep(for: .milliseconds(250)) }

Check warning on line 656 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 @@ -661,7 +697,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 700 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 @@ -688,7 +724,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 727 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 @@ -789,12 +825,10 @@
configuration.activates = false
configuration.hides = true
configuration.addsToRecentItems = false
configuration.arguments = [
capabilitiesOnly ? "--session-capabilities" : "--session-runtime",
"--session-report", url.path, "--session-nonce", nonce,
"--session-owner", String(getpid()), "--session-port", String(KeyPathConstants.Networking.defaultTCPPort),
"--session-config", KeyPathConstants.Config.mainConfigPath
]
configuration.arguments = Self.sessionLaunchArguments(
capabilitiesOnly: capabilitiesOnly, reportPath: url.path, nonce: nonce, ownerPID: getpid(),
port: KeyPathConstants.Networking.defaultTCPPort, configPath: KeyPathConstants.Config.mainConfigPath
)
sessionCapsIntermediate = nil
if !capabilitiesOnly {
sessionCapsPaused = nil
Expand All @@ -809,7 +843,7 @@
} else if SessionCapsRuntimeSupport.selectedKeyboardOnly(),
let selection = try await chooseCapsSelection()
{
passed = try String(decoding: JSONEncoder().encode(selection), as: UTF8.self)

Check warning on line 846 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 @@ -833,7 +867,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 870 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
38 changes: 38 additions & 0 deletions Sources/KeyPathAppKit/Managers/SessionUninstallCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import AppKit
import KeyPathWizardCore

/// What a sweep of this user's KeyPath session workers proved.
enum SessionWorkerSweepResult: Equatable, Sendable {
case noneRunning
case stillRunning
/// The process table could not be read, so absence is unproven.
case unverified
}

/// Removes only the current user's driverless installation. No privileged cleanup.
@MainActor
final class SessionUninstallCoordinator: WizardUninstalling {
Expand All @@ -12,6 +20,7 @@
private let resume: () async -> Void
private let trash: (URL) throws -> Void
private let removeLoginItem: () async throws -> Void
private let stopSessionWorkers: () async -> SessionWorkerSweepResult
private let files = FileManager.default

init(
Expand All @@ -22,6 +31,7 @@
prepare: @escaping () async -> Bool,
resume: @escaping () async -> Void,
removeLoginItem: @escaping () async throws -> Void = {},
stopSessionWorkers: @escaping () async -> SessionWorkerSweepResult = { .noneRunning },
trash: @escaping (URL) throws -> Void = { try FileManager.default.trashItem(at: $0, resultingItemURL: nil) }
) {
self.home = home
Expand All @@ -32,6 +42,7 @@
self.resume = resume
self.trash = trash
self.removeLoginItem = removeLoginItem
self.stopSessionWorkers = stopSessionWorkers
}

func performUninstall(deleteConfig: Bool, removeVirtualHID: Bool, allowAdminFallback: Bool) async -> WizardUninstallResult {
Expand Down Expand Up @@ -60,6 +71,19 @@
await resume()
return WizardUninstallResult(success: false, failureReason: "Keyboard cleanup could not be verified. Nothing was removed. Try again after releasing all keys.")
}
// prepare stops only the worker the live app tracks. Orphans of an
// earlier app process, or probes it abandoned, would otherwise keep
// running from the trashed bundle.
switch await stopSessionWorkers() {
case .noneRunning:
break
case .stillRunning:
await resume()
return WizardUninstallResult(success: false, failureReason: "A KeyPath keyboard process did not stop. Nothing was removed. Restart your Mac, then try again.")
case .unverified:
await resume()
return WizardUninstallResult(success: false, failureReason: "KeyPath could not check which of its processes are running. Nothing was removed. Try again.")
}

let backup = home.appendingPathComponent("Downloads/KeyPath-Uninstall-Backup-\(UUID().uuidString)", isDirectory: true)
do {
Expand All @@ -75,7 +99,7 @@
let preferences = defaults.persistentDomain(forName: domain) ?? [:]
let data = try PropertyListSerialization.data(fromPropertyList: preferences, format: .xml, options: 0)
try data.write(to: backup.appendingPathComponent("preferences.plist"), options: .atomic)
let receipt = "KeyPath uninstall backup\nPreferences domain: \(domain)\nPaths:\n\(paths.joined(separator: "\n"))\nExternal symlink targets are preserved. macOS permission grants are unchanged.\n"

Check warning on line 102 in Sources/KeyPathAppKit/Managers/SessionUninstallCoordinator.swift

View workflow job for this annotation

GitHub Actions / quality-full

Line should be 200 characters or less; currently it has 207 characters (line_length)
try receipt.write(to: backup.appendingPathComponent("README.txt"), atomically: true, encoding: .utf8)
} catch {
await resume()
Expand All @@ -100,6 +124,20 @@
guard !files.fileExists(atPath: app.path) else {
throw CocoaError(.fileWriteUnknown)
}
// Success means nothing is left running, so check again at the end.
// No resume() here, unlike the paths above: the app and its settings
// are gone, so there is nothing to resume watching or starting.
let finalSweep = await stopSessionWorkers()
guard finalSweep == .noneRunning else {
let state = finalSweep == .stillRunning
? "a KeyPath keyboard process is still running. Restart your Mac to stop it."
: "KeyPath could not check that its processes stopped. Restart your Mac to be sure."
return WizardUninstallResult(
success: false,
failureReason: "KeyPath was removed, but \(state) Your backup is at \(backup.path).",
logs: ["Backup saved to \(backup.path)"]
)
}
return WizardUninstallResult(success: true, logs: ["Backup saved to \(backup.path)"])
} catch {
await resume()
Expand Down
100 changes: 100 additions & 0 deletions Sources/KeyPathAppKit/Services/SessionRuntime/SessionOwnerWatch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Foundation
import KeyPathCore

/// Retires a worker whose owning app is gone. It polls on a background queue,
/// so a worker wedged before its main-loop tick exists still exits: on
/// 2026-11-03 two capability probes outlived their owner by hours this way.
final class SessionOwnerWatch: @unchecked Sendable {
enum Verdict: Equatable, Sendable {
case ownerExited
case deadlineExceeded
}

/// Owner death wins over the deadline; a worker without an owner PID is
/// bounded only by its deadline.
nonisolated static func verdict(ownerPID: Int32, ownerAlive: Bool, elapsed: TimeInterval,
deadline: TimeInterval?) -> Verdict?
{
if ownerPID > 0, !ownerAlive { return .ownerExited }
if let deadline, elapsed >= deadline { return .deadlineExceeded }
return nil
}

private let lock = NSLock()
private var handler: @Sendable (Verdict) -> Void
private let timer: DispatchSourceTimer

/// The resumed timer retains itself, and its handler retains the watch,
/// until the first verdict cancels it.
init(
ownerPID: Int32, deadline: TimeInterval?, interval: TimeInterval = 0.25,
isAlive: @escaping @Sendable (Int32) -> Bool = { SystemStateProvider.isProcessAlive(pid: $0) },
uptime: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
queue: DispatchQueue = DispatchQueue(label: "com.keypath.session-owner-watch", qos: .utility),
handler: @escaping @Sendable (Verdict) -> Void
) {
self.handler = handler
timer = DispatchSource.makeTimerSource(queue: queue)
let started = uptime()
// The first check runs immediately: the owner may already be gone.
timer.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(Int(interval * 250)))
timer.setEventHandler { [self] in
guard let verdict = Self.verdict(ownerPID: ownerPID, ownerAlive: isAlive(ownerPID),
elapsed: uptime() - started, deadline: deadline)
else { return }
timer.cancel()
// Deliver under the lock: once setHandler returns, a verdict
// already in flight has either run the old handler (and exited)
// or will run the new one, never the old one late. Both handlers
// return at once, so holding the lock cannot stall setHandler.
lock.lock()
self.handler(verdict)
lock.unlock()
}
timer.resume()
}

/// The runtime hands off from a hard exit to an orderly shutdown once it
/// may own outputs or a Caps mapping. Blocks while a verdict is being
/// delivered, so the old handler cannot run after this returns.
func setHandler(_ handler: @escaping @Sendable (Verdict) -> Void) {
lock.lock()
self.handler = handler
lock.unlock()
}
}

/// Bounds an owner-exit retirement that the worker's run loop never runs. An
/// orderly shutdown cancels it when it begins, so a slow Caps restore or key
/// release is never cut off; the HID join it waits on stays unbounded.
final class SessionShutdownFallback: @unchecked Sendable {
static let shared = SessionShutdownFallback()

private let lock = NSLock()
private var shutdownBegan = false
private var pending: DispatchWorkItem?

/// Arms `action` after `grace`, once. Returns whether an owner-exit
/// retirement should proceed: false when an orderly shutdown already began,
/// true otherwise, including when an earlier call already armed it.
@discardableResult
func arm(after grace: TimeInterval, queue: DispatchQueue = .global(qos: .utility),
action: @escaping @Sendable () -> Void) -> Bool
{
lock.lock()
defer { lock.unlock() }
guard !shutdownBegan, pending == nil else { return !shutdownBegan }
let item = DispatchWorkItem(block: action)
pending = item
queue.asyncAfter(deadline: .now() + grace, execute: item)
return true
}

func shutdownBegins() {
lock.lock()
shutdownBegan = true
pending?.cancel()
pending = nil
lock.unlock()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,21 @@ public final class SessionRuntimeWorker {
let path = argument("--session-report"),
let nonce = argument("--session-nonce"), UUID(uuidString: nonce) != nil
else { exit(64) }
let ownerPID = argument("--session-owner").flatMap(Int32.init) ?? 0
let probeOnly = arguments.contains("--session-capabilities")
// Until the runtime can own outputs, nothing needs releasing: a dead
// owner, or a probe still running after its parent stopped waiting,
// ends the process at once. _exit cannot block on a wedged main thread.
// A runtime's tick already checks the owner every 5 ms; this backstop
// for a wedged run loop polls slowly, since the 30 s grace dominates.
let ownerWatch = SessionOwnerWatch(ownerPID: ownerPID,
deadline: probeOnly ? capabilityProbeDeadline : nil,
interval: probeOnly ? 0.25 : 2) { _ in _exit(0) }

if arguments.contains("--session-capabilities") {
if probeOnly {
await writeCapabilityReport(
reportURL: URL(fileURLWithPath: path), nonce: nonce,
ownerPID: argument("--session-owner").flatMap(Int32.init) ?? 0,
ownerPID: ownerPID,
port: argument("--session-port").flatMap(UInt16.init) ?? 0,
snapshotProvider: {
await SystemStateProvider.shared.currentProcessSessionCapabilitySnapshot()
Expand All @@ -118,7 +128,7 @@ public final class SessionRuntimeWorker {
let capabilities = await SystemStateProvider.shared.currentProcessPermissionCapabilities()
let worker = SessionRuntimeWorker(
reportURL: URL(fileURLWithPath: path), nonce: nonce,
ownerPID: argument("--session-owner").flatMap(Int32.init) ?? 0,
ownerPID: ownerPID,
port: argument("--session-port").flatMap(UInt16.init) ?? 0,
capabilities: capabilities
)
Expand All @@ -131,12 +141,35 @@ public final class SessionRuntimeWorker {
worker.experimentalRawCapabilities = SystemStateProvider.shared.currentProcessRawPermissionCapabilities()
worker.experimentalRawPosting = await SystemStateProvider.shared.currentProcessRawEventPostingStatus()
#endif
// From here the worker may hold keys or a Caps mapping, so a dead owner
// retires it on its run loop, which releases them before exiting. A
// run loop that never gets there would keep an orphan forever; after
// the grace it exits anyway, and the Caps journal survives for recovery.
// A shutdown already under way, or one this starts, cancels that exit.
ownerWatch.setHandler { [weak worker] _ in
guard SessionShutdownFallback.shared.arm(after: ownerExitShutdownGrace, action: { _exit(1) }) else { return }
CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) {
MainActor.assumeIsolated { worker?.retire(reason: "owner-exited") }
}
CFRunLoopWakeUp(CFRunLoopGetMain())
}
worker.start(configPath: config)
// Keep the worker and loaded bridge alive through process termination;
// dropping a runtime cannot stop its detached TCP listener threads.
worker.runLoop()
}

/// The parent waits about two seconds for a capability report and then
/// abandons the probe; this bounds a probe the parent could not signal.
nonisolated static let capabilityProbeDeadline: TimeInterval = 15
/// How long an orphaned runtime gets to release outputs on its run loop.
nonisolated static let ownerExitShutdownGrace: TimeInterval = 30

private func retire(reason: String) {
guard !finished else { return }
finish(.stopped, reason: reason)
}

/// Capability mode samples passive APIs and writes evidence only. In particular,
/// this path never enters start(), creates an event tap, or loads configuration.
static func writeCapabilityReport(
Expand Down Expand Up @@ -1154,6 +1187,8 @@ public final class SessionRuntimeWorker {
acknowledge: (() -> Void)? = nil
) -> Never {
finished = true
// The orderly path owns shutdown from here; no hard exit may cut it off.
SessionShutdownFallback.shared.shutdownBegins()
// Every exit records why, so a stop with no failure is still explainable.
AppLogger.shared.log("🛑 [SessionWorker] Finishing: state=\(state) reason=\(reason ?? "none")")
AppLogger.shared.flushBuffer()
Expand Down
Loading
Loading