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:
@@ -855,14 +855,89 @@ struct PrintRunningHeadTests {
|
||||
@Suite("Print ▸ menu validation")
|
||||
struct PrintCommandValidationTests {
|
||||
|
||||
/// Two disjuncts and nothing else — a print is a read, so neither the read-only lock nor the
|
||||
/// focused-editor rule closes the row (`PrintCommand`).
|
||||
@Test("Scope alone enables the row, and a card window with no card does not")
|
||||
func validation() {
|
||||
#expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: false))
|
||||
#expect(PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: true))
|
||||
#expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: true))
|
||||
#expect(!PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: false), "welcome, or nothing at all")
|
||||
/// **The row never disables now** (revised 2026-08-09) — a print is a read, so neither the
|
||||
/// read-only lock nor the focused-editor rule ever closed it, and the old third state
|
||||
/// ("nothing published, so grey out") turned out to leave the ⌘P chord live anyway, falling
|
||||
/// through to AppKit's own stock print handling (`PrintCommand`'s doc comment tells the whole
|
||||
/// story). `resolveScope` is what the row switches on instead: three answers, no `disabled` left
|
||||
/// to fall through.
|
||||
@Test("The board wins over a card, and neither published is a polite refusal — not a dead key")
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user