From 04d1e735327b9b2f3e6a520c1802e21ef798a6d4 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:55:16 +0400 Subject: [PATCH] fix: store pendingReconcileTask handle; skip real Carbon hotkey binding on self-test runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppModel gains pendingReconcileTask (the most recent watcher-reconcile Task spawned by chooseTransport/chooseOneDriveFolder), which send() now awaits — see the SendController.swift commit in this series. chooseTransport/ chooseOneDriveFolder store their Task's handle into it instead of firing an untracked `Task { }`. bootstrap() now also skips binding the real, process-wide Carbon global capture hotkey on the same env-var-flagged self-test/headless runs that already skip the update-check schedule (PickerSelfTest's phases, PanelSnapshot, and the new ShotdeckTests launch-wiring regression test). Real Carbon hotkey registration is not safe to exercise in an automated test process — it can collide with ShotdeckCoreTests' own HotkeyCenterCarbonTests running in the same test binary — and bootstrap()'s hotkey step had never actually been exercised by any self-test before (none of them call bootstrap() directly) until the new real-wiring test in this series does. A real user launch never sets these env vars, so production behavior is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- Sources/Shotdeck/AppModel.swift | 34 ++++++++++++++++++++++------- Sources/Shotdeck/SettingsView.swift | 7 ++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index c4f6310..79888da 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -139,6 +139,14 @@ public final class AppModel { /// lets the LAST choice win instead of applying stale, superseded work. Not /// `@Observable`-relevant state — pure internal bookkeeping, never read by a View. var reconcileGeneration = 0 + /// The MOST RECENT watcher-reconcile Task spawned by chooseTransport/ + /// chooseOneDriveFolder, if one is still (or was just) in flight. send() awaits + /// this BEFORE snapshotting transport/folder, so a toggle immediately followed by + /// Send can never race ahead of the reconcile it depends on (the watcher's + /// recordUncommented flag briefly lagging the just-chosen transport, for example). + /// `Task` never throws; awaiting an already-completed task's `.value` + /// returns immediately. Not `@Observable`-relevant — pure internal bookkeeping. + var pendingReconcileTask: Task? func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } /// True when a last-composed PDF path is known this run, or the newest @@ -241,19 +249,29 @@ public final class AppModel { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.") } - let pref = HotkeyPreference.load() - captureHotkey = pref - if !bindCaptureHotkey(pref) { - setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") - } - - let skipSchedule = + // Env-var-flagged self-test/headless runs (PickerSelfTest's phases, PanelSnapshot, + // and the new ShotdeckTests launch-wiring regression test) skip two real-world + // side effects that are unsafe or meaningless in that context: the update-check + // schedule (a real network call), and binding the REAL, process-wide Carbon + // global hotkey — which is not safe to exercise in an automated/parallel test + // process (it can collide with ShotdeckCoreTests' own HotkeyCenterCarbonTests + // running in the same test binary) and was never meaningfully exercised by any + // self-test anyway. A real user launch never sets these env vars, so production + // behavior is unchanged. + let isSelfTestRun = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil || ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil - if !skipSchedule { + + let pref = HotkeyPreference.load() + captureHotkey = pref + if !isSelfTestRun, !bindCaptureHotkey(pref) { + setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") + } + + if !isSelfTestRun { updateChecker.startSchedule() } } diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index 9c8ba42..787332a 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -255,6 +255,9 @@ extension AppModel: SettingsWindowPresenting { /// the live value before every mutating step, so rapid toggling (this function or /// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an /// earlier, superseded call applying its stale folder/flag after a later one already won. + /// The Task's handle is stored in `pendingReconcileTask` so send() can await its + /// completion before snapshotting transport/folder — closing the OTHER race, where a + /// toggle is immediately followed by Send before this reconcile has settled. func chooseTransport(_ value: SendTransport) { guard !isSending else { setStatus("Finish the current send first.") @@ -274,7 +277,7 @@ extension AppModel: SettingsWindowPresenting { reconcileGeneration += 1 let generation = reconcileGeneration - Task { + pendingReconcileTask = Task { guard generation == self.reconcileGeneration else { return } await watcher.setRecordUncommented(value == .airDrop) guard generation == self.reconcileGeneration else { return } @@ -306,7 +309,7 @@ extension AppModel: SettingsWindowPresenting { reconcileGeneration += 1 let generation = reconcileGeneration - Task { + pendingReconcileTask = Task { guard generation == self.reconcileGeneration else { return } do { try await watcher.updateWatchFolder(url)