From 68c3021e854373749982754ede5da9ff30de2585 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 14:42:35 -0700 Subject: [PATCH 1/3] fix: resolve transient runtime setup spinner --- .../Core/ServiceStatusEvaluator.swift | 22 +++++ .../UI/Pages/WizardKanataServicePage.swift | 95 ++++++++++--------- .../ServiceStatusEvaluatorActionTests.swift | 51 ++++++++++ 3 files changed, 125 insertions(+), 43 deletions(-) diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift b/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift index dd993460b..f62387aa4 100644 --- a/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift +++ b/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift @@ -15,6 +15,28 @@ 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 } + + switch runtimeStatus { + case .starting: + return true + case .stopped: + return isInTransientStartupWindow + case .running, .failed, .unknown: + return 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..d66a8235f 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,62 @@ 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 - ) - } - - guard !Task.isCancelled else { return } + while !Task.isCancelled { + let runtimeStatus = await kanataManager.currentRuntimeStatus() + let isInTransientStartupWindow = await kanataManager.isInTransientRuntimeStartupWindow() - await MainActor.run { - withAnimation(.easeInOut(duration: 0.3)) { - applyStatusUpdate( - runtimeStatus: runtimeStatus, - processStatus: processStatus, - isInTransientStartupWindow: isInTransientStartupWindow + 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 } + + 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 completedAttempts >= ServiceStatusEvaluator.transientRefreshAttemptLimit, + runtimeStatus == .starting + { + 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 +617,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..0c700bd94 100644 --- a/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift @@ -3,6 +3,57 @@ 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 + ) + ) + } + + func testSettledRuntimeStatusDoesNotRetry() { + XCTAssertFalse( + ServiceStatusEvaluator.shouldRetryTransientStatus( + runtimeStatus: .running(pid: 42), + isInTransientStartupWindow: true, + completedAttempts: 1 + ) + ) + XCTAssertFalse( + ServiceStatusEvaluator.shouldRetryTransientStatus( + runtimeStatus: .failed(reason: "boom"), + isInTransientStartupWindow: true, + completedAttempts: 1 + ) + ) + } + func testSuccessfulActionUsesFreshRunningObservationOverStaleIssue() { let status = ServiceStatusEvaluator.evaluateAfterAction( operationSucceeded: true, From 04731b096111a1a428115980730f5b39187e5920 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 14:52:30 -0700 Subject: [PATCH 2/3] chore: bump hotfix to 1.0.2 build 13 --- Sources/KeyPathApp/Info.plist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 45e0d3c55fea66f57e3d0a63d1406bff39ee7321 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 14:56:20 -0700 Subject: [PATCH 3/3] fix: handle stopped startup timeout --- .../Core/ServiceStatusEvaluator.swift | 27 ++++++++++++++++--- .../UI/Pages/WizardKanataServicePage.swift | 8 +++--- .../ServiceStatusEvaluatorActionTests.swift | 14 ++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift b/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift index f62387aa4..521016b30 100644 --- a/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift +++ b/Sources/KeyPathInstallationWizard/Core/ServiceStatusEvaluator.swift @@ -26,14 +26,35 @@ public enum ServiceStatusEvaluator { 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: - return true + true case .stopped: - return isInTransientStartupWindow + isInTransientStartupWindow case .running, .failed, .unknown: - return false + false } } diff --git a/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift b/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift index d66a8235f..26007ff6c 100644 --- a/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift +++ b/Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift @@ -332,9 +332,11 @@ public struct WizardKanataServicePage: View { completedAttempts: completedAttempts ) guard shouldRetry else { - if completedAttempts >= ServiceStatusEvaluator.transientRefreshAttemptLimit, - runtimeStatus == .starting - { + if ServiceStatusEvaluator.didExhaustTransientStatus( + runtimeStatus: runtimeStatus, + isInTransientStartupWindow: isInTransientStartupWindow, + completedAttempts: completedAttempts + ) { await MainActor.run { serviceStatus = .failed( error: "Runtime startup did not finish. Click Restart to retry." diff --git a/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift b/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift index 0c700bd94..fd896a05f 100644 --- a/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/ServiceStatusEvaluatorActionTests.swift @@ -35,6 +35,13 @@ final class ServiceStatusEvaluatorActionTests: XCTestCase { completedAttempts: 1 ) ) + XCTAssertTrue( + ServiceStatusEvaluator.didExhaustTransientStatus( + runtimeStatus: .stopped, + isInTransientStartupWindow: true, + completedAttempts: ServiceStatusEvaluator.transientRefreshAttemptLimit + ) + ) } func testSettledRuntimeStatusDoesNotRetry() { @@ -52,6 +59,13 @@ final class ServiceStatusEvaluatorActionTests: XCTestCase { completedAttempts: 1 ) ) + XCTAssertFalse( + ServiceStatusEvaluator.didExhaustTransientStatus( + runtimeStatus: .running(pid: 42), + isInTransientStartupWindow: true, + completedAttempts: ServiceStatusEvaluator.transientRefreshAttemptLimit + ) + ) } func testSuccessfulActionUsesFreshRunningObservationOverStaleIssue() {