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
+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
/// 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
/// 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
/// 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
/// `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
/// obstacle either the print takes the snapshot as it stands, which is what is on screen.
/// ### 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.
/// 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 {
let appModel: AppModel
@@ -97,56 +138,79 @@ struct PrintCommand: View {
print()
}
.keyboardShortcut("p", modifiers: .command)
.disabled(!isEnabled)
}
/// One answer for both the `disabled` state and the action the codebase's usual shape, for its usual
/// reason: two derivations of a rule are two chances to disagree.
private var isEnabled: Bool {
Self.isEnabled(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil)
/// 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 validation as a pure function of the two facts it turns on, extracted from `isEnabled` for
/// `SaveAsTemplateCommand.allowsSave`'s reason: a rule that can only be exercised through a menu is a
/// rule nobody tests.
/// 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.
///
/// **Two disjuncts and nothing else.** No lock, no focused-editor rule, no is-there-anything-to-print
/// see the type's doc comment for why each of those is deliberately absent. A card window whose board
/// has not loaded yet publishes a subject with no card, which is the second disjunct's whole point:
/// scope alone would enable the row over a window with nothing behind it.
static func isEnabled(hasBoard: Bool, hasPrintableCard: Bool) -> Bool {
hasBoard || hasPrintableCard
/// **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() {
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)
return
}
if let cardPrint {
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.
/// **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
/// 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 this uses the sheeted form, whose completion is where the Last Used
/// capture lands.
/// 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 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
/// with no cards, and every component toggled off and both deserve a sentence rather than a sheet of
/// running heads over blank paper. The alert is the whole response: nothing failed, so this is not
/// 02-architecture.md's write-failure banner surface, which is about writes.
/// 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 {
@@ -207,29 +271,7 @@ enum PrintCoordinator {
refuse(session)
return
}
// `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))
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.
@@ -245,12 +287,61 @@ enum PrintCoordinator {
// 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.
if operation.run() {
session.profiles.captureLastUsed(session.options)
}
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
@@ -269,6 +360,146 @@ enum PrintCoordinator {
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: "`<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.**