diff --git a/Kanban/App/AppDelegate.swift b/Kanban/App/AppDelegate.swift index e83b14e..618085d 100644 --- a/Kanban/App/AppDelegate.swift +++ b/Kanban/App/AppDelegate.swift @@ -34,6 +34,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return false } + /// Double-clicking a `.kanban` folder in Finder, or `open -a` — "opening a board from Finder is a + /// standard document open" (02-architecture.md § Launch and window lifecycle). Every URL is + /// forwarded to `AppModel.openBoard(at:)`, the exact entry point welcome and File ▸ Open… already + /// call (`AppModel.presentOpenPanel()`), so a Finder open gets the identical registry record, + /// board window, and recents stamp, and a board that is already open focuses its window rather + /// than opening a second one — `openBoard` starts its own security-scoped access on the URL, the + /// same as the open panel's, so nothing needs stashing here first. + /// + /// **Not pre-validated.** Info.plist's `CFBundleDocumentTypes` declares the UTI, so macOS should + /// never route anything but a `.kanban` folder here — but if it did, or the folder has since gone + /// missing, `openBoard`'s fail-fast load surfaces the failure row-level on welcome, uniform with + /// every other open failure. A second vocabulary for "not a board" here would just be a worse copy + /// of the one that already exists. + /// + /// **Can fire before any scene has appeared** — a cold launch (the app was not already running) + /// delivers this ahead of the first window's `onAppear`, which is where `windowOpener` is normally + /// captured (`CaptureOpenWindow`). `openBoard` buffers a URL that arrives that early and replays + /// it once the action exists, so this method does not have to reason about launch ordering itself. + func application(_ application: NSApplication, open urls: [URL]) { + guard let appModel else { return } + for url in urls { + appModel.openBoard(at: url) + } + } + /// Quit runs the close flush for **every** open board before the app goes away. /// /// The same `CloseFlushCoordinator` sequence as a user close, once per board, in the same fixed diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index f823404..60be6d5 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -251,11 +251,25 @@ public final class AppModel { @ObservationIgnored public var windowDismisser: DismissWindowAction? + /// URLs handed to `openBoard(at:)` before `windowOpener` existed to open them — a cold launch's + /// Finder-open (`AppDelegate.application(_:open:)`) can arrive ahead of the first scene's + /// `onAppear`. Held in order, replayed the moment `captureWindowActions` gives the app somewhere + /// to open them, then discarded — the buffer is a doorway, not a second registry of intent. + @ObservationIgnored + private var pendingOpenURLs: [URL] = [] + /// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which /// needs the actions before any `onAppear` has run, has one thing to call. func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) { windowOpener = open windowDismisser = dismiss + + guard !pendingOpenURLs.isEmpty else { return } + let urls = pendingOpenURLs + pendingOpenURLs.removeAll() + for url in urls { + openBoard(at: url) + } } // MARK: Recents @@ -403,9 +417,13 @@ public final class AppModel { /// /// Security-scoped access starts here, *before* the window exists, because the host's very first /// act is a tree walk: a scope started after the load would be too late. + /// + /// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can + /// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same + /// method runs again for it once `captureWindowActions` has something to open it with. public func openBoard(at url: URL) { guard let windowOpener else { - Self.logger.error("openBoard with no window opener captured yet — ignored") + pendingOpenURLs.append(url) return }