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
88 lines
5.3 KiB
Swift
88 lines
5.3 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|