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
4 changes: 2 additions & 2 deletions Sources/KeyPathApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<key>CFBundleDisplayName</key>
<string>KeyPath</string>
<key>CFBundleVersion</key>
<string>12</string>
<string>13</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>1.0.2</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

@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<Void, Never>?

Expand Down Expand Up @@ -88,7 +87,7 @@
}

if let cta = primaryCTAConfiguration {
Button(cta.label, action: cta.action)

Check warning on line 90 in Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift

View workflow job for this annotation

GitHub Actions / code-quality

Interactive UI element (Button/Toggle/Picker) should have .accessibilityIdentifier() modifier. See ACCESSIBILITY_COVERAGE.md (require_accessibility_identifier)
.buttonStyle(WizardDesign.Component.PrimaryButton(isLoading: isPerformingAction))
.keyboardShortcut(.defaultAction)
.disabled(cta.disabled)
Expand All @@ -98,7 +97,7 @@
}

if shouldShowNextStepButton {
Button(nextStepButtonTitle) {

Check warning on line 100 in Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift

View workflow job for this annotation

GitHub Actions / code-quality

Interactive UI element (Button/Toggle/Picker) should have .accessibilityIdentifier() modifier. See ACCESSIBILITY_COVERAGE.md (require_accessibility_identifier)
navigateToNextStep()
}
.buttonStyle(WizardDesign.Component.PrimaryButton())
Expand All @@ -113,11 +112,9 @@
.background(WizardDesign.Colors.wizardBackground)
.wizardDetailPage()
.onAppear {
startAutoRefresh()
refreshStatus()
}
.onDisappear {
stopAutoRefresh()
refreshTask?.cancel()
refreshTask = nil
}
Expand Down Expand Up @@ -295,34 +292,64 @@
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 }
}
}

Expand Down Expand Up @@ -408,12 +435,12 @@
}

if let logData = readRecentLogData(from: logPath, maxBytes: 64 * 1024) {
let logString = String(decoding: logData, as: UTF8.self)

Check warning on line 438 in Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.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)
let lines = logString.components(separatedBy: .newlines)
let recentLines = lines.suffix(40) // Check last 40 lines

for line in recentLines.reversed() {
if isLikelyActionableCrashLine(line) {

Check warning on line 443 in Sources/KeyPathInstallationWizard/UI/Pages/WizardKanataServicePage.swift

View workflow job for this annotation

GitHub Actions / code-quality

`where` clauses are preferred over a single `if` inside a `for` (for_where)
return extractErrorMessage(from: line)
}
}
Expand Down Expand Up @@ -592,20 +619,4 @@
"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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading