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
544 lines
30 KiB
Swift
544 lines
30 KiB
Swift
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: "`<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.**
|
|
///
|
|
/// `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
|
|
}
|
|
}
|