From ef0d8712f79961fe3ef23a1556c4727ac5089dbf Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:27:42 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20BLOCKER=20=E2=80=94=20launch=20model=20u?= =?UTF-8?q?ses=20transport-aware=20paths;=20bootstrap=20reconciles=20watch?= =?UTF-8?q?er=20folder=20unconditionally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppDelegate.makeLaunchModel() built `paths` via the AirDrop-only FolderSettings.resolvedAppSupportPaths(), so ReturnWatcher's internal watchFolder (seeded from paths.watchFolder in its own init) was the AirDrop folder even when OneDrive was the persisted transport, and bootstrap() never reconciled it before starting. Net effect: on every relaunch with OneDrive selected, PDFs went to OneDrive but FSEvents kept watching the stale AirDrop folder for the whole session — marked-up returns were never detected. Fix: makeLaunchModel() now calls TransportSettings.resolvedAppSupportPaths() (single source of truth for the transport-folder mapping); made internal (not private) with an optional appSupportRoot override so PickerSelfTest's relaunch-simulation sub-step can call the exact same function against a temp root instead of the real Application Support folder. bootstrap() now unconditionally calls watcher.updateWatchFolder(watchFolderURL) before watcher.start() (belt-and-suspenders reconciliation, even though the paths fix alone already makes this a no-op in the normal case), and sets recordUncommented before start as it already did. Regression tests proving this land in the same PR (ReturnWatcherTests.swift): one characterizing the old bug's exact construction still missing a marked OneDrive-mode return, one proving the fixed launch-construction path detects it end to end. The ONEDRIVE-SELFTEST phase also gains a relaunch sub-step using this same makeLaunchModel() function. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- Sources/Shotdeck/AppModel.swift | 18 ++++++++++++++++++ Sources/Shotdeck/main.swift | 13 +++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index b59e451..c4f6310 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -132,6 +132,13 @@ public final class AppModel { } func setTransport(_ value: SendTransport) { transport = value } func setResolvedOneDriveFolder(_ value: URL?) { resolvedOneDriveFolder = value } + + /// Bumped by chooseTransport/chooseOneDriveFolder (SettingsView.swift) before each + /// spawns its async watcher-reconcile Task; that Task checks its own snapshot + /// against the live value before every mutating step, so rapid toggling always + /// 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 func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } /// True when a last-composed PDF path is known this run, or the newest @@ -211,6 +218,17 @@ public final class AppModel { await watcher.setRecordUncommented(transport == .airDrop) do { + // BLOCKER fix: reconcile the watcher's internal watchFolder with the live + // watchFolderURL UNCONDITIONALLY, before it ever starts. `paths` (and so the + // watcher's initial folder, set in its own init) now comes from the same + // transport-aware TransportSettings.effectiveFolders() as watchFolderURL, so + // in the normal case this is a no-op — but it is the only thing that would + // have caught the old bug (launch paths built AirDrop-only while OneDrive was + // the persisted transport, leaving the watcher's FSEvents stream pointed at a + // stale folder for the whole session) and it stays cheap insurance against + // that class of drift ever recurring. Calling it before start() only updates + // the stored folder — no FSEvents stream exists yet to restart. + try await watcher.updateWatchFolder(watchFolderURL) try await watcher.start { [weak self] _ in Task { @MainActor in guard let self else { return } diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index e2f5fff..bf052db 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -50,9 +50,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { await model.bootstrap() } } - private static func makeLaunchModel() -> AppModel { + /// Builds the model exactly the way the real app launches: paths come from + /// `TransportSettings.resolvedAppSupportPaths()` — transport-aware, so the watcher + /// this feeds is never seeded with a stale AirDrop folder while OneDrive is the + /// persisted transport (that was the BLOCKER this function used to have, when it + /// called the AirDrop-only `FolderSettings.resolvedAppSupportPaths()` instead). + /// `appSupportRoot` exists only so PickerSelfTest's relaunch-simulation sub-step can + /// point this at a temp directory instead of the real + /// ~/Library/Application Support/Shotdeck — production always calls this with no + /// argument (the real root). Internal, not private, for that same reason. + static func makeLaunchModel(appSupportRoot: URL? = nil) -> AppModel { do { - let paths = try FolderSettings.resolvedAppSupportPaths() + let paths = try TransportSettings.resolvedAppSupportPaths(root: appSupportRoot) return try makeModel(paths: paths) } catch { Log.ui.critical(