diff --git a/Sources/KeyPathApp/Info.plist b/Sources/KeyPathApp/Info.plist
index 3c7b2321b..30dd3e5ce 100644
--- a/Sources/KeyPathApp/Info.plist
+++ b/Sources/KeyPathApp/Info.plist
@@ -11,9 +11,9 @@
CFBundleDisplayName
KeyPath
CFBundleVersion
- 12
+ 13
CFBundleShortVersionString
- 1.0.1
+ 1.0.2
CFBundlePackageType
APPL
CFBundleIconFile
diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift b/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift
index dd993460b..521016b30 100644
--- a/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift
+++ b/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift
@@ -15,6 +15,49 @@ public enum ServiceProcessStatus: Equatable {
/// Single source of truth for service status evaluation across all wizard pages
/// Pure function approach - no side effects, consistent results
public enum ServiceStatusEvaluator {
+ /// The service page rechecks only while the runtime is genuinely in a
+ /// transient startup state. The cap prevents a stale lifecycle signal from
+ /// leaving the wizard in an endless spinner.
+ public static let transientRefreshAttemptLimit = 30
+
+ public static func shouldRetryTransientStatus(
+ runtimeStatus: WizardRuntimeStatus,
+ isInTransientStartupWindow: Bool,
+ completedAttempts: Int
+ ) -> Bool {
+ guard completedAttempts < transientRefreshAttemptLimit else { return false }
+ return isTransientStartupStatus(
+ runtimeStatus: runtimeStatus,
+ isInTransientStartupWindow: isInTransientStartupWindow
+ )
+ }
+
+ public static func didExhaustTransientStatus(
+ runtimeStatus: WizardRuntimeStatus,
+ isInTransientStartupWindow: Bool,
+ completedAttempts: Int
+ ) -> Bool {
+ guard completedAttempts >= transientRefreshAttemptLimit else { return false }
+ return isTransientStartupStatus(
+ runtimeStatus: runtimeStatus,
+ isInTransientStartupWindow: isInTransientStartupWindow
+ )
+ }
+
+ private static func isTransientStartupStatus(
+ runtimeStatus: WizardRuntimeStatus,
+ isInTransientStartupWindow: Bool
+ ) -> Bool {
+ switch runtimeStatus {
+ case .starting:
+ true
+ case .stopped:
+ isInTransientStartupWindow
+ case .running, .failed, .unknown:
+ false
+ }
+ }
+
/// Evaluates a fresh runtime observation after an explicit lifecycle action.
///
/// A successful lifecycle operation has already verified runtime readiness, so
diff --git a/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift b/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift
index f80d361c7..26007ff6c 100644
--- a/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift
+++ b/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift
@@ -8,7 +8,6 @@ public struct WizardKanataServicePage: View {
@State private var isPerformingAction = false
@State private var serviceStatus: ServiceStatus = .unknown
- @State private var refreshTimer: Timer?
@State private var actionStatus: WizardDesign.ActionStatus = .idle
@State private var refreshTask: Task?
@@ -113,11 +112,9 @@ public struct WizardKanataServicePage: View {
.background(WizardDesign.Colors.wizardBackground)
.wizardDetailPage()
.onAppear {
- startAutoRefresh()
refreshStatus()
}
.onDisappear {
- stopAutoRefresh()
refreshTask?.cancel()
refreshTask = nil
}
@@ -295,34 +292,64 @@ public struct WizardKanataServicePage: View {
AppLogger.shared.log("⚠️ [WizardKanataServicePage] kanataManager not configured — skipping status refresh")
return
}
- let runtimeStatus = await kanataManager.currentRuntimeStatus()
- let isInTransientStartupWindow = await kanataManager.isInTransientRuntimeStartupWindow()
+ var completedAttempts = 0
- let processStatus = if let actionSucceeded {
- ServiceStatusEvaluator.evaluateAfterAction(
- operationSucceeded: actionSucceeded,
- kanataIsRunning: runtimeStatus.isRunning,
- systemState: systemState,
- issues: issues
- )
- } else {
- ServiceStatusEvaluator.evaluate(
- kanataIsRunning: runtimeStatus.isRunning,
- systemState: systemState,
- issues: issues
- )
- }
+ while !Task.isCancelled {
+ let runtimeStatus = await kanataManager.currentRuntimeStatus()
+ let isInTransientStartupWindow = await kanataManager.isInTransientRuntimeStartupWindow()
+
+ let processStatus = if let actionSucceeded {
+ ServiceStatusEvaluator.evaluateAfterAction(
+ operationSucceeded: actionSucceeded,
+ kanataIsRunning: runtimeStatus.isRunning,
+ systemState: systemState,
+ issues: issues
+ )
+ } else {
+ ServiceStatusEvaluator.evaluate(
+ kanataIsRunning: runtimeStatus.isRunning,
+ systemState: systemState,
+ issues: issues
+ )
+ }
- guard !Task.isCancelled else { return }
+ guard !Task.isCancelled else { return }
- await MainActor.run {
- withAnimation(.easeInOut(duration: 0.3)) {
- applyStatusUpdate(
+ await MainActor.run {
+ withAnimation(.easeInOut(duration: 0.3)) {
+ applyStatusUpdate(
+ runtimeStatus: runtimeStatus,
+ processStatus: processStatus,
+ isInTransientStartupWindow: isInTransientStartupWindow
+ )
+ }
+ }
+
+ completedAttempts += 1
+ let shouldRetry = ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: runtimeStatus,
+ isInTransientStartupWindow: isInTransientStartupWindow,
+ completedAttempts: completedAttempts
+ )
+ guard shouldRetry else {
+ if ServiceStatusEvaluator.didExhaustTransientStatus(
runtimeStatus: runtimeStatus,
- processStatus: processStatus,
- isInTransientStartupWindow: isInTransientStartupWindow
- )
+ isInTransientStartupWindow: isInTransientStartupWindow,
+ completedAttempts: completedAttempts
+ ) {
+ await MainActor.run {
+ serviceStatus = .failed(
+ error: "Runtime startup did not finish. Click Restart to retry."
+ )
+ }
+ }
+ return
}
+
+ AppLogger.shared.log(
+ "⏳ [WizardKanataServicePage] Runtime is still starting; scheduling bounded status retry \(completedAttempts)/\(ServiceStatusEvaluator.transientRefreshAttemptLimit)"
+ )
+ guard await WizardSleep.seconds(1) else { return }
}
}
@@ -592,20 +619,4 @@ public struct WizardKanataServicePage: View {
"Check log file for details"
}
}
-
- // MARK: - Auto Refresh
-
- private func startAutoRefresh() {
- // DISABLED: This timer calls refreshStatus() which may trigger invasive permission checks
- // that cause KeyPath to auto-add to Input Monitoring system preferences
-
- AppLogger.shared.log(
- "🔄 [WizardKanataServicePage] Auto-refresh timer DISABLED to prevent invasive permission checks"
- )
- }
-
- private func stopAutoRefresh() {
- refreshTimer?.invalidate()
- refreshTimer = nil
- }
}
diff --git a/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift b/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift
index 56a402a60..fd896a05f 100644
--- a/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift
+++ b/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift
@@ -3,6 +3,71 @@ import KeyPathWizardCore
import XCTest
final class ServiceStatusEvaluatorActionTests: XCTestCase {
+ func testTransientStartingStatusRetriesUntilAttemptLimit() {
+ XCTAssertTrue(
+ ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: .starting,
+ isInTransientStartupWindow: false,
+ completedAttempts: 1
+ )
+ )
+ XCTAssertFalse(
+ ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: .starting,
+ isInTransientStartupWindow: false,
+ completedAttempts: ServiceStatusEvaluator.transientRefreshAttemptLimit
+ )
+ )
+ }
+
+ func testStoppedStatusRetriesOnlyInsideTransientStartupWindow() {
+ XCTAssertTrue(
+ ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: .stopped,
+ isInTransientStartupWindow: true,
+ completedAttempts: 1
+ )
+ )
+ XCTAssertFalse(
+ ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: .stopped,
+ isInTransientStartupWindow: false,
+ completedAttempts: 1
+ )
+ )
+ XCTAssertTrue(
+ ServiceStatusEvaluator.didExhaustTransientStatus(
+ runtimeStatus: .stopped,
+ isInTransientStartupWindow: true,
+ completedAttempts: ServiceStatusEvaluator.transientRefreshAttemptLimit
+ )
+ )
+ }
+
+ func testSettledRuntimeStatusDoesNotRetry() {
+ XCTAssertFalse(
+ ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: .running(pid: 42),
+ isInTransientStartupWindow: true,
+ completedAttempts: 1
+ )
+ )
+ XCTAssertFalse(
+ ServiceStatusEvaluator.shouldRetryTransientStatus(
+ runtimeStatus: .failed(reason: "boom"),
+ isInTransientStartupWindow: true,
+ completedAttempts: 1
+ )
+ )
+ XCTAssertFalse(
+ ServiceStatusEvaluator.didExhaustTransientStatus(
+ runtimeStatus: .running(pid: 42),
+ isInTransientStartupWindow: true,
+ completedAttempts: ServiceStatusEvaluator.transientRefreshAttemptLimit
+ )
+ )
+ }
+
func testSuccessfulActionUsesFreshRunningObservationOverStaleIssue() {
let status = ServiceStatusEvaluator.evaluateAfterAction(
operationSucceeded: true,