Root cause of the owner's repro (board window frontmost, File ▸ Print… enabled, chosen from the menu, alert appears anyway): Kanban.entitlements carried no com.apple.security.print key. The app is sandboxed, and a sandboxed NSPrintOperation is denied by the sandbox with exactly this wording — "This application does not support printing. Please contact the application's developer." — regardless of which code path invokes it. Fix: add the entitlement. Alongside it, hardening for a separate, narrower failure mode that happens to produce the identical alert text by a different mechanism: PrintCommand used to disable itself over a window that published neither a board nor a printable card (welcome, the template chooser, Settings, the restore-bootstrap window, a card window whose board hasn't joined). A disabled SwiftUI Button still owns its .keyboardShortcut, so the unclaimed ⌘P chord fell through to AppKit's own nil-target printDocument: action, whose stock failure is the same system alert. The row now claims ⌘P unconditionally in every window; scope resolves at the moment of the action instead (board, then card, then a polite "Nothing to Print" / "Open a board or a card to print it." refusal in the app's own voice). The boolean isEnabled(hasBoard: hasPrintableCard:) becomes a three-way PrintCommand.resolveScope(...) -> Scope pure function. Also implements AppDelegate's application(_:printFiles:withSettings: showPrintPanels:) — Finder's own File ▸ Print… / drag-to-printer / print-and-open path was previously unhandled, its own separate route to the same stock alert. PrintCoordinator.printFiles loads each path headless through BoardLoader (no store, no window) and either prints it or gives the same one-sentence refusal; the operation-building code shared with the in-app path is factored out of run(_:) into makeOperation(for:showsPrintPanel:) and runOperation(_:session:). Docs: 11-command-nexus.md's Print row, PrintCommand's and PrintCoordinator's doc comments, KanbanApp.swift's CommandGroup comment, and project.yml's entitlements comment all narrate the entitlement as the actual fix and the scope work as hardening beside it. Tests: PrintCommandValidationTests now exercises resolveScope's three arms in place of the old boolean. A new PrintFinderResolutionTests suite covers PrintCoordinator.resolveFinderPrint(atPath:) — the one piece of the Finder half a test can drive without handing AppKit a real print job — against a real board, an empty non-board folder, a plain file, and an unsupported schema. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
127 lines
7.3 KiB
Swift
127 lines
7.3 KiB
Swift
import AppKit
|
|
import os
|
|
|
|
/// The three window-lifecycle answers SwiftUI has no modifier for (02-architecture.md § Launch and
|
|
/// window lifecycle, § Windows).
|
|
///
|
|
/// It holds the `AppModel` rather than reaching for a singleton: `KanbanApp` creates the model and
|
|
/// hands it over in its own `init`, so there is exactly one and no global to accidentally build a
|
|
/// second registry behind.
|
|
@MainActor
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
|
/// Set by `KanbanApp.init()`. Optional only because the adaptor constructs this object before the
|
|
/// model exists; it is non-`nil` from the first run-loop turn onward.
|
|
var appModel: AppModel?
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate")
|
|
|
|
/// Applies the stored appearance override to `NSApp` (03-board-ui.md ▸ Toolbar).
|
|
///
|
|
/// **Here rather than in `AppModel.init`**, and the distinction matters: a unit-test host *is*
|
|
/// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch
|
|
/// (`AppStateHome.isUnitTestHost`). `AppearanceStore.init` only reads a default; this is the one
|
|
/// call that hands its answer to `NSApp` — the global side effect `KanbanApp.init` must not
|
|
/// carry (`AppearanceStore.applyCurrent`). Launch is the honest home for it, and this is the
|
|
/// app's.
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
appModel?.appearance.applyCurrent()
|
|
}
|
|
|
|
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
|
|
/// alive)" — a document-shaped app whose windows are boards has no business quitting because the
|
|
/// user tidied one away, and welcome is one Dock click or one menu item back.
|
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
false
|
|
}
|
|
|
|
/// A Dock click with nothing on screen shows welcome — the other half of the rule above.
|
|
///
|
|
/// `false` means "handled, do nothing further"; `true` lets AppKit run its default (unminiaturize,
|
|
/// open an untitled document), which is right when windows do exist and wrong when they do not —
|
|
/// this app has no untitled document to make.
|
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
|
guard !hasVisibleWindows, let appModel else { return true }
|
|
appModel.showWelcome()
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Finder's `printFiles` Apple Event — File ▸ Print… on a board selected in Finder, a drag onto a
|
|
/// printer queue, or a print-and-open service. Left unimplemented, this delivers to AppKit's stock
|
|
/// nil-target print handling and its stock failure: "This application does not support printing.
|
|
/// Please contact the application's developer." — the same wording the *reported* bug turned out to
|
|
/// have a different cause for (the sandbox's `com.apple.security.print` entitlement,
|
|
/// `Kanban.entitlements`, `PrintCommand`'s doc comment tells that story) — but an unhandled Apple
|
|
/// Event is its own route to it regardless, which is what this method retires
|
|
/// (`PrintCoordinator.printFiles`'s doc comment tells the whole story; this method is only the
|
|
/// receiving end of the Apple Event).
|
|
///
|
|
/// Every path is resolved and printed independently — `PrintCoordinator.printFiles` does the actual
|
|
/// work, headless (no store, no window, `BoardLoader` on the path directly), so this method is a
|
|
/// straight handoff of the Apple Event's three arguments and nothing more. `appModel` should always
|
|
/// be set by the time an Apple Event can reach here (it exists from the first run-loop turn onward —
|
|
/// this type's own note), but a `nil` is answered with `.printingFailure` rather than force-unwrapped,
|
|
/// the same defensive posture `application(_:open:)` takes one method up.
|
|
func application(
|
|
_ application: NSApplication,
|
|
printFiles filenames: [String],
|
|
withSettings printSettings: [NSPrintInfo.AttributeKey: Any],
|
|
showPrintPanels: Bool
|
|
) -> NSApplication.PrintReply {
|
|
guard let appModel else { return .printingFailure }
|
|
return PrintCoordinator.printFiles(
|
|
filenames,
|
|
settings: printSettings,
|
|
showPrintPanels: showPrintPanels,
|
|
profiles: appModel.printProfiles
|
|
)
|
|
}
|
|
|
|
/// 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
|
|
/// order — card windows and their sessions, then pending debounced work, then the registry stamp,
|
|
/// then teardown (02 § Windows: "closing a board window (**and app quit**) first closes the
|
|
/// board's card windows …"). The one difference is the cause: quit does not clear the open-now
|
|
/// flags, which is what makes the next launch reopen exactly this set.
|
|
///
|
|
/// `.terminateLater` plus a deferred reply is the only way to await anything here — the delegate
|
|
/// method is synchronous and the flush is not. With no boards open there is nothing to flush and
|
|
/// the app exits immediately rather than taking a run-loop turn to discover that.
|
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
|
guard let appModel, appModel.hasOpenBoards else { return .terminateNow }
|
|
|
|
Task { @MainActor in
|
|
await appModel.flushAllBoardsForQuit()
|
|
Self.logger.debug("quit flush complete")
|
|
sender.reply(toApplicationShouldTerminate: true)
|
|
}
|
|
return .terminateLater
|
|
}
|
|
}
|