Print: entitle the sandbox, and stop the ⌘P chord from ever falling through

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
This commit is contained in:
2026-08-08 23:46:16 -04:00
parent ce92c24190
commit 9e6f4567df
7 changed files with 432 additions and 76 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ The single source of truth for **every command and action the app can perform**
| File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column | | File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column |
| File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28; lanes rejoined 2026-07-29): board cards and lanes move to `.trash/`, trash selections delete permanently (03's recoverability confirm — freight-counting for lanes). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05). **Delete Immediately (⌥⌘⌫) is deliberately absent** (removed 2026-07-30): permanence is only reachable inside the trash — 03 ▸ Trash | | File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28; lanes rejoined 2026-07-29): board cards and lanes move to `.trash/`, trash selections delete permanently (03's recoverability confirm — freight-counting for lanes). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05). **Delete Immediately (⌥⌘⌫) is deliberately absent** (removed 2026-07-30): permanence is only reachable inside the trash — 03 ▸ Trash |
| File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) | | File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) |
| File | Print… | ⌘P | Board window: prints **the board** as a linear document — lanes in left-to-right order, each lane's cards top-to-bottom, never a graphical snapshot of the strip; the trash is unreachable (it is a sibling container of `lanes`, not a lane — 01 ▸ Deletion). Card window: prints **that card**. Validation is scope and nothing else — a print is a read, so neither the read-only lock nor the focused-editor rule closes it (Reveal in Finder's posture). Options ride in a **print-panel accessory** with the system's live preview: which components (title, icon+labels line, rendered body, comments — off by default, oldest- or newest-first), page breaks (continuous / between lanes / between cards, real sheet boundaries), one base font face and size every other size derives from, and a toggleable header/footer (board title, print date, page numbers, custom line). The option sets persist app-side as **named print profiles** with a reserved "Last Used" pseudo-profile that auto-captures the most recent settings — `UserDefaults`, never board data (02 ▸ Per-board app state), the zoom level's own argument. **Page Setup… is deliberately absent**: the paper questions are answered in the print panel's own page-setup group, so a second dialog would be a second place to set one margin | | File | Print… | ⌘P | Board window: prints **the board** as a linear document — lanes in left-to-right order, each lane's cards top-to-bottom, never a graphical snapshot of the strip; the trash is unreachable (it is a sibling container of `lanes`, not a lane — 01 ▸ Deletion). Card window: prints **that card**. **The sandbox needs an entitlement to print at all** (owner-reported 2026-08-09, board window frontmost, row enabled): `Kanban.entitlements` carried no `com.apple.security.print`, so a sandboxed `NSPrintOperation` was denied with the system alert "This application does not support printing" regardless of which code path invoked it — the fix is the entitlement key, not app logic. **Also revised the same day, as hardening rather than as the fix**: the row is now **always enabled** — a print is a read, so neither the read-only lock nor the focused-editor rule ever closed it (Reveal in Finder's posture), and the row claims ⌘P unconditionally rather than disabling, because a disabled `Button` still owns its key equivalent — a window with neither scope (welcome, the template chooser, Settings, the restore-bootstrap window, a card window whose board hasn't joined) used to drop the unclaimed chord into AppKit's own nil-target print handling, whose stock failure happens to be the identical alert text by a wholly separate mechanism. Scope now resolves at the moment ⌘P is pressed instead: board, then card, then — with neither — a one-sentence app-drawn refusal ("Nothing to Print" / "Open a board or a card to print it."), the same voice the empty-document refusal already uses. Finder's own `printFiles` Apple Event (File ▸ Print… on a selected board, a drag onto a printer queue, a print-and-open service) is implemented rather than left unhandled — its own separate route to the same stock alert: `AppDelegate` loads the path headless through `BoardLoader` and either prints it or gives the identical one-sentence refusal. Options ride in a **print-panel accessory** with the system's live preview: which components (title, icon+labels line, rendered body, comments — off by default, oldest- or newest-first), page breaks (continuous / between lanes / between cards, real sheet boundaries), one base font face and size every other size derives from, and a toggleable header/footer (board title, print date, page numbers, custom line). The option sets persist app-side as **named print profiles** with a reserved "Last Used" pseudo-profile that auto-captures the most recent settings — `UserDefaults`, never board data (02 ▸ Per-board app state), the zoom level's own argument. **Page Setup… is deliberately absent**: the paper questions are answered in the print panel's own page-setup group, so a second dialog would be a second place to set one margin |
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows | | File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
| Edit | Undo / Redo (M) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) | | Edit | Undo / Redo (M) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard | | Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard |
+31
View File
@@ -71,6 +71,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
/// 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. /// 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 /// The same `CloseFlushCoordinator` sequence as a user close, once per board, in the same fixed
+8
View File
@@ -8,6 +8,14 @@
<true/> <true/>
<key>com.apple.security.files.bookmarks.app-scope</key> <key>com.apple.security.files.bookmarks.app-scope</key>
<true/> <true/>
<!-- Print is a v1 feature (11-command-nexus.md's Print row): the sandbox denies
`NSPrintOperation` outright without this key, and the denial surfaces as the misleading
system alert "This application does not support printing. Please contact the
application's developer." — which reads like a missing feature rather than a missing
entitlement. Needed for both halves of File ▸ Print… (`PrintCoordinator`) and Finder's
own `printFiles` Apple Event (`AppDelegate`). -->
<key>com.apple.security.print</key>
<true/>
<!-- **Declared now and exercised by nothing.** Nothing in the shipped app opens a socket: the <!-- **Declared now and exercised by nothing.** Nothing in the shipped app opens a socket: the
git provider that would have used this key is excised (strategy/01-git-excision.md), and git provider that would have used this key is excised (strategy/01-git-excision.md), and
the ops-based sync capability that will is a design pass still to come. The key stays the ops-based sync capability that will is a design pass still to come. The key stays
+9
View File
@@ -320,6 +320,15 @@ struct KanbanApp: App {
// window*, which no responder vends. Two items sharing the title "Print" is also exactly what // window*, which no responder vends. Two items sharing the title "Print" is also exactly what
// titles-are-API forbids. // titles-are-API forbids.
// //
// A reported "This application does not support printing" alert (2026-08-09) turned out to be
// the sandbox denying `NSPrintOperation` for want of `com.apple.security.print`
// (`Kanban.entitlements`) the actual fix, not anything here. Found alongside it, and worth
// keeping regardless: `PrintCommand` used to disable itself over a window with no board and no
// card, and a disabled `Button` still owns its `.keyboardShortcut` the unclaimed chord fell
// straight through to AppKit's own nil-target `printDocument:`, whose stock failure happens to be
// the identical alert text by a different route. The row now claims P unconditionally and
// answers the no-board-no-card case itself (`PrintCommand`'s doc comment).
//
// Page Setup stays absent with it: the paper questions are answered in the print panel's own // Page Setup stays absent with it: the paper questions are answered in the print panel's own
// page-setup group (`PrintCoordinator`), so a second dialog would be a second place to set one // page-setup group (`PrintCoordinator`), so a second dialog would be a second place to set one
// margin. // margin.
+291 -60
View File
@@ -61,7 +61,24 @@ extension FocusedValues {
/// **File Print (P)** 11-command-nexus.md's Print row, and the retirement of that document's "No /// **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. /// Print story in v1 (P unused)" line.
/// ///
/// ### Two scopes, one row /// ### 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 /// 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 /// linear document rather than a picture of the strip (`PrintSource.board`). The card window prints **that
@@ -74,17 +91,41 @@ extension FocusedValues {
/// decides nothing, and it follows `RevealInFinderCommand`'s the board in front wins, the card-window /// decides nothing, and it follows `RevealInFinderCommand`'s the board in front wins, the card-window
/// branch stands when a card window is. /// branch stands when a card window is.
/// ///
/// ### Validation is scope and nothing else /// 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.
/// ///
/// Neither the read-only lock nor the focused-editor rule closes it, unlike every mutating row in /// ### Always enabled the alternative is worse than a refusal
/// `BoardCommands.swift`: a print is a **read**, and a locked board is exactly the board someone wants a ///
/// paper copy of (`RevealInFinderCommand`'s posture, and `BoardInfoCommand`'s). An inline title editor is no /// **The row never disables**, which is a reversal from this feature's first cut: it used to grey out over
/// obstacle either the print takes the snapshot as it stands, which is what is on screen. /// 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 /// 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 /// 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 greys out on a board the user is looking at reads as a broken app rather than as an empty document.
/// The refusal, when it happens, is `PrintCoordinator`'s and it says so. /// 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 { struct PrintCommand: View {
let appModel: AppModel let appModel: AppModel
@@ -97,56 +138,79 @@ struct PrintCommand: View {
print() print()
} }
.keyboardShortcut("p", modifiers: .command) .keyboardShortcut("p", modifiers: .command)
.disabled(!isEnabled)
} }
/// One answer for both the `disabled` state and the action the codebase's usual shape, for its usual /// The row's three answers, named the shape `print()` switches over and the shape a test can hold
/// reason: two derivations of a rule are two chances to disagree. /// without a menu, a window, or AppKit (`SaveAsTemplateCommand.allowsSave`'s reason for `isEnabled`
private var isEnabled: Bool { /// existing as a pure function in the first place, one refactor later).
Self.isEnabled(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil) 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 validation as a pure function of the two facts it turns on, extracted from `isEnabled` for /// The row's whole decision, as a pure function of the two facts it turns on extracted from the view
/// `SaveAsTemplateCommand.allowsSave`'s reason: a rule that can only be exercised through a menu is a /// for the same reason `isEnabled` used to be: a rule that can only be exercised through a menu is a
/// rule nobody tests. /// rule nobody tests. What changed is the shape of the answer, not the two facts it is a function of.
/// ///
/// **Two disjuncts and nothing else.** No lock, no focused-editor rule, no is-there-anything-to-print /// **The board in front wins**, exactly as `RevealInFinderCommand`'s three-way branch does a card
/// see the type's doc comment for why each of those is deliberately absent. A card window whose board /// window publishes no `boardStore`, so the two facts can never both be true at once, and the order
/// has not loaded yet publishes a subject with no card, which is the second disjunct's whole point: /// below decides nothing real.
/// scope alone would enable the row over a window with nothing behind it. static func resolveScope(hasBoard: Bool, hasPrintableCard: Bool) -> Scope {
static func isEnabled(hasBoard: Bool, hasPrintableCard: Bool) -> Bool { if hasBoard { return .board }
hasBoard || hasPrintableCard if hasPrintableCard { return .card }
return .refuse
} }
private func print() { private func print() {
if let store { switch Self.resolveScope(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil) {
case .board:
guard let store else { return }
PrintCoordinator.printBoard(store: store, profiles: appModel.printProfiles) PrintCoordinator.printBoard(store: store, profiles: appModel.printProfiles)
return case .card:
} guard let cardPrint else { return }
if let cardPrint {
PrintCoordinator.printCard(cardPrint, profiles: appModel.printProfiles) PrintCoordinator.printCard(cardPrint, profiles: appModel.printProfiles)
case .refuse:
PrintCoordinator.refuseNoScope()
} }
} }
} }
// MARK: - PrintCoordinator // MARK: - PrintCoordinator
/// **The AppKit half of P**: build the session, hand the panel our accessory, run the operation. /// **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 operation is sheeted on the window rather than run modally /// ### 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 /// `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 /// 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 /// 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 this uses the sheeted form, whose completion is where the Last Used /// running, an agent may be writing. So `run(_:)` uses the sheeted form when a window exists, whose
/// capture lands. /// 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 one refusal /// ### The two refusals
/// ///
/// A document with no pages is refused with an alert instead of printed. It is reachable two ways a board /// A document with no pages is refused with an alert instead of printed (`refuse(_:)`). It is reachable two
/// with no cards, and every component toggled off and both deserve a sentence rather than a sheet of /// ways a board with no cards, and every component toggled off and both deserve a sentence rather than a
/// running heads over blank paper. The alert is the whole response: nothing failed, so this is not /// sheet of running heads over blank paper. A P with **no scope at all** the window in front is not a
/// 02-architecture.md's write-failure banner surface, which is about writes. /// 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 @MainActor
enum PrintCoordinator { enum PrintCoordinator {
@@ -207,29 +271,7 @@ enum PrintCoordinator {
refuse(session) refuse(session)
return return
} }
let operation = makeOperation(for: session, showsPrintPanel: true)
// `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.
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 = true
operation.showsProgressPanel = true
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))
// **The Last Used capture happens when the operation ends, and only if it ran** see // **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. // `PrintCompletion`, which is also the reason the sheeted form needs a delegate at all.
@@ -245,12 +287,61 @@ enum PrintCoordinator {
// No window to sheet on which should not happen for a command scoped to a focused window, but // 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 // 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. // synchronous, so the capture is an ordinary line rather than a callback.
if operation.run() { runOperation(operation, session: session)
session.profiles.captureLastUsed(session.options)
}
} }
} }
/// **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 /// 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 /// 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 /// focus system because a `NSWindow` is not a value a `FocusedValue` should carry, and because
@@ -269,6 +360,146 @@ enum PrintCoordinator {
alert.addButton(withTitle: "OK") alert.addButton(withTitle: "OK")
alert.runModal() 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: "`<root>` 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.** /// The sheeted operation's delegate **and the only place the Last Used capture can honestly happen.**
+83 -8
View File
@@ -855,14 +855,89 @@ struct PrintRunningHeadTests {
@Suite("Print ▸ menu validation") @Suite("Print ▸ menu validation")
struct PrintCommandValidationTests { struct PrintCommandValidationTests {
/// Two disjuncts and nothing else a print is a read, so neither the read-only lock nor the /// **The row never disables now** (revised 2026-08-09) a print is a read, so neither the
/// focused-editor rule closes the row (`PrintCommand`). /// read-only lock nor the focused-editor rule ever closed it, and the old third state
@Test("Scope alone enables the row, and a card window with no card does not") /// ("nothing published, so grey out") turned out to leave the P chord live anyway, falling
func validation() { /// through to AppKit's own stock print handling (`PrintCommand`'s doc comment tells the whole
#expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: false)) /// story). `resolveScope` is what the row switches on instead: three answers, no `disabled` left
#expect(PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: true)) /// to fall through.
#expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: true)) @Test("The board wins over a card, and neither published is a polite refusal — not a dead key")
#expect(!PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: false), "welcome, or nothing at all") func resolution() {
#expect(PrintCommand.resolveScope(hasBoard: true, hasPrintableCard: false) == .board)
#expect(PrintCommand.resolveScope(hasBoard: false, hasPrintableCard: true) == .card)
#expect(PrintCommand.resolveScope(hasBoard: true, hasPrintableCard: true) == .board, "the board in front wins")
#expect(
PrintCommand.resolveScope(hasBoard: false, hasPrintableCard: false) == .refuse,
"welcome, or nothing at all — answered with a sentence, never silence"
)
}
}
// MARK: - Finder's half: the `printFiles` Apple Event
/// `PrintCoordinator.resolveFinderPrint(atPath:)` the one part of the Finder-print path a test can
/// call without handing AppKit a real print job (`resolveFinderPrint`'s own doc comment). Everything
/// past this point (`printFiles`, `printHeadlessBoard`) drives a real `NSPrintOperation`, the same
/// AppKit boundary `PrintCoordinator.run`/`refuse` already sit past untested this suite pins the
/// board-or-refuse *decision*, not what AppKit does with it.
@Suite("Print ▸ Finder printFiles resolution")
@MainActor
struct PrintFinderResolutionTests {
@Test("A board's path resolves to its snapshot, extension-less exactly like every other board open")
func resolvesABoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.board(title: "Roadmap")
try fixture.lane(Ident.lane1, order: "1024", title: "Doing")
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "A card")
// `fixture.root` itself carries no `.kanban` extension the "both open" half of
// `BoardModel`'s own doc comment, exercised by construction rather than by a second fixture.
guard case let .board(model) = PrintCoordinator.resolveFinderPrint(atPath: fixture.root.path) else {
Issue.record("expected the path to resolve as a board")
return
}
#expect(model.title.value == "Roadmap")
#expect(model.lanes.map(\.title.value) == ["Doing"])
#expect(model.lanes.first?.cards.map(\.title.value) == ["A card"])
}
@Test("A folder with no root index.md refuses rather than throwing")
func refusesANonBoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// No `board()` call an ordinary empty folder, the shape of "not a board at all".
guard case .refuse = PrintCoordinator.resolveFinderPrint(atPath: fixture.root.path) else {
Issue.record("expected the path to refuse")
return
}
}
@Test("A plain file refuses rather than throwing")
func refusesAFile() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let fileURL = fixture.root.appendingPathComponent("not-a-board.txt")
try Data("hello".utf8).write(to: fileURL)
guard case .refuse = PrintCoordinator.resolveFinderPrint(atPath: fileURL.path) else {
Issue.record("expected the path to refuse")
return
}
}
@Test("A board whose root schema is newer than this build refuses, the same fail-fast every open gives")
func refusesAnUnsupportedSchema() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 999\ntitle: Future\n---\n")
guard case .refuse = PrintCoordinator.resolveFinderPrint(atPath: fixture.root.path) else {
Issue.record("expected the path to refuse")
return
}
} }
} }
+9 -7
View File
@@ -92,13 +92,15 @@ targets:
PRODUCT_MODULE_NAME: Kanban PRODUCT_MODULE_NAME: Kanban
MARKETING_VERSION: "2.0" MARKETING_VERSION: "2.0"
INFOPLIST_FILE: Kanban/Info.plist INFOPLIST_FILE: Kanban/Info.plist
# `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, and # `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks,
# `com.apple.security.network.client`, **declared now and exercised by nothing**: the key # `com.apple.security.print` (added 2026-08-09 — a sandboxed `NSPrintOperation` is denied
# stays because the sync capability to come needs it regardless (12-editions.md ▸ PIVOT # without it, with the misleading system alert "This application does not support
# 2026-08-08), and a key added later is a new provisioning profile and a new review # printing"), and `com.apple.security.network.client`, **declared now and exercised by
# surface. No App Group and no keychain access group — groups exist to share *between* # nothing**: the key stays because the sync capability to come needs it regardless
# apps, and there is one app, so app-side state homes in the ordinary sandbox container # (12-editions.md ▸ PIVOT 2026-08-08), and a key added later is a new provisioning profile
# (`AppStateHome`). # and a new review surface. No App Group and no keychain access group — groups exist to
# share *between* apps, and there is one app, so app-side state homes in the ordinary
# sandbox container (`AppStateHome`).
CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements
GENERATE_INFOPLIST_FILE: false GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete SWIFT_STRICT_CONCURRENCY: complete