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(