Skip to content
Open
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
84 changes: 53 additions & 31 deletions Sources/KeyPathAppKit/Managers/HelperMaintenance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public final class HelperMaintenance {
log("🔐 Helper install finished")
}

let copies = detectDuplicateAppCopies()
let copies = await detectDuplicateAppCopies()
if copies.filter({ !$0.hasPrefix("/Applications/KeyPath.app") }).count > 0 {
log("⚠️ Multiple KeyPath.app copies detected:")
for c in copies {
Expand Down Expand Up @@ -115,7 +115,7 @@ public final class HelperMaintenance {
}

// Step 0: Duplicate app detection
let copies = detectDuplicateAppCopies()
let copies = await detectDuplicateAppCopies()
if copies.filter({ !$0.hasPrefix("/Applications/KeyPath.app") }).count > 0 {
log("⚠️ Multiple KeyPath.app copies detected:")
for c in copies {
Expand Down Expand Up @@ -205,47 +205,39 @@ public final class HelperMaintenance {
return healthy
}

// Find all KeyPath.app copies visible to Spotlight (fast, robust in practice).
// Find all KeyPath.app copies visible to Spotlight.
// Results are sorted with `/Applications/KeyPath.app` first if present.
// Excludes build directories (dist/, .build/, build/) to avoid flagging build artifacts.
// Excludes build and Sparkle staging directories to avoid flagging artifacts.
//
// Async and time-boxed on purpose: Spotlight can stall for minutes (for example
// while it wakes a sleeping external volume that holds an indexed KeyPath.app),
// and this used to run `mdfind` with an unbounded `waitUntilExit()` on the main
// actor, freezing Settings and the wizard behind a spinner. A slow or failed
// scan now falls back to the canonical install locations instead.
#if DEBUG
nonisolated(unsafe) static var testDuplicateAppPathsOverride: (() -> [String]?)?
nonisolated(unsafe) static var testDuplicateScanRunner: (any SubprocessRunning)?
#endif
public nonisolated func detectDuplicateAppCopies() -> [String] {
var paths: [String] = []
let process = Process()
process.launchPath = "/usr/bin/mdfind"
process.arguments = ["kMDItemFSName == 'KeyPath.app'c"]
let out = Pipe()
process.standardOutput = out
process.standardError = Pipe()
do { try process.run() } catch {
return canonicalAppCandidates()
}
nonisolated static let duplicateScanTimeout: TimeInterval = 2
nonisolated static let duplicateScanExcludedPathFragments = [
"/dist/", "/.build/", "/build/", "/DerivedData/", "/Sparkle_generate_appcast/"
]

public nonisolated func detectDuplicateAppCopies() async -> [String] {
var paths: [String]
#if DEBUG
if let override = Self.testDuplicateAppPathsOverride?() {
paths = override
} else {
process.waitUntilExit()
let s = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
paths = s.split(separator: "\n").map(String.init)
if paths.isEmpty {
paths = canonicalAppCandidates()
}
paths = await spotlightAppCopies()
}
#else
process.waitUntilExit()
let s = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
paths = s.split(separator: "\n").map(String.init)
if paths.isEmpty {
paths = canonicalAppCandidates()
}
paths = await spotlightAppCopies()
#endif

// Filter out build directories to avoid flagging build artifacts as duplicates
let buildDirPatterns = ["/dist/", "/.build/", "/build/", "/DerivedData/"]
// Filter out build/staging directories to avoid flagging artifacts as duplicates
paths = paths.filter { path in
!buildDirPatterns.contains { pattern in path.contains(pattern) }
!Self.duplicateScanExcludedPathFragments.contains { path.contains($0) }
}

paths = Array(Set(paths)) // unique
Expand All @@ -261,6 +253,34 @@ public final class HelperMaintenance {
return paths
}

private nonisolated func spotlightAppCopies() async -> [String] {
var runner: any SubprocessRunning = SubprocessRunner.shared
#if DEBUG
if let override = Self.testDuplicateScanRunner {
runner = override
}
#endif
do {
let result = try await runner.run(
"/usr/bin/mdfind",
args: ["kMDItemFSName == 'KeyPath.app'c"],
timeout: Self.duplicateScanTimeout
)
let paths = result.stdout.split(separator: "\n").map(String.init)
if result.exitCode == 0, !paths.isEmpty {
return paths
}
AppLogger.shared.log(
"⚠️ [HelperMaintenance] Duplicate-app Spotlight scan exited \(result.exitCode) with \(paths.count) result(s); using canonical locations"
)
} catch {
AppLogger.shared.log(
"⚠️ [HelperMaintenance] Duplicate-app Spotlight scan failed; using canonical locations: \(error)"
)
}
return canonicalAppCandidates()
}

// MARK: - Private steps

private func unregisterHelperIfPresent() async {
Expand Down Expand Up @@ -427,7 +447,9 @@ public final class HelperMaintenance {
for p in defaults where Foundation.FileManager().fileExists(atPath: p) {
candidates.append(p)
}
return candidates.isEmpty ? defaults : candidates
// Only locations that exist: returning the hardcoded defaults when none do
// reported phantom "multiple copies" whenever the Spotlight scan fell back.
return candidates
}

private nonisolated func legacyHelperArtifactPaths() -> (legacyBin: String, legacyPlist: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
}

HStack(spacing: 10) {
Button(role: .destructive) {

Check warning on line 119 in Sources/KeyPathAppKit/UI/Settings/AdvancedSettingsTabView.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)
showingHelperUninstallConfirm = true
} label: {
Label("Uninstall Helper", systemImage: "trash")
Expand Down Expand Up @@ -183,7 +183,7 @@
}
.task {
await refreshHelperStatus()
duplicateAppCopies = HelperMaintenance.shared.detectDuplicateAppCopies()
duplicateAppCopies = await HelperMaintenance.shared.detectDuplicateAppCopies()
backups = kanataManager.underlyingManager.configBackupManager.getAvailableBackups()
}
.alert("Uninstall Privileged Helper?", isPresented: $showingHelperUninstallConfirm) {
Expand Down Expand Up @@ -265,9 +265,9 @@
}
}

Button(action: {

Check warning on line 268 in Sources/KeyPathAppKit/UI/Settings/AdvancedSettingsTabView.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)
showingRemoveDuplicatesConfirm = true
}) {

Check warning on line 270 in Sources/KeyPathAppKit/UI/Settings/AdvancedSettingsTabView.swift

View workflow job for this annotation

GitHub Actions / code-quality

Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
Label(
removeDuplicatesInProgress ? "Removing\u{2026}" : "Remove Extra Copies", systemImage: "trash"
)
Expand Down Expand Up @@ -312,7 +312,7 @@

Spacer()

Button("Restore") {

Check warning on line 315 in Sources/KeyPathAppKit/UI/Settings/AdvancedSettingsTabView.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)
backupToRestore = backup
showingRestoreConfirm = true
}
Expand Down Expand Up @@ -410,7 +410,7 @@
}
}

let refreshed = HelperMaintenance.shared.detectDuplicateAppCopies()
let refreshed = await HelperMaintenance.shared.detectDuplicateAppCopies()
await MainActor.run {
duplicateAppCopies = refreshed
if removed > 0 {
Expand Down
23 changes: 22 additions & 1 deletion Sources/KeyPathAppKit/UI/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
@State var wizardIssues: [WizardIssue] = []
@State var tcpConfigured: Bool?
@State var duplicateAppCopies: [String] = []
@State private var lastDuplicateScan: Date?
@State private var settingsToastManager = WizardToastManager()
@State var showingPermissionAlert = false
@State private var localServiceRunning: Bool? // Optimistic local state for instant toggle feedback
Expand Down Expand Up @@ -92,7 +93,7 @@
.fill(systemHealthTint.opacity(0.15))
.frame(width: 80, height: 80)

Button(action: { wizardInitialPage = .summary }) {

Check warning on line 96 in Sources/KeyPathAppKit/UI/Settings/SettingsView.swift

View workflow job for this annotation

GitHub Actions / code-quality

Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
Image(systemName: systemHealthIcon)
.font(.largeTitle)
.foregroundColor(systemHealthTint)
Expand All @@ -118,7 +119,7 @@

Button(action: {
NotificationCenter.default.post(name: .openSettingsRules, object: nil)
}) {

Check warning on line 122 in Sources/KeyPathAppKit/UI/Settings/SettingsView.swift

View workflow job for this annotation

GitHub Actions / code-quality

Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
Text(activeRulesText(count: activeRulesCount))
.font(.body)
.foregroundColor(.secondary)
Expand All @@ -136,7 +137,7 @@
if !isSystemHealthy, !isIntentionallyDisabled {
if isOnlyKanataUnverified {
// Only issue is unverified kanata — lead with FDA
Button(action: { SystemDiagnostics.open(.fullDiskAccess) }) {

Check warning on line 140 in Sources/KeyPathAppKit/UI/Settings/SettingsView.swift

View workflow job for this annotation

GitHub Actions / code-quality

Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
Label("Enable Enhanced Diagnostics", systemImage: "checkmark.shield")
.font(.body.weight(.semibold))
}
Expand All @@ -145,7 +146,7 @@
.tint(.blue)
.accessibilityIdentifier("status-enable-fda-button")
} else {
Button(action: { wizardInitialPage = .summary }) {

Check warning on line 149 in Sources/KeyPathAppKit/UI/Settings/SettingsView.swift

View workflow job for this annotation

GitHub Actions / code-quality

Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
Label("Fix it", systemImage: "wand.and.stars")
.font(.body.weight(.semibold))
}
Expand Down Expand Up @@ -299,7 +300,7 @@
.sheet(item: $wizardInitialPage, onDismiss: {
PermissionRequestService.shared.leaveWizardContext()
Task { await refreshStatus() }
}) { page in

Check warning on line 303 in Sources/KeyPathAppKit/UI/Settings/SettingsView.swift

View workflow job for this annotation

GitHub Actions / code-quality

Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
InstallationWizardView(initialPage: page)
.customizeSheetWindow()
.environment(kanataManager)
Expand Down Expand Up @@ -493,7 +494,7 @@
let controller = MainAppStateController.shared

let context = controller.lastValidatedSystemContext ?? .empty
let duplicates = HelperMaintenance.shared.detectDuplicateAppCopies()
let duplicates = duplicateAppCopies

permissionSnapshot = context.permissions
systemContext = context
Expand All @@ -504,6 +505,26 @@
|| duplicates.count > 1
|| (context.services.kanataRunning && controller.lastTCPConfigured == false)
duplicateAppCopies = duplicates
refreshDuplicateAppCopies()
}

/// Spotlight can stall, so the duplicate-app scan never runs inline on the main
/// actor: publish status immediately, then fold the scan result in when it lands.
/// Until the first scan lands (at most `duplicateScanTimeout`), a genuine duplicate
/// install is not yet reflected in the setup banner; that brief warm-up is intended.
/// Validation publishes roughly every minute, so rescans are throttled to match.
private func refreshDuplicateAppCopies() {
if let last = lastDuplicateScan, Date().timeIntervalSince(last) < 60 { return }
lastDuplicateScan = Date()
Task { @MainActor in
let duplicates = await HelperMaintenance.shared.detectDuplicateAppCopies()
guard duplicates != duplicateAppCopies else { return }
duplicateAppCopies = duplicates
let context = systemContext ?? .empty
showSetupBanner = !(context.permissions.isSystemReady && context.services.isHealthy)
|| duplicates.count > 1
|| (context.services.kanataRunning && tcpConfigured == false)
}
}

private func startViaInstallerEngine() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,13 @@ public struct WizardSystemStatusOverview: View {
}
}
.onAppear {
duplicateCopies = WizardDependencies.helperMaintenance?.detectDuplicateAppCopies() ?? []
updateNavSequence()
}
.task {
// .task, not a Task in .onAppear: dismissing the page cancels the scan
// (and SubprocessRunner kills its mdfind) instead of leaving it running.
duplicateCopies = await WizardDependencies.helperMaintenance?.detectDuplicateAppCopies() ?? []
}
.onChange(of: showAllItems) { _, _ in updateNavSequence() }
.onChange(of: issues.count) { _, _ in updateNavSequence() }
.onChange(of: systemState) { _, _ in updateNavSequence() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,13 @@ public struct WizardHelperPage: View {
isLoadingHelperStatus = false
}
.onAppear {
duplicateCopies = WizardDependencies.helperMaintenance?.detectDuplicateAppCopies() ?? []
logLoginItemsDiagnostics()
}
.task {
// .task, not a Task in .onAppear: dismissing the page cancels the scan
// (and SubprocessRunner kills its mdfind) instead of leaving it running.
duplicateCopies = await WizardDependencies.helperMaintenance?.detectDuplicateAppCopies() ?? []
}
.onDisappear {
stopApprovalPolling()
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/KeyPathWizardCore/WizardServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

@MainActor
public protocol WizardHelperMaintaining: AnyObject, Sendable {
func detectDuplicateAppCopies() -> [String]
func detectDuplicateAppCopies() async -> [String]
func installOrRefresh() async -> Bool
func runCleanupAndRepair(useAppleScriptFallback: Bool) async -> Bool
func runCleanupAndRepair(useAppleScriptFallback: Bool, forceFullRepair: Bool) async -> Bool
Expand Down Expand Up @@ -129,7 +129,7 @@
/// Check if the current app is running ad-hoc signed (not notarized)
public static func isRunningAdHoc() -> Bool {
guard let bundlePath = Bundle.main.bundlePath as String? else { return false }
let task = Process()

Check warning on line 132 in Sources/KeyPathWizardCore/WizardServiceProtocols.swift

View workflow job for this annotation

GitHub Actions / code-quality

Direct Process() usage detected. Use SubprocessRunner.shared.run() instead to prevent MainActor blocking. (no_direct_process)
task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
task.arguments = ["-dvvv", bundlePath]
let pipe = Pipe()
Expand Down
16 changes: 16 additions & 0 deletions Tests/KeyPathTests/Core/SubprocessRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ final class SubprocessRunnerTests: XCTestCase {

// MARK: - Timeout Scenarios

func testRealRunnerTimeoutTerminatesLongRunningProcess() async {
// The fake above only proves callers handle a thrown timeout. This drives the
// real runner against a real process, which is the path that let an unbounded
// mdfind freeze Settings (docs/bugs/2026-09-21-duplicate-app-scan-mdfind-hang.md).
let start = Date()
do {
_ = try await SubprocessRunner.shared.run("/bin/sleep", args: ["10"], timeout: 0.5)
XCTFail("Expected timeout error")
} catch let SubprocessError.timeout(executable, _) {
XCTAssertEqual(executable, "/bin/sleep")
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertLessThan(Date().timeIntervalSince(start), 5, "Timeout must bound the wait")
}

func testTimeoutHandling() async {
// Setup fake to simulate timeout
await fakeRunner.setShouldTimeout(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ private final class StubHelperMaintenance: WizardHelperMaintaining {
var logLines: [String] = []
var lastErrorLine: String?

func detectDuplicateAppCopies() -> [String] {
func detectDuplicateAppCopies() async -> [String] {
["/Applications/KeyPath.app"]
}

Expand Down
44 changes: 42 additions & 2 deletions Tests/KeyPathTests/Managers/HelperMaintenanceTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@testable import KeyPathAppKit
@testable import KeyPathCore
@preconcurrency import XCTest

@MainActor
Expand All @@ -13,24 +14,26 @@ final class HelperMaintenanceTests: XCTestCase {
override func tearDown() async throws {
try await super.tearDown()
HelperMaintenance.testDuplicateAppPathsOverride = nil
HelperMaintenance.testDuplicateScanRunner = nil
HelperMaintenance.testLegacyHelperArtifactPathsOverride = nil
HelperMaintenance.shared.applyTestHooks(nil)
HelperManager.testHelperFunctionalityOverride = nil
HelperManager.testInstallHelperOverride = nil
AdminCommandExecutorHolder.shared = originalExecutor
}

func testDetectDuplicateAppCopiesFiltersBuildPathsAndSortsApplicationsFirst() {
func testDetectDuplicateAppCopiesFiltersBuildPathsAndSortsApplicationsFirst() async {
HelperMaintenance.testDuplicateAppPathsOverride = {
[
"/Users/test/Downloads/KeyPath.app",
"/dist/KeyPath.app",
"/Volumes/Cache/Sparkle_generate_appcast/abc123/KeyPath.app",
"/Applications/KeyPath.app",
"/Users/test/KeyPath.app"
]
}

let copies = HelperMaintenance.shared.detectDuplicateAppCopies()
let copies = await HelperMaintenance.shared.detectDuplicateAppCopies()
XCTAssertEqual(copies.first, "/Applications/KeyPath.app")
let remaining = Set(copies.dropFirst())
XCTAssertEqual(
Expand All @@ -42,6 +45,43 @@ final class HelperMaintenanceTests: XCTestCase {
)
}

func testDetectDuplicateAppCopiesUsesBoundedSpotlightScan() async {
let runner = SubprocessRunnerFake.shared
await runner.reset()
await runner.configureRunResult { _, _ in
ProcessResult(
exitCode: 0,
stdout: "/Applications/KeyPath.app\n/Users/test/Downloads/KeyPath.app\n",
stderr: "",
duration: 0.01
)
}
HelperMaintenance.testDuplicateScanRunner = runner

let copies = await HelperMaintenance.shared.detectDuplicateAppCopies()

XCTAssertEqual(copies, ["/Applications/KeyPath.app", "/Users/test/Downloads/KeyPath.app"])
let commands = await runner.executedCommands
XCTAssertEqual(commands.first?.executable, "/usr/bin/mdfind")
XCTAssertEqual(HelperMaintenance.duplicateScanTimeout, 2)
XCTAssertTrue(HelperMaintenance.duplicateScanExcludedPathFragments.contains("/Sparkle_generate_appcast/"))
}

func testDetectDuplicateAppCopiesFallsBackWhenSpotlightTimesOut() async {
let runner = SubprocessRunnerFake.shared
await runner.reset()
await runner.setShouldTimeout(true)
HelperMaintenance.testDuplicateScanRunner = runner

let copies = await HelperMaintenance.shared.detectDuplicateAppCopies()

// A stalled Spotlight must degrade to the canonical locations, never hang, and
// never report a location that does not exist (which read as a phantom duplicate).
XCTAssertTrue(copies.allSatisfy { $0.hasSuffix("/KeyPath.app") })
XCTAssertTrue(copies.allSatisfy { FileManager.default.fileExists(atPath: $0) })
await runner.reset()
}

func testRunCleanupLogsWarningForDuplicateCopies() async {
HelperMaintenance.testDuplicateAppPathsOverride = {
[
Expand Down
55 changes: 55 additions & 0 deletions docs/bugs/2026-09-21-duplicate-app-scan-mdfind-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Settings/Wizard Spinner: Unbounded Spotlight Scan on the Main Actor

**Date:** 2026-09-21
**Severity:** User-visible hang (window stuck on a spinner indefinitely)
**Status:** Fixed

## Problem

Opening Settings → Advanced froze KeyPath behind a spinner for over a minute. The
debug log stopped mid-validation, and `ps` showed a single child process that never
exited:

```
/usr/bin/mdfind kMDItemFSName == 'KeyPath.app'c (parent: KeyPath, 70s+)
```

Killing that `mdfind` unblocked the app instantly. Run by hand a moment later, the
same query returned in 0.18s.

## Root Cause

Two defects compounded:

1. **No timeout.** `HelperMaintenance.detectDuplicateAppCopies()` launched `mdfind`
with `Process` and called `waitUntilExit()`, bypassing `SubprocessRunner`, which
every other subprocess call uses for its timeout. Spotlight can stall for a long
time — here the index included `KeyPath.app` copies on an external drive
(Sparkle `generate_appcast` staging directories), plausibly waiting on a sleeping
volume — and the call simply never returned.
2. **Called on the main actor.** The function was `nonisolated` but synchronous, and
every caller invoked it from main-actor UI code (`AdvancedSettingsTabView`'s
`.task`, `SettingsView.copyPublishedStatus()`, and the wizard pages' `.onAppear`).
A slow scan therefore froze the entire UI rather than one status row.

A third, smaller issue: the build-artifact filter did not know Sparkle's
`Sparkle_generate_appcast/` staging directories, so those copies would have been
reported as duplicate installs.

## Fix

- `detectDuplicateAppCopies()` is now `async` and runs `mdfind` through
`SubprocessRunner` with a 2-second timeout (`duplicateScanTimeout`). A timeout,
launch failure, non-zero exit, or empty result falls back to the canonical install
locations.
- All callers `await` it off the synchronous path. `SettingsView.copyPublishedStatus()`
stays synchronous (its validation-date observer must only copy published state, see
`SettingsStatusValidationLintTests`) and folds the scan result in from a follow-up task.
- `/Sparkle_generate_appcast/` joins the excluded path fragments.
- Tests: `testDetectDuplicateAppCopiesUsesBoundedSpotlightScan`,
`testDetectDuplicateAppCopiesFallsBackWhenSpotlightTimesOut`.

## Lesson

Any subprocess reachable from UI code must go through `SubprocessRunner` with a
timeout. "Fast in practice" (the old comment on this function) is not a bound.
Loading