File ▸ Share… stages a board as a zip and hands it to the system share sheet

Duplicate's own posture — a faithful copy, `.git` the sole exclusion, attachments/comments/trash
carried verbatim — staged to a temp directory (BoardShareStager, ditto-zipped via
DittoZipArchiver) and presented through NSSharingServicePicker (BoardSharePresentation), anchored
to the board window's toolbar or its center. Follows Duplicate/Save as Template's flush-then-
cancellable-copy sequence under the banner's in-progress row (ShareBoardCommand, AppCommands.swift),
menu-validated on focus alone rather than the read-only lock (a share is a read, Print's own
posture) with the one carve-out an open inline title editor still needs. WriteOperation gains
.shareBoard for the banner vocabulary; 11-command-nexus.md's File menu table gains the row.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 02:13:54 -04:00
parent 05bbf78926
commit da5d310673
10 changed files with 997 additions and 15 deletions
+147 -9
View File
@@ -168,7 +168,7 @@ struct DuplicateBoardCommand: View {
let source = store.rootURL
Task { @MainActor in
let cancellation = DuplicateCancellation()
let cancellation = BoardCopyCancellation<URL>()
// The in-progress row 02 § The banner surface names for "big-board Duplicate": info
// tone, pinned, cleared on completion, swapped for the error row on failure and
// carrying Cancel, which 02 promises on copy-shaped work ("remove the partial copy,
@@ -225,7 +225,7 @@ struct DuplicateBoardCommand: View {
_ source: URL,
titled name: String,
into destination: URL?,
cancellation: DuplicateCancellation
cancellation: BoardCopyCancellation<URL>
) async -> Result<URL, BoardDuplicator.Failure> {
// Cancelled during the flush: the copy never starts, rather than starting and being told to
// stop the same outcome, reached without making a folder to delete.
@@ -379,7 +379,7 @@ struct SaveAsTemplateCommand: View {
let source = store.rootURL
Task { @MainActor in
let cancellation = DuplicateCancellation()
let cancellation = BoardCopyCancellation<URL>()
// Copy-shaped work, so the row carries Cancel "remove the partial copy, nothing lost"
// (02 § The banner surface), which the engine honors by removing the partial store entry.
let operation = store.banners.beginOperation(
@@ -434,9 +434,142 @@ struct SaveAsTemplateCommand: View {
}
}
/// The Cancel button's end of a running board copy File Duplicate's and File Save as
/// Template's alike: the one piece of state the banner row's `cancel` closure and the copy task have
/// to share.
// MARK: - Share
/// File Share the board staged into a `.zip` and handed to `NSSharingServicePicker`
/// (11-command-nexus.md; design ruling 2026-08-09, card 72691b11 "create a share sheet target for
/// board").
///
/// ### What it shares, and why a zip
///
/// A board is a folder tree, and no share destination (Mail, Messages, AirDrop) knows how to carry
/// one as an item the way it carries a file so this stages the board into a single `.zip`, named
/// `"<Board Title>.zip"`, and hands the picker that one URL (`BoardShareStager`). The content rule
/// is the ruling's own: **`.git` is the sole exclusion** inert history a recipient has no use for
/// and should not receive unasked and **everything else rides along verbatim: attachments,
/// comments, and `.trash/` included**, "a faithful copy" in the ruling's own words, echoing
/// `BoardDuplicator`'s posture rather than `TemplateEngine`'s narrower one. The trash inclusion is
/// flagged for owner review in the card's DECISIONS comment.
///
/// ### The sequence is Duplicate's, once more, with a picker where the open used to be
///
/// 1. **The flush first** `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own step:
/// "stage from a flushed state never share a half-written buffer" is the ruling's phrasing for
/// exactly what pending-work-landed-on-disk means.
/// 2. **The stage** `BoardShareStager.stage(boardAt:titled:)`, off the main actor so the banner's
/// in-progress row can spin, cancellable the same way (`BoardCopyCancellation`).
/// 3. **The picker** `BoardSharePresentation`, anchored to the window that was key when Share was
/// pressed (captured weakly before the flush's `await`, so a window closed mid-stage does not
/// keep it alive, and re-resolved to the current key window if it went away `PrintCoordinator
/// .keyWindow()`'s reasoning, stretched across the async gap staging needs that printing does
/// not). Presentation owns the staged files from there it is the one thing that knows when the
/// picker's interaction has actually finished and it is what removes them.
///
/// ### Validation
///
/// **Simpler than Duplicate's or Save as Template's**, by the ruling's own words ("enabled when a
/// board window is focused") and defensibly so: unlike either sibling, this never writes a byte
/// into the board's own tree, so the read-only lock's reason for existing (protecting board writes
/// a sandbox refusal or a vanished root would refuse) does not apply `PrintCommand`'s own "a
/// print is a read" posture, applied to a copy that leaves rather than one that prints. **The one
/// carve-out kept is the focused-inline-editor half**, not the whole of `acceptsBoardMutations`: an
/// open rename or new-card placeholder holds the one pending change no flush can reach
/// (`DuplicateBoardCommand`'s own note), and sharing mid-rename would zip a title the user is still
/// typing. Flagged for owner review the ruling's literal wording names only "board window
/// focused", not this carve-out.
struct ShareBoardCommand: View {
let appModel: AppModel
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardWindowRef) private var ref
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "share")
var body: some View {
Button("Share…") {
share()
}
.disabled(!canShare)
}
/// The row's whole decision, as a pure function of the three facts it turns on
/// (`SaveAsTemplateCommand.allowsSave`'s reason for extracting validation from the view: a rule
/// reachable only through a menu is a rule nobody tests).
static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool {
hasStore && hasRef && !isEditingInline
}
private var canShare: Bool {
Self.isEnabled(
hasStore: store != nil,
hasRef: ref != nil,
isEditingInline: store?.isEditingInline == true
)
}
private func share() {
guard canShare, let store, let ref else { return }
let name = AppModel.displayName(of: store)
let source = store.rootURL
Task { @MainActor in
// Weak: the window this share was invoked from must not be kept alive by this task
// across the flush's `await` just so the picker can anchor to it later.
weak var invokedWindow = NSApp.keyWindow ?? NSApp.mainWindow
let cancellation = BoardCopyCancellation<BoardShareStager.StagedArchive>()
let operation = store.banners.beginOperation(
label: "Preparing '\(name)' to share…",
cancel: { cancellation.cancel() }
)
defer { store.banners.endOperation(operation) }
await appModel.flushPendingWork(for: ref)
// Cancelled during the flush: staging never starts (`SaveAsTemplateCommand.save()`'s
// own guard, verbatim).
guard !cancellation.isCancelled else { return }
// Detached, `DuplicateBoardCommand`'s two-part reason: real I/O (a board's trash and
// attachments can be real bytes, and `ditto` zipping them is not instant) that must not
// block the banner's spinner, and a detached task's cancellation is only ever this
// row's Cancel.
let task = Task.detached(priority: .userInitiated) {
try BoardShareStager.stage(boardAt: source, titled: name)
}
cancellation.attach(task)
do {
let archive = try await task.value
let anchorWindow = invokedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow
BoardSharePresentation(archive: archive).present(anchorWindow: anchorWindow)
} catch let failure as BoardShareStager.Failure {
switch failure {
case .cancelled:
// The walk removed its own partial staging directory; the row leaves with the
// `defer`.
Self.logger.notice("share cancelled — the staged copy was removed")
case let .failed(error):
Self.logger.error("share failed: \(error.description, privacy: .public)")
store.banners.post(error)
}
} catch {
let write = BoardWriteError(
operation: .shareBoard(title: name),
path: source.path,
reason: .io(message: error.localizedDescription)
)
Self.logger.error("share failed: \(write.description, privacy: .public)")
store.banners.post(write)
}
}
}
}
/// The Cancel button's end of a running board copy File Duplicate's, File Save as Template's
/// and File Share's alike: the one piece of state the banner row's `cancel` closure and the copy
/// task have to share.
///
/// **A main-actor box rather than a lock**, because there is nothing here to race over: the row's
/// `cancel` is `@MainActor @Sendable`, and the task is created and attached on the same actor. The
@@ -444,13 +577,18 @@ struct SaveAsTemplateCommand: View {
/// sets by cancelling the task so this type exists only to close the window between the row
/// appearing and the copy task existing. A Cancel pressed during the flush must not be forgotten by
/// the task that starts after it, which is what `isCancelled` is for.
///
/// **Generic over the task's success type** (`URL` for Duplicate and Save as Template,
/// `BoardShareStager.StagedArchive` for Share) since it joined a third caller whose detached task
/// answers a different value the cancellation bookkeeping is identical either way and does not
/// care what the copy produced.
@MainActor
private final class DuplicateCancellation {
private final class BoardCopyCancellation<Success: Sendable> {
private var task: Task<URL, any Error>?
private var task: Task<Success, any Error>?
private(set) var isCancelled = false
func attach(_ task: Task<URL, any Error>) {
func attach(_ task: Task<Success, any Error>) {
self.task = task
if isCancelled { task.cancel() }
}
+135
View File
@@ -0,0 +1,135 @@
import AppKit
/// File Share's AppKit half `NSSharingServicePicker`, shown over the staged `.zip`
/// (`BoardShareStager`) and torn down once its interaction has genuinely finished (design ruling
/// 2026-08-09, card 72691b11).
///
/// **Deliberately untested**, `PrintCommand`'s own line drawn in the same place: "The picker
/// presentation itself stays untested (AppKit boundary, consistent with Print's own boundary)".
/// What is tested is everything on the other side of that boundary `BoardShareStager`'s staging
/// (pure `FileManager`/`Process` work) and `ShareBoardCommand.isEnabled` (a pure function of three
/// facts) so this file is deliberately thin: it owns exactly the two things that need `NSApp` and
/// a live window to exist at all, presentation and cleanup timing, and nothing else.
///
/// ### Anchor: the toolbar, or the window's center
///
/// The ruling's own words: "anchored to the board window's toolbar (or window center fallback)".
/// AppKit does not expose a public way to fetch a *specific* `NSToolbarItem`'s view for a
/// customizable toolbar whose items the user may have rearranged or removed (`WindowToolbarController`
/// builds items from a user-editable configuration; there is no "the Share button" to point at,
/// because the ruling asks for a menu row, not a toolbar item) so the closest honest reading of
/// "anchored to the toolbar" reachable through public API is the content view's top-trailing
/// corner, immediately below wherever the toolbar strip is drawn, when a toolbar is showing at all.
/// **Flagged for owner review** (the card's DECISIONS comment): a real toolbar-item anchor would
/// need a dedicated Share toolbar button, which the ruling's Surface bullet does not ask for.
@MainActor
enum BoardShareAnchor {
/// The view and rect `NSSharingServicePicker.show(relativeTo:of:preferredEdge:)` wants
/// window center whenever there is no window or no visible toolbar to approximate.
static func anchor(in window: NSWindow?) -> (view: NSView, rect: NSRect, edge: NSRectEdge) {
guard let window, let contentView = window.contentView else {
// A command validated against the focus system (`ShareBoardCommand` requires a
// focused board window) should never actually reach this pure defensive fallback so
// the picker still has *some* view to anchor to rather than a crash.
let view = NSView()
return (view, view.bounds, .minY)
}
let bounds = contentView.bounds
guard window.toolbar?.isVisible == true else {
let center = NSRect(x: bounds.midX, y: bounds.midY, width: 1, height: 1)
return (contentView, center, .minY)
}
let corner = NSRect(x: bounds.maxX - 1, y: bounds.maxY - 1, width: 1, height: 1)
return (contentView, corner, .maxY)
}
}
/// One picker, one staged archive, one cleanup self-retained until the interaction is over,
/// `PrintCommand.swift`'s `PrintCompletion` doing the identical trick for the identical reason:
/// `NSSharingServicePicker` (like `NSPrintOperation`) holds its delegate weakly, and the object
/// presenting it is otherwise a local that would deallocate the instant `present(anchorWindow:)`
/// returns, before the user has clicked anything in the popover it just opened.
///
/// ### When the cleanup actually runs
///
/// Three terminal states, and cleanup on all three the ruling's "removed after the picker
/// dismisses or errors", read as *the interaction concluding* rather than only the popover
/// closing, because a chosen service's own work (Mail composing a message, AirDrop's transfer) can
/// genuinely outlive the small popover UI disappearing:
///
/// - **Dismissed without choosing** (`didChoose: nil`) nothing will ever read the file; clean up
/// immediately.
/// - **The chosen service failed** (`didFailToShareItems`) nothing shared; clean up.
/// - **The chosen service succeeded** (`didShareItems`) the recipient side has what it needs
/// (Mail copied the attachment into the draft, AirDrop's transfer completed); clean up.
///
/// `sharingServicePicker(_:delegateFor:)` not setting `service.delegate` inside `didChoose` is
/// the documented hook for supplying the delegate the picker's own internal `perform()` call will
/// use, and it is what makes the two service callbacks below reachable at all.
// `NSSharingServicePickerDelegate`/`NSSharingServiceDelegate` are not themselves `@MainActor` in
// the SDK overlay (unlike `NSWindowDelegate`, which `HostedWindowController` conforms to with no
// such note) the conformance is isolated explicitly here, which is safe by construction: AppKit
// invokes every one of these callbacks on the main thread, they touch nothing but this object's own
// `archive`/`untilTheInteractionEnds`, and `present(anchorWindow:)` is only ever called from the
// share command's own `@MainActor` task.
@MainActor
final class BoardSharePresentation: NSObject, @MainActor NSSharingServicePickerDelegate, @MainActor NSSharingServiceDelegate {
private let archive: BoardShareStager.StagedArchive
private var untilTheInteractionEnds: BoardSharePresentation?
init(archive: BoardShareStager.StagedArchive) {
self.archive = archive
super.init()
untilTheInteractionEnds = self
}
/// Shows the picker over `archive.zipURL`, anchored per `BoardShareAnchor`.
func present(anchorWindow: NSWindow?) {
let picker = NSSharingServicePicker(items: [archive.zipURL])
picker.delegate = self
let (view, rect, edge) = BoardShareAnchor.anchor(in: anchorWindow)
picker.show(relativeTo: rect, of: view, preferredEdge: edge)
}
// MARK: - NSSharingServicePickerDelegate
func sharingServicePicker(
_ sharingServicePicker: NSSharingServicePicker,
didChoose service: NSSharingService?
) {
guard service != nil else {
// The user closed the popover without picking anything nothing is going to read the
// staged file from here on.
cleanup()
return
}
// Left non-nil deliberately: `delegateFor:` below is what actually wires the completion
// callbacks onto the service the picker is about to perform.
}
func sharingServicePicker(
_ sharingServicePicker: NSSharingServicePicker,
delegateFor sharingService: NSSharingService
) -> (any NSSharingServiceDelegate)? {
self
}
// MARK: - NSSharingServiceDelegate
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
cleanup()
}
func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: any Error) {
cleanup()
}
private func cleanup() {
BoardShareStager.cleanup(archive)
untilTheInteractionEnds = nil
}
}
+237
View File
@@ -0,0 +1,237 @@
import Foundation
/// File Share's staging half (design ruling 2026-08-09, card 72691b11 "create a share sheet
/// target for board") everything that happens **before** `NSSharingServicePicker` ever appears
/// (`BoardSharePresentation`, the AppKit half this deliberately has none of).
///
/// ### A faithful copy, `.git` the sole exclusion
///
/// The ruling's own words: content rules follow **Save as Template's exclusion precedent**
/// applied more narrowly "exclude any inert `.git`; include everything else verbatim
/// (attachments, comments, trash included it's a faithful copy)". That is `BoardDuplicator`'s
/// posture, not `TemplateEngine`'s: Save as Template drops **two** things (`.git` *and*
/// `.trash/`) because a template is content, not a fork; Duplicate drops neither, because a
/// duplicate *is* a fork. A share is neither of those nobody is forking history (a recipient
/// with no push access to gain nothing from a `.git` folder full of it, hence dropping it) or
/// building a content-only template (the recipient is meant to see the board exactly as it
/// stands, hence keeping the trash) so it lands in between, one exclusion, everything else
/// carried.
///
/// **The trash inclusion is flagged for owner review** (recorded in the card's DECISIONS
/// comment): a shared board carries the sender's `.trash/` cards they deleted to the
/// recipient, which is a real question ("does a shared board maybe not want to carry the
/// recipient your deleted cards?") this file answers one way (include, matching Duplicate's own
/// "it's a faithful copy" reasoning literally) without pretending the other answer is unreasonable.
///
/// ### Two steps, and a temp tree that never survives the zip
///
/// `stage(boardAt:titled:)` runs `stageTree(boardAt:into:)` (a plain `BoardTreeCopy` walk the
/// exact machinery `BoardDuplicator` and `TemplateEngine` already share, `.trash/`'s exclusion
/// list narrowed to one entry) and then `DittoZipArchiver.zip(contentsOf:to:)` over the result,
/// **removing the unzipped tree the moment the zip exists** the ruling asks for one file, the
/// `.zip`, at the destination the picker is handed, and there is no reason a decompressed second
/// copy of the board (trash included) should keep occupying the temp volume for the whole life of
/// the share sheet.
///
/// The two are split into their own functions rather than folded into one, because the split is
/// what makes the exclusion rule testable without the ditto subprocess in the loop at all
/// (`BoardShareStagerTests`'s tree-only suite) `DittoZipArchiverTests` is where the subprocess
/// itself gets its own, separate verification.
///
/// ### Atomicity: construct-then-clean, `TemplateEngine`'s own note
///
/// "A half-staged share must never be left for the picker to find." The whole staging directory is
/// created by this call and removed by this call on every exit that is not a `StagedArchive`
/// cancellation and failure alike (09-templates.md's atomicity note, applied one boundary over).
/// The one thing this file never owns is the **successful** exit's cleanup: a `StagedArchive` that
/// lands is handed to `BoardSharePresentation`, which is the one thing that knows when the
/// picker's interaction has actually finished dismissed, failed, or a completed share and
/// therefore the one thing that calls `cleanup(_:)` on that path.
///
/// ### Not `@MainActor`
///
/// A board's `.trash/` and attachments can be real bytes, and `ditto` zipping them is real I/O
/// the same reason `BoardTreeCopy` and `BoardDuplicator` stay off the main actor so the banner's
/// in-progress row can actually spin. Safe by construction, for the identical reason: this touches
/// only the two URLs it is handed and a staging directory it alone created.
enum BoardShareStager {
// MARK: - Outcomes
/// The two ways staging ends without a `StagedArchive` `TemplateEngine.Failure`'s own
/// vocabulary, for the same reason: the destination is a temp directory this app always owns,
/// so there is no save-panel-shaped `.refused` question to ask, only "the user cancelled" or
/// "something else went wrong".
enum Failure: Error, Sendable, Equatable {
/// The user cancelled (the banner row's Cancel). The staging directory is already gone
/// "a cancelled share never happened", `BoardDuplicator`'s rule verbatim.
case cancelled
/// Anything else: a full disk, an unreadable board, `ditto` itself refusing. The ordinary
/// one-shot banner's vocabulary.
case failed(BoardWriteError)
}
/// What a successful stage hands `BoardSharePresentation`: the whole staging directory (which
/// `cleanup(_:)` removes wholesale) and the one `.zip` file inside it the picker actually
/// shares.
struct StagedArchive: Sendable, Equatable {
let root: URL
let zipURL: URL
}
// MARK: - Naming
/// `"<Board Title>.zip"`'s left half the board's display title, made safe as a path
/// component.
///
/// Neither `BoardDuplicator` nor `TemplateEngine` sanitizes a name: both build their
/// destination from a folder name already legal on disk (`copyDestination(for:)` reads the
/// source `URL`'s own `lastPathComponent`), never from the frontmatter `title:` string this
/// function takes. A share's filename is the first place in the app a **freeform title**
/// becomes a **filename** on its own, so this is genuinely new, not a convention borrowed from
/// either sibling checked first, per the card's instructions, and found absent rather than
/// found and mirrored.
///
/// `/` is the one byte APFS/HFS+ actually forbids in a path component; `:` is legal on disk
/// but has read as a path separator in Finder since the Carbon HFS colon-mapping, and is the
/// one other character this codebase already treats as filename-hostile
/// (`BoardRegistry.quarantine`'s own colon replacement, on a timestamp rather than a title, but
/// the same character for the same reason). Both become `-`, trimmed, and an all-whitespace or
/// empty result falls back to `"Board"` the same word `WriterFixture.board(title:)`'s own
/// default uses, so an untitled board's zip reads as "a board" rather than as an empty string
/// AppKit would have to reject the file at all.
static func sanitizedFilenameComponent(from title: String) -> String {
let replaced = title
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "-")
let trimmed = replaced.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Board" : trimmed
}
// MARK: - Staging
/// A fresh, empty staging directory nobody else could be using `NSTemporaryDirectory()`
/// rather than beside the board (unlike Duplicate's sibling): there is no Finder-visible
/// destination here to place next to anything, and the app's own temp directory is always
/// writable, which is exactly why sharing carries no `.refused` outcome at all.
private static func makeStagingRoot() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("dev.rzen.indie.Kanban-share-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
/// The copy half, alone and testable without a subprocess: `rootURL`'s tree, minus `.git`,
/// landing at `destination` `BoardTreeCopy`'s ordinary per-item walk, carrying folder
/// attributes the way `BoardDuplicator` does ("a faithful copy", the ruling's own words,
/// echoing Duplicate's rather than the template engine's stripped-attributes posture).
static func stageTree(
boardAt rootURL: URL,
into destination: URL,
isCancelled: () -> Bool
) throws(BoardTreeCopy.Stop) {
try BoardTreeCopy.copy(
contentsOf: rootURL,
into: destination,
excludingTopLevel: [".git"],
carryingFolderAttributes: true,
isCancelled: isCancelled
)
}
/// Stages `rootURL` for sharing: a temp directory holding one `.zip`, named from `title`.
///
/// `title` is the board's resolved display name (`AppModel.displayName(of:)`, the same read
/// `DuplicateBoardCommand` takes) never optional here, unlike the `WriteOperation` payload it
/// feeds: a share's filename needs *some* word, and `AppModel.displayName(of:)` already
/// guarantees one (title, falling back to the folder name), so there is no genuinely titleless
/// case for this function to represent.
///
/// `isCancelled` is read between the walk's items and nowhere else, `BoardDuplicator`'s own
/// seam the zip step itself, once started, runs to completion rather than mid-process
/// (`DittoZipArchiver`'s own note: this app does not partially zip a file any more than
/// `BoardTreeCopy` partially copies one).
static func stage(
boardAt rootURL: URL,
titled title: String,
isCancelled: () -> Bool = { Task.isCancelled }
) throws(Failure) -> StagedArchive {
let operation = WriteOperation.shareBoard(title: title)
let name = sanitizedFilenameComponent(from: title)
func failure(at url: URL, _ message: String) -> Failure {
.failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
}
let root: URL
do {
root = try makeStagingRoot()
} catch {
throw failure(at: rootURL, "could not create a staging folder: \(error.localizedDescription)")
}
// Cancelled before it began is still cancelled checked before the tree walk starts so an
// empty staging directory never lingers for a share nobody asked to continue.
if isCancelled() {
try? FileManager.default.removeItem(at: root)
throw .cancelled
}
let treeCopy = root.appendingPathComponent(name, isDirectory: true)
let zipURL = root.appendingPathComponent("\(name).zip", isDirectory: false)
do {
try BoardTreeCopy.createDirectory(at: treeCopy)
try stageTree(boardAt: rootURL, into: treeCopy, isCancelled: isCancelled)
} catch let stop as BoardTreeCopy.Stop {
try? FileManager.default.removeItem(at: root)
switch stop {
case .cancelled:
throw .cancelled
case let .failed(url, underlying):
throw failure(at: url, "could not copy the board: \(underlying.localizedDescription)")
}
} catch {
try? FileManager.default.removeItem(at: root)
throw failure(at: treeCopy, "could not create the staging folder: \(error.localizedDescription)")
}
// Between the copy and the zip, once more a Cancel pressed the instant the walk finished
// must not still spawn `ditto` over a tree nobody wants shared.
if isCancelled() {
try? FileManager.default.removeItem(at: root)
throw .cancelled
}
do {
try DittoZipArchiver.zip(contentsOf: treeCopy, to: zipURL)
} catch let zipFailure as DittoZipArchiver.Failure {
try? FileManager.default.removeItem(at: root)
switch zipFailure {
case let .launchFailed(message):
throw failure(at: treeCopy, "could not start the archiver: \(message)")
case let .nonZeroExit(status, message):
throw failure(at: treeCopy, "the archiver exited with status \(status): \(message)")
}
} catch {
try? FileManager.default.removeItem(at: root)
throw failure(at: treeCopy, "could not create the zip: \(error.localizedDescription)")
}
// The zip is what the picker shares; the decompressed tree underneath it was only ever a
// means to that one file, and leaving it beside the zip would be a second, needless copy of
// the board's attachments and trash sitting in temp storage for the picker's whole lifetime.
try? FileManager.default.removeItem(at: treeCopy)
return StagedArchive(root: root, zipURL: zipURL)
}
/// Removes a staged archive's whole directory `BoardSharePresentation`'s call, once the
/// picker's interaction has genuinely finished (dismissed without choosing, a service failed,
/// or a service reported success). Best-effort: a cleanup that cannot remove a temp file it no
/// longer needs is not a reason to trouble the user with a banner about it.
static func cleanup(_ archive: StagedArchive) {
try? FileManager.default.removeItem(at: archive.root)
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
/// **The one place this app creates a `.zip`** `/usr/bin/ditto -c -k`, spawned as a child
/// process, for File Share (`BoardShareStager`; design ruling 2026-08-09, card 72691b11).
///
/// ### Why a spawned system tool rather than a library or a framework
///
/// Foundation has no public zip-writing API, and the two frameworks that could stand in for one
/// both miss: `AppleArchive` (macOS 11+) writes `.aar`/LZFSE streams, not the `.zip` format every
/// share destination this card targets (Mail, Messages, AirDrop, Finder) actually expects to open;
/// pulling in a third-party archiver (libarchive, minizip, ) is real dependency weight for one
/// button. `ditto -c -k` is Apple's own documented recipe for "make a zip archive" outside of
/// Finder's Compress menu, and it is what Finder's own Compress item shells out to so a `.zip`
/// this produces is the same bytes a user's own Finder compress would have written.
///
/// ### The sandbox question, answered rather than assumed
///
/// A sandboxed app's `Process` spawns a **child that inherits the parent's sandbox** rather than
/// running unconfined the concern the card's design ruling asks to "VERIFY in a sandboxed
/// build" rather than take on faith. `/usr/bin/ditto` is a platform binary reading and writing
/// only paths this app's own process already owns (`BoardShareStager`'s staging directory, inside
/// the app's temporary directory no security-scoped bookmark, no path outside the sandbox the
/// process wasn't already free to touch), so there is no privilege the child needs that the parent
/// does not already have. **Verified empirically, not just argued**: `DittoZipArchiverTests` runs
/// this exact call inside `KanbanTests`, which is host-application-hosted
/// (`TEST_HOST = .../Lanework.app/...` in `project.yml`) and therefore runs *as* the sandboxed app
/// process, entitlements included the closest this suite can get to the real Share button
/// without driving AppKit. A green run there is the sandboxed build the ruling asked for.
///
/// If a future OS or entitlement change ever makes that test go red, the ruling's own fallback is
/// recorded at the call site that would need it (`BoardShareStager`'s doc comment): share the
/// board's **folder URL** directly rather than a zip, which needs no subprocess at all.
enum DittoZipArchiver {
/// Why the archive did not get made `BoardShareStager` folds both into its own `.failed`.
enum Failure: Error, Sendable, Equatable {
/// `Process.run()` itself threw the executable could not be launched at all (missing,
/// unreadable, or a sandbox denial of the spawn itself rather than of what runs inside it).
case launchFailed(String)
/// `ditto` ran and exited non-zero its own stderr, which names the actual reason (a
/// source that vanished mid-copy, a destination it could not create) far better than this
/// type could reword it.
case nonZeroExit(status: Int32, message: String)
}
/// Zips the **contents** of `source` into `destination` `--keepParent` so the archive's one
/// top-level entry is `source`'s own folder name (a recipient decompressing the `.zip` gets
/// back a folder named after the board, not its contents spilled loose), and
/// `--sequesterRsrc` so any resource forks or Finder metadata land in `__MACOSX/` alongside
/// the payload rather than corrupting it for a non-Apple unzip on the receiving end the same
/// two flags Finder's own Compress uses.
///
/// `destination`'s parent must already exist; `ditto` does not create intermediate
/// directories, and this call does not either `BoardShareStager` owns the staging
/// directory's lifecycle, this only ever writes one file into a folder that is already there.
///
/// Synchronous and blocking callers running this off the main actor (`BoardShareStager`,
/// via `Task.detached`) is what keeps a multi-hundred-megabyte board's zip from freezing a
/// spinner, the same posture `BoardTreeCopy`'s own doc comment states for the plain-copy half
/// of this same flow.
static func zip(contentsOf source: URL, to destination: URL) throws(Failure) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.arguments = ["-c", "-k", "--sequesterRsrc", "--keepParent", source.path, destination.path]
// Piped rather than inherited: a child's stdout would otherwise interleave with this
// process's own on the same terminal/log, and stderr is read back on a non-zero exit,
// which is the only time `ditto`'s own words are worth surfacing.
process.standardOutput = Pipe()
let stderrPipe = Pipe()
process.standardError = stderrPipe
do {
try process.run()
} catch {
throw .launchFailed(error.localizedDescription)
}
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
throw .nonZeroExit(status: process.terminationStatus, message: message)
}
}
}