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
+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)
}
}
}