import AppKit import Observation import SwiftUI import os // MARK: - The focused card window's printable card /// **What a card window offers File ▸ Print…**: the card as the last snapshot found it, the lane it is in, /// the board it belongs to, its folder, and the thread it is showing. /// /// A handle of its own rather than a reuse of `cardComments` or `cardAttachments`, because neither carries /// the card: the comments pane knows a title and a folder (it announces about them), the attachments /// section knows a folder, and printing needs the whole `Card` — its `icon`, its reserved `labels`, its /// body. `CardAttachments`' shape and for its reasons: one per window, `@State` in the host, published /// through the focus system so a **menu row** reaches the frontmost card window without anyone keeping a /// which-window-is-key register (`FocusedBoardStoreKey`). /// /// **Re-derived from every snapshot**, like the window's title and subtitle: a card renamed, restyled or /// moved between lanes mid-session prints as it is now, not as it was when the window opened. @MainActor @Observable final class CardPrintSubject { /// `nil` until the window has joined its board — which is also exactly when there is nothing to print. var card: Card? /// The lane the card is in, for the print's context line. `nil` renders as the same "Untitled" /// placeholder a lane header shows (`PrintDocumentBuilder.untitled`). var laneTitle: String? /// The board's display name (`AppModel.displayName(of:)`), for the running head. var boardTitle = "" /// The card's folder — the anchor a relative image in its body resolves against (`BodyTarget.resolve`). var cardFolder: URL? /// The thread as the pane last read it, read again at print time. /// /// A closure rather than a stored value for `CardComments.readThread`'s reason: comments are /// window-scoped and outside the snapshot, so there is nothing to republish from — the pane re-reads /// from disk, and a print asks the same question at the moment it is asked to print. @ObservationIgnored var readThread: (() -> CommentThread)? init() {} } struct FocusedCardPrintKey: FocusedValueKey { typealias Value = CardPrintSubject } extension FocusedValues { var cardPrint: CardPrintSubject? { get { self[FocusedCardPrintKey.self] } set { self[FocusedCardPrintKey.self] = newValue } } } // MARK: - File ▸ Print… /// **File ▸ Print… (⌘P)** — 11-command-nexus.md's Print row, and the retirement of that document's "No /// Print story in v1 (⌘P unused)" line. /// /// ### The reported bug's actual cause, and what this file adds beside it /// /// The owner's repro (2026-08-09) is the board window frontmost, File ▸ Print… **enabled**, chosen from the /// menu — and the system alert appears anyway: "This application does not support printing. Please contact /// the application's developer." That rules out this row ever being unclaimed or disabled in the repro; the /// row runs, `PrintCoordinator` runs, and the alert comes out of the print operation itself. The actual cause /// is `Kanban.entitlements`: the app is sandboxed (`com.apple.security.app-sandbox`) and had no /// `com.apple.security.print` key, and a sandboxed app's `NSPrintOperation` is denied by the sandbox with /// this exact wording — the fix is that one entitlement key, not anything in this file. /// /// What *is* in this file, below, is real but secondary hardening: a **separate, narrower** failure mode /// with the coincidence of producing the identical alert text through AppKit's own nil-target print action /// rather than the sandbox (see "Always enabled" below) — a window that published no print scope at all used /// to leave ⌘P's key equivalent live and unclaimed, which is worth closing regardless of the entitlement. /// Finder's `printFiles` half (`PrintCoordinator.printFiles`) is hardening of a different kind: it was /// simply unimplemented before, which is its own route to the same stock alert, entitlement or not. /// /// ### Two scopes, one row — and a third that isn't a scope at all /// /// The board window prints **the board**: its lanes left to right, each lane's cards top to bottom, as a /// linear document rather than a picture of the strip (`PrintSource.board`). The card window prints **that /// card**. One menu row for both, which is what the platform means by Print — the frontmost window's /// document — and which is why the row reaches its subject through the focus system rather than through the /// app model. /// /// The two are told apart by which focused value is present, and cannot both be: a card window publishes no /// `boardStore` (`CardWindowHost`), so the board branch is scopeless there. The order below therefore /// decides nothing, and it follows `RevealInFinderCommand`'s — the board in front wins, the card-window /// branch stands when a card window is. /// /// A third window shape publishes neither: welcome, the template chooser, Settings, the restore-bootstrap /// window, or a card window whose board has not joined yet (`CardPrintSubject.card`'s own `nil` note). Those /// are not a third *scope* — there is nothing behind them to print — but ⌘P has to answer for them somehow, /// and the next section is about exactly that answer. /// /// ### Always enabled — the alternative is worse than a refusal /// /// **The row never disables**, which is a reversal from this feature's first cut: it used to grey out over /// the scopeless window above, on the ordinary "nothing to act on" theory every other command follows. The /// trouble is what "greyed out" means for a *keyboard shortcut* rather than a menu click — a disabled /// `Button` still owns its `.keyboardShortcut`, so ⌘P over one of those windows was never actually inert. /// SwiftUI drops the unclaimed chord back into the responder chain, which hands it to AppKit's own nil-target /// `printDocument:` — the platform's stock print action, present whether or not this app ever registers a /// document type for it — and *that* action's stock failure is, coincidentally, the same alert text the /// entitlement gap produces: "This application does not support printing. Please contact the application's /// developer." Two different mechanisms, one AppKit sentence — the entitlement is what the owner's repro /// needed; this closes the other door to the same words, for a window with genuinely nothing to print rather /// than a board or card the app simply couldn't get permission to send to a printer. /// /// So the row claims ⌘P **unconditionally**, in every window, and answers the scopeless case itself rather /// than declining to answer at all — the same instinct `BoardInfoCommand` and `RevealInFinderCommand` already /// have for the read-only lock (a locked board is exactly the board someone wants a paper copy of), pushed /// one layer further out. Validation used to be "scope and nothing else"; now there *is* nothing else to /// validate — every window answers, and the only question is which of three answers it gets. /// /// Neither the read-only lock nor the focused-editor rule ever closed this row, unlike every mutating row in /// `BoardCommands.swift`: a print is a **read**. An inline title editor is no obstacle either — the print /// takes the snapshot as it stands, which is what is on screen. /// /// A board with **nothing to print** — no lanes, or every card empty of every included component — still /// enables the row, deliberately: the honest place to discover that is the panel's own preview, and a ⌘P /// that greys out on a board the user is looking at reads as a broken app rather than as an empty document. /// That refusal, and the scopeless one, are both `PrintCoordinator`'s and both say so out loud /// (`PrintCoordinator.refuse` and `.refuseNoScope`) — an app-drawn sentence in this app's own voice, never /// AppKit's stock line about a developer to contact. struct PrintCommand: View { let appModel: AppModel @FocusedValue(\.boardStore) private var store @FocusedValue(\.cardPrint) private var cardPrint var body: some View { Button("Print…") { print() } .keyboardShortcut("p", modifiers: .command) } /// The row's three answers, named — the shape `print()` switches over and the shape a test can hold /// without a menu, a window, or AppKit (`SaveAsTemplateCommand.allowsSave`'s reason for `isEnabled` /// existing as a pure function in the first place, one refactor later). enum Scope: Equatable { /// A board window is in front — print the board. case board /// No board window, but a card window with a card that has joined its board — print the card. case card /// Neither: welcome, the template chooser, Settings, the restore-bootstrap window, or a card /// window whose board has not caught up yet. Not a failure — a fact about what is on screen — and /// answered with a sentence rather than silence (`PrintCoordinator.refuseNoScope`). case refuse } /// The row's whole decision, as a pure function of the two facts it turns on — extracted from the view /// for the same reason `isEnabled` used to be: a rule that can only be exercised through a menu is a /// rule nobody tests. What changed is the shape of the answer, not the two facts it is a function of. /// /// **The board in front wins**, exactly as `RevealInFinderCommand`'s three-way branch does — a card /// window publishes no `boardStore`, so the two facts can never both be true at once, and the order /// below decides nothing real. static func resolveScope(hasBoard: Bool, hasPrintableCard: Bool) -> Scope { if hasBoard { return .board } if hasPrintableCard { return .card } return .refuse } private func print() { switch Self.resolveScope(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil) { case .board: guard let store else { return } PrintCoordinator.printBoard(store: store, profiles: appModel.printProfiles) case .card: guard let cardPrint else { return } PrintCoordinator.printCard(cardPrint, profiles: appModel.printProfiles) case .refuse: PrintCoordinator.refuseNoScope() } } } // MARK: - PrintCoordinator /// **The AppKit half of ⌘P**: build the session, hand the panel our accessory, run the operation. And, since /// 2026-08-09, **the AppKit half of Finder's half too** — the `printFiles` Apple Event a File ▸ Print… on a /// selected board, a drag onto a printer queue, or a print-and-open service delivers, forwarded here from /// `AppDelegate.application(_:printFiles:withSettings:showPrintPanels:)` rather than left unimplemented. /// Neither half of this was the reported bug's actual cause — `PrintCommand`'s own doc comment tells that /// story, and it is `Kanban.entitlements`' `com.apple.security.print` key, not this file. Left unimplemented, /// though, an unhandled `printFiles` *is* its own route to AppKit's stock refusal — "This application does /// not support printing. Please contact the application's developer." — entitlement or not, so implementing /// it is still worth doing on its own terms. /// /// ### Why the windowed operation is sheeted rather than run modally /// /// `runModal()` would block the main thread inside a nested run loop for as long as the panel is up, which /// is the shape `NSSavePanel`'s uses in `DuplicateBoardCommand` — and is right there, because that panel /// answers a question the copy is *waiting* on. A print is not: the board keeps reloading, the watcher keeps /// running, an agent may be writing. So `run(_:)` uses the sheeted form when a window exists, whose /// completion is where the Last Used capture lands. `printFiles` never has that luxury — Finder's caller is /// not a window at all — so its half always takes the synchronous fallback `run(_:)` itself falls back to /// when there is no key window (`runOperation(for:showsPrintPanel:)`, the two entry points' shared floor). /// /// ### The two refusals /// /// A document with no pages is refused with an alert instead of printed (`refuse(_:)`). It is reachable two /// ways — a board with no cards, and every component toggled off — and both deserve a sentence rather than a /// sheet of running heads over blank paper. A ⌘P with **no scope at all** — the window in front is not a /// board and not a card — gets the second sentence (`refuseNoScope()`), and `printFiles` reuses that same /// wording for a path that does not resolve to a board. Neither alert is 02-architecture.md's write-failure /// banner surface, which is about writes: nothing failed in either case, there was simply nothing to print. @MainActor enum PrintCoordinator { private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing") // MARK: Entry points /// **The board**, as of this moment (`PrintSource`'s frozen-snapshot note). static func printBoard(store: BoardStore, profiles: PrintProfileStore) { let snapshot = store.snapshot let title = AppModel.displayName(of: store) let session = PrintSession( provider: PrintSourceProvider( withoutComments: PrintSource.board(snapshot, titled: title), // Deferred, and read at most once — the comments toggle decides whether a board print pays // a thread read per card (`PrintSourceProvider`). withComments: { PrintSource.board(snapshot, titled: title) { card in PrintComment.list(of: store.commentThread(inCard: card.id)) } } ), profiles: profiles, // No single folder is right for every card's relative images, so a board print resolves none // (`PrintDocumentRenderer.appendBody`). cardFolder: nil, jobTitle: title, boardTitle: title ) run(session) } /// **One card**, with the thread read now. static func printCard(_ subject: CardPrintSubject, profiles: PrintProfileStore) { guard let card = subject.card else { return } let comments = subject.readThread.map { PrintComment.list(of: $0()) } ?? [] let source = PrintSource.card( card, laneTitle: subject.laneTitle, boardTitle: subject.boardTitle, comments: comments ) let session = PrintSession( provider: PrintSourceProvider(complete: source), profiles: profiles, cardFolder: subject.cardFolder, jobTitle: card.title.value ?? PrintDocumentBuilder.untitled, boardTitle: subject.boardTitle ) run(session) } // MARK: The operation private static func run(_ session: PrintSession) { guard !session.blocks().isEmpty else { refuse(session) return } let operation = makeOperation(for: session, showsPrintPanel: true) // **The Last Used capture happens when the operation ends, and only if it ran** — see // `PrintCompletion`, which is also the reason the sheeted form needs a delegate at all. if let window = keyWindow() { let completion = PrintCompletion(session: session) operation.runModal( for: window, delegate: completion, didRun: #selector(PrintCompletion.printOperationDidRun(_:success:contextInfo:)), contextInfo: nil ) } else { // No window to sheet on — which should not happen for a command scoped to a focused window, but // a print is still a legitimate thing to do and a modal run is the honest fallback. `run()` is // synchronous, so the capture is an ordinary line rather than a callback. runOperation(operation, session: session) } } /// **The one place an `NSPrintOperation` is actually built** — `run(_:)`'s sheeted path and /// `printFiles`'s always-headless one both start here, so the two can never quietly drift apart on the /// paper, the accessory, or the job title. /// /// `NSPrintInfo.shared`, deliberately: it is where the panel's paper, orientation and margins are /// remembered between prints, which is exactly the continuity a user expects from a print dialog — and /// an app with no `NSDocument` has no per-document print info for them to live in instead (which is also /// why `printFiles`'s own `withSettings` dictionary is read by nobody here — see its doc comment). /// /// The accessory and the panel's option set are built only when a panel is actually going to show: /// `showsPrintPanel: false` is Finder's silent-print request, and an accessory nobody will ever see is a /// `PrintSession` retained for no reason. private static func makeOperation(for session: PrintSession, showsPrintPanel: Bool) -> NSPrintOperation { let printInfo = NSPrintInfo.shared // Both modes are moot while the view answers `knowsPageRange` itself — AppKit takes the view's page // rects and does not subdivide them further — and they are set anyway to say what the document is: // one column exactly as wide as the page, which never spills sideways. printInfo.horizontalPagination = .clip printInfo.verticalPagination = .automatic let view = PrintDocumentView(session: session, printInfo: printInfo) let operation = NSPrintOperation(view: view, printInfo: printInfo) operation.jobTitle = session.jobTitle operation.showsPrintPanel = showsPrintPanel operation.showsProgressPanel = showsPrintPanel if showsPrintPanel { let panel = operation.printPanel // The preview is what makes the accessory worth having (`PrintOptionsAccessoryController`), and // the page-setup group is what lets the paper questions be answered in the same dialog rather // than in a second one this app does not have (there is no File ▸ Page Setup… row — // 11-command-nexus.md). panel.options.formUnion([.showsPreview, .showsPaperSize, .showsOrientation, .showsScaling, .showsCopies, .showsPageRange]) panel.addAccessoryController(PrintOptionsAccessoryController(session: session)) } return operation } /// The synchronous fallback both windowless callers use: run the operation now, capture Last Used only /// on success (`PrintCompletion`'s own asymmetry, restated here because there is no delegate on this /// path to hold it) — and hand back what happened, which `run(_:)`'s own fallback discards and /// `printFiles` reports to Finder. @discardableResult private static func runOperation(_ operation: NSPrintOperation, session: PrintSession) -> Bool { let success = operation.run() if success { session.profiles.captureLastUsed(session.options) } return success } /// The window the sheet hangs on: the app's key window, which for a command validated against the focus /// system *is* the window that published the subject. Asked of AppKit rather than threaded through the /// focus system because a `NSWindow` is not a value a `FocusedValue` should carry, and because /// `WindowAccessor`'s per-window controllers exist for window *lifecycle*, not for presenting over one. private static func keyWindow() -> NSWindow? { NSApp.keyWindow ?? NSApp.mainWindow } private static func refuse(_ session: PrintSession) { logger.notice("print refused: the document has no content") let alert = NSAlert() alert.messageText = "Nothing to Print" alert.informativeText = session.options.describesAnyContent ? "'\(session.jobTitle)' has no cards with any of the content you chose to include." : "Turn on at least one of Title, Icon & Labels, Body or Comments." alert.addButton(withTitle: "OK") alert.runModal() } /// **The row's third answer** (`PrintCommand.Scope.refuse`): no board window, no printable card. Same /// voice as `refuse(_:)` — "Nothing to Print" is the one title this file's alerts ever wear — and the /// same posture: nothing failed, there was simply nothing behind the window to print. This is the /// sentence a stray ⌘P now gets instead of falling through to AppKit's "This application does not /// support printing" (`PrintCommand`'s doc comment tells the whole story). static func refuseNoScope() { logger.notice("print refused: no board or card in focus") let alert = NSAlert() alert.messageText = "Nothing to Print" alert.informativeText = "Open a board or a card to print it." alert.addButton(withTitle: "OK") alert.runModal() } // MARK: - Finder's half: the `printFiles` Apple Event /// One `printFiles` path's answer — the Finder-print counterpart of `PrintCommand.Scope`, and a /// separate type from it rather than a reused one because a Finder path can never resolve to `.card`: /// Finder hands over folders, never the identity of a card inside one. enum FinderPrintTarget { case board(BoardModel) case refuse } /// **Board-or-refuse, read straight off disk** — `BoardLoader` succeeding on the path is the whole /// test, exactly as `BoardModel`'s own doc comment describes what opens: "`` is the `.kanban` /// package (or an extension-less folder — both open)". No extension check, no UTI re-derivation: a path /// this app's own loader accepts is a board, whatever it is named, and a path it refuses (a plain file, /// a folder with no root `index.md`, one whose `schema` is newer than this build understands) is not. /// /// A free function of a path rather than folded into `printFiles` itself, for `PrintCommand.resolveScope`'s /// own reason one boundary over: this is the part a test can call without handing AppKit a real print job. static func resolveFinderPrint(atPath path: String) -> FinderPrintTarget { guard let model = try? BoardLoader.load(boardRoot: URL(fileURLWithPath: path)).model else { return .refuse } return .board(model) } /// **`AppDelegate.application(_:printFiles:withSettings:showPrintPanels:)`'s whole implementation.** /// Every path is resolved independently (`resolveFinderPrint`) and, for a path that resolves to a /// board, printed **headless** — `BoardLoader`'s snapshot directly, no `BoardStore`, no window, the same /// frozen-snapshot posture `printBoard(store:profiles:)` takes from a live one, because this is a /// snapshot too: read once, printed once. A path that is not a board refuses politely /// (`refuseNoScope()`'s wording) rather than surfacing the loader's own defect UI, which is a board-open /// experience this is not. /// /// `printSettings` is accepted, never read: this app has no `NSDocument` and therefore no per-document /// print info for a caller's dictionary to override (`makeOperation`'s own note) — `NSPrintInfo.shared` /// is the one paper configuration Finder's print and the in-app one both draw from, which is what keeps /// "Print This Board" from Finder and ⌘P on the same board agreeing about margins. /// /// The reply folds every path's outcome into AppKit's one required answer: any success at all is /// `.printingSuccess` (Finder's queue is the honest place to discover a *partial* batch failure, not this /// return value), a batch with no successes but at least one non-board path is `.printingFailure`, and a /// batch of boards that all cancelled or failed at the operation level is `.printingCancelled` — the same /// cancel-or-jam ambiguity `PrintCompletion` already lives with, one layer up. static func printFiles( _ paths: [String], settings printSettings: [NSPrintInfo.AttributeKey: Any], showPrintPanels: Bool, profiles: PrintProfileStore ) -> NSApplication.PrintReply { guard !paths.isEmpty else { return .printingFailure } var printedAny = false var refusedAny = false for path in paths { switch resolveFinderPrint(atPath: path) { case let .board(model): if printHeadlessBoard(model, atPath: path, showPrintPanels: showPrintPanels, profiles: profiles) { printedAny = true } case .refuse: refuseNoScope() refusedAny = true } } if printedAny { return .printingSuccess } return refusedAny ? .printingFailure : .printingCancelled } /// One board, loaded and printed with no store and no window behind it — `printBoard(store:profiles:)` /// read straight off a `BoardModel` instead of a live store, with the comments closure rebuilt against /// `CommentThread.load` in place of `BoardStore.commentThread(inCard:)`, since there is no store here to /// ask. private static func printHeadlessBoard( _ model: BoardModel, atPath path: String, showPrintPanels: Bool, profiles: PrintProfileStore ) -> Bool { let root = URL(fileURLWithPath: path) let title = displayName(of: model, atPath: root) let session = PrintSession( provider: PrintSourceProvider( withoutComments: PrintSource.board(model, titled: title), withComments: { PrintSource.board(model, titled: title) { card in PrintComment.list(of: commentThread(of: card, inBoardAt: root, snapshot: model)) } } ), profiles: profiles, cardFolder: nil, jobTitle: title, boardTitle: title ) guard !session.blocks().isEmpty else { refuse(session) return false } return runOperation(makeOperation(for: session, showsPrintPanel: showPrintPanels), session: session) } /// `AppModel.displayName(of:)`'s reading, off a `BoardModel` rather than a live `BoardStore` — there is /// no store on this path to ask, and the rule is the same either way: the board's own `title`, falling /// back to the folder name (01-storage-format.md § Board naming). private static func displayName(of model: BoardModel, atPath root: URL) -> String { if let title = model.title.value, !title.isEmpty { return title } return AppModel.folderDisplayName(of: root) } /// One card's thread, read fresh from disk — `BoardStore.commentThread(inCard:)`'s own reading, off a /// bare snapshot: find which lane the card lives in (`BoardStore.boardItem`, a `nonisolated static` /// function that needs no store instance), resolve its folder (`ItemPath.card(lane:id:).folder(under:)`), /// and read the thread there. `.empty` for a card the snapshot no longer names — unreachable in practice /// (the card came from this very snapshot's own walk), kept as the same vanished-target answer every /// other card-scoped read gives rather than a force-unwrap. private static func commentThread(of card: Card, inBoardAt root: URL, snapshot: BoardModel) -> CommentThread { guard let item = BoardStore.boardItem(card.id, in: snapshot), let cardID = item.cardID else { return .empty } let path = "\(item.laneID.rawValue)/\(cardID.rawValue)" return CommentThread.load(inCard: ItemPath.card(lane: item.laneID, id: cardID).folder(under: root), path: path) } } /// The sheeted operation's delegate — **and the only place the Last Used capture can honestly happen.** /// /// `runModal(for:delegate:didRun:contextInfo:)` presents the panel as a sheet and **returns immediately**, /// which is the whole reason this class exists: capturing on the line after that call would record the /// options the panel *opened* with, before the user touched a control. A synchronous capture is only /// available on the windowless `run()` fallback, which is where `PrintCoordinator` puts one. /// /// **The gate is `success`, so a cancelled print rewrites nothing.** Cancel and a printer failure are /// indistinguishable here — both arrive as `success == false` — and of the two possible mistakes the /// asymmetry is clear: capturing on Cancel would overwrite the user's remembered settings with ones they /// abandoned, while declining to capture on a jam merely leaves Last Used where it was. So the settings a /// jammed print was configured with are not remembered; that is the cheaper loss, and the print system's /// own queue window is where the user retries the job anyway. /// /// **It retains itself until the callback.** `NSPrintOperation` holds its delegate weakly, and the sheeted /// form outlives every local in the method that presented it — so the object hands its own reference back /// only once the callback has landed. @MainActor private final class PrintCompletion: NSObject { private let session: PrintSession private var untilTheCallback: PrintCompletion? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing") init(session: PrintSession) { self.session = session super.init() untilTheCallback = self } @objc func printOperationDidRun(_ operation: NSPrintOperation, success: Bool, contextInfo: UnsafeMutableRawPointer?) { Self.logger.info("print operation finished — success: \(success, privacy: .public)") if success { session.profiles.captureLastUsed(session.options) } untilTheCallback = nil } }