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
+1
View File
@@ -27,6 +27,7 @@ The single source of truth for **every command and action the app can perform**
| File | Board Info (toggles the board popover — opens it closed, closes it open) | ⌘I | Board window | | File | Board Info (toggles the board popover — opens it closed, closes it open) | ⌘I | Board window |
| File | Duplicate (the board — a Finder-style "copy" sibling, 03 ▸ Welcome; never the selection) | ⇧⌘S | Board window | | File | Duplicate (the board — a Finder-style "copy" sibling, 03 ▸ Welcome; never the selection) | ⇧⌘S | Board window |
| File | Save as Template | — (no default) | Board window; 09-templates.md | | File | Save as Template | — (no default) | Board window; 09-templates.md |
| File | Share… (the board, staged as a `.zip`, `NSSharingServicePicker` anchored to the board window's toolbar or its center) | — (no default) | Board window; design ruling 2026-08-09, card 72691b11 — a faithful copy like Duplicate's (`.git` the sole exclusion; attachments, comments and `.trash/` carried), never gated on the read-only lock (a share is a read, `Print…`'s own posture), disabled only while an inline title editor is focused |
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin | | File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
| File | Add Attachment… | ⇧⌘A | Card window | | File | Add Attachment… | ⇧⌘A | Card window |
| File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column | | File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column |
+147 -9
View File
@@ -168,7 +168,7 @@ struct DuplicateBoardCommand: View {
let source = store.rootURL let source = store.rootURL
Task { @MainActor in Task { @MainActor in
let cancellation = DuplicateCancellation() let cancellation = BoardCopyCancellation<URL>()
// The in-progress row 02 § The banner surface names for "big-board Duplicate": info // 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 // 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, // carrying Cancel, which 02 promises on copy-shaped work ("remove the partial copy,
@@ -225,7 +225,7 @@ struct DuplicateBoardCommand: View {
_ source: URL, _ source: URL,
titled name: String, titled name: String,
into destination: URL?, into destination: URL?,
cancellation: DuplicateCancellation cancellation: BoardCopyCancellation<URL>
) async -> Result<URL, BoardDuplicator.Failure> { ) async -> Result<URL, BoardDuplicator.Failure> {
// Cancelled during the flush: the copy never starts, rather than starting and being told to // 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. // stop the same outcome, reached without making a folder to delete.
@@ -379,7 +379,7 @@ struct SaveAsTemplateCommand: View {
let source = store.rootURL let source = store.rootURL
Task { @MainActor in Task { @MainActor in
let cancellation = DuplicateCancellation() let cancellation = BoardCopyCancellation<URL>()
// Copy-shaped work, so the row carries Cancel "remove the partial copy, nothing lost" // 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. // (02 § The banner surface), which the engine honors by removing the partial store entry.
let operation = store.banners.beginOperation( 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 // MARK: - Share
/// Template's alike: the one piece of state the banner row's `cancel` closure and the copy task have
/// to 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 /// **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 /// `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 /// 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 /// 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. /// 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 @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 private(set) var isCancelled = false
func attach(_ task: Task<URL, any Error>) { func attach(_ task: Task<Success, any Error>) {
self.task = task self.task = task
if isCancelled { task.cancel() } 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)
}
}
}
+10 -5
View File
@@ -186,11 +186,15 @@ struct KanbanApp: App {
IndieAboutCommand(configuration: AboutBox.configuration) IndieAboutCommand(configuration: AboutBox.configuration)
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board, // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board,
// Open, Open Recent , Board Info, Duplicate, Save as Template, Reveal in Finder, Add // Open, Open Recent , Board Info, Duplicate, Save as Template, Share, Reveal in Finder,
// Attachment, then the trash trio and Empty Trash. The board-scoped ones validate against // Add Attachment, then the trash trio and Empty Trash. The board-scoped ones validate
// the frontmost board through the focus system, so each is simply absent-of-effect when no // against the frontmost board through the focus system, so each is simply absent-of-effect
// board is in front; New Board and Open Recent are available everywhere, including with no // when no board is in front; New Board and Open Recent are available everywhere, including
// window at all. Close is the system's own item no row of ours to add. // with no window at all. Close is the system's own item no row of ours to add.
//
// **Share joined 2026-08-09** (design ruling, card 72691b11) right after Save as
// Template: the third command in a row that copies the whole board wholesale, each to a
// different destination a sibling folder, the templates store, or a share sheet.
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
BoardCreationCommands() BoardCreationCommands()
NewBoardCommand(appModel: appModel) NewBoardCommand(appModel: appModel)
@@ -212,6 +216,7 @@ struct KanbanApp: App {
DuplicateBoardCommand(appModel: appModel) DuplicateBoardCommand(appModel: appModel)
SaveAsTemplateCommand(appModel: appModel) SaveAsTemplateCommand(appModel: appModel)
ShareBoardCommand(appModel: appModel)
RevealInFinderCommand() RevealInFinderCommand()
AddAttachmentCommand() AddAttachmentCommand()
AddCommentCommand() AddCommentCommand()
+6
View File
@@ -917,6 +917,12 @@ public final class BannerCenter {
// pressed and the board is still exactly as it was: nothing was saved *over*, and the // pressed and the board is still exactly as it was: nothing was saved *over*, and the
// failure is about the copy in the templates folder, not about this board's own files. // failure is about the copy in the templates folder, not about this board's own files.
if let title { "Couldn't save '\(title)' as a template" } else { "Couldn't save the board as a template" } if let title { "Couldn't save '\(title)' as a template" } else { "Couldn't save the board as a template" }
case let .shareBoard(title):
// The command's own word (File Share), on `.saveAsTemplate`'s reasoning: the board
// itself is untouched by a share that fails partway nothing here was saved *over*
// so the sentence is about the staged copy that never reached the picker, not about
// this board's own files.
if let title { "Couldn't share '\(title)'" } else { "Couldn't share the board" }
case let .importAttachment(filename): case let .importAttachment(filename):
"Couldn't import '\(filename)'" "Couldn't import '\(filename)'"
case .listAttachments: case .listAttachments:
+11 -1
View File
@@ -3010,6 +3010,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// `title` is the board's display name. /// `title` is the board's display name.
case saveAsTemplate(title: String?) case saveAsTemplate(title: String?)
/// File Share the whole board staged into a `.zip` for `NSSharingServicePicker`
/// (design ruling 2026-08-09, card 72691b11; `BoardShareStager`). Its own case beside
/// `.duplicateBoard` and `.saveAsTemplate`, on both their reasoning: a third command copies a
/// board wholesale for a third reason, and a banner saying the app could not "duplicate" or
/// "save as template" a board nobody asked to duplicate or template would name gestures that
/// never happened. `title` is the board's display name.
case shareBoard(title: String?)
case importAttachment(filename: String) case importAttachment(filename: String)
case listAttachments case listAttachments
@@ -3246,6 +3254,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .rename: .rename(title: title) case .rename: .rename(title: title)
case .duplicateBoard: .duplicateBoard(title: title) case .duplicateBoard: .duplicateBoard(title: title)
case .saveAsTemplate: .saveAsTemplate(title: title) case .saveAsTemplate: .saveAsTemplate(title: title)
case .shareBoard: .shareBoard(title: title)
case .toggleTask: .toggleTask(title: title) case .toggleTask: .toggleTask(title: title)
case .editBody: .editBody(title: title) case .editBody: .editBody(title: title)
case .rawSource: .rawSource(title: title) case .rawSource: .rawSource(title: title)
@@ -3289,7 +3298,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// reading to be about and `.deleteComment`'s move into `comments/.trash/` stamps for the // reading to be about and `.deleteComment`'s move into `comments/.trash/` stamps for the
// plain container reason its board-level twin does. // plain container reason its board-level twin does.
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone, case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .paste, .style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.importAttachment, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema, .setBoardBackground, .mintBoardIndex, .stampSchema, .setBoardBackground,
@@ -3326,6 +3335,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .rename(title): Self.phrase("rename", title) case let .rename(title): Self.phrase("rename", title)
case let .duplicateBoard(title): Self.phrase("duplicate board", title) case let .duplicateBoard(title): Self.phrase("duplicate board", title)
case let .saveAsTemplate(title): Self.phrase("save as template", title) case let .saveAsTemplate(title): Self.phrase("save as template", title)
case let .shareBoard(title): Self.phrase("share board", title)
case let .importAttachment(filename): "import attachment '\(filename)'" case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments" case .listAttachments: "list attachments"
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash" case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
+308
View File
@@ -0,0 +1,308 @@
import Foundation
import Testing
@testable import Kanban
/// File Share's staging half (`BoardShareStager`; design ruling 2026-08-09, card 72691b11).
///
/// Three promises, and all three are about what lands in the staged `.zip`:
///
/// - **`.git` is the sole exclusion** attachments, comments and `.trash/` all carry through
/// verbatim, "a faithful copy" in the ruling's own words;
/// - **the filename is the sanitized title**, `"<Board Title>.zip"`, never the on-disk folder name
/// (the one place this app turns a freeform `title:` string into a path component);
/// - **cancellation and failure leave no residue** the staging directory this call created is
/// the one it also removes, `BoardDuplicator`'s and `TemplateEngine`'s own rule at a third
/// boundary.
///
/// The picker (`BoardSharePresentation`) and the subprocess itself (`DittoZipArchiver`) are
/// exercised elsewhere this suite is `stageTree(boardAt:into:isCancelled:)`, the plain
/// `FileManager` half, so the exclusion rule is provable without a subprocess anywhere in the
/// loop, plus a handful of end-to-end `stage(boardAt:titled:)` runs that do call through to
/// `ditto` (`DittoZipArchiverTests` is where that subprocess call gets its own, separate
/// verification that it actually works inside this app's sandbox).
// MARK: - Fixture
/// A board carrying everything the exclusion rule has an opinion about: `.git`, `.trash/`, a
/// comment thread with its own `.trash/`, an attachment, and a board-level stray.
private struct ShareableBoard {
let fixture: WriterFixture
let root: URL
static let name = "Roadmap.kanban"
init() throws {
fixture = try WriterFixture()
root = fixture.url(Self.name)
let path = Self.name
try fixture.item(path, "---\nschema: 1\ntitle: Roadmap\n---\nThe board's description.\n")
try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8))
// The sole exclusion.
try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
try fixture.file("\(path)/.git/objects/pack/pack-1.pack", Data([0x01, 0x02]))
// Carried: the board's own trash.
try fixture.item("\(path)/\(BoardLoader.trashFolderName)/\(Ident.card4)",
Item.rich(order: "1024", title: "Thrown Away"))
try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do"))
try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter"))
try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03]))
// Carried: a comment thread. Excluded regardless (unconditionally, by `BoardTreeCopy`
// itself): the comment thread's own `.trash/`.
try fixture.item(
"\(path)/\(Ident.lane1)/\(Ident.card1)/comments/\(Ident.card2)",
"---\nschema: 1\nkind: comment\nauthor: tester\n---\nA comment.\n"
)
try fixture.item(
"\(path)/\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Ident.card3)",
"---\nschema: 1\nkind: comment\nauthor: tester\n---\nA deleted comment.\n"
)
}
func tearDown() { fixture.tearDown() }
}
/// Every path under `root`, root-relative, hidden entries included.
private func tree(of root: URL) -> Set<String> {
var paths: Set<String> = []
let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
while let url = walker?.nextObject() as? URL {
paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
}
return paths
}
private func shareFailure(_ operation: () throws -> Void) -> BoardShareStager.Failure? {
do {
try operation()
Issue.record("expected the share to fail, but it succeeded")
return nil
} catch let failure as BoardShareStager.Failure {
return failure
} catch {
Issue.record("expected a BoardShareStager.Failure, got \(error)")
return nil
}
}
// MARK: - Naming
@Suite("BoardShareStager — the filename")
struct BoardShareStagerNamingTests {
@Test("An ordinary title needs no changes")
func ordinaryTitlePassesThrough() {
#expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap") == "Roadmap")
}
@Test("A slash is the one byte the filesystem actually forbids, and it is replaced")
func slashIsReplaced() {
#expect(BoardShareStager.sanitizedFilenameComponent(from: "Q1/Q2 Plan") == "Q1-Q2 Plan")
}
@Test("A colon is legal on disk but reads as a path separator in Finder, so it goes too")
func colonIsReplaced() {
#expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap: 2026") == "Roadmap- 2026")
}
@Test("Surrounding whitespace is trimmed")
func whitespaceIsTrimmed() {
#expect(BoardShareStager.sanitizedFilenameComponent(from: " Roadmap ") == "Roadmap")
}
@Test("An empty or all-whitespace title falls back to 'Board'")
func emptyTitleFallsBack() {
#expect(BoardShareStager.sanitizedFilenameComponent(from: "") == "Board")
#expect(BoardShareStager.sanitizedFilenameComponent(from: " ") == "Board")
// Made entirely of the one replaced character, and nothing left once it's swapped for '-'.
#expect(BoardShareStager.sanitizedFilenameComponent(from: "/") == "-")
}
}
// MARK: - The tree copy's exclusion rule
@Suite("BoardShareStager — the tree copy")
struct BoardShareStagerTreeTests {
@Test("`.git` is the sole exclusion — everything else, trash and comments included, rides along")
func onlyGitIsExcluded() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let destination = board.fixture.url("staged")
try BoardTreeCopy.createDirectory(at: destination)
try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })
let paths = tree(of: destination)
#expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") },
"inert history nobody receiving a shared board asked for")
#expect(paths.contains { $0.hasPrefix("\(BoardLoader.trashFolderName)/") },
"a share is a faithful copy — Duplicate's posture, not Save as Template's")
#expect(paths.contains { $0.hasSuffix("attachments/shot.png") })
#expect(paths.contains { $0.hasSuffix("comments/\(Ident.card2)/index.md") })
#expect(paths.contains("CLAUDE.user.md"))
#expect(!paths.contains { $0.contains("comments/.trash") },
"the comment thread's own trash never travels — BoardTreeCopy's unconditional rule")
}
@Test("The copy is byte for byte")
func contentIsVerbatim() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let destination = board.fixture.url("staged")
try BoardTreeCopy.createDirectory(at: destination)
try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })
#expect(try Data(contentsOf: destination.appendingPathComponent("CLAUDE.user.md"))
== Data("board instructions\n".utf8))
#expect(try Data(contentsOf: destination.appendingPathComponent(
"\(Ident.lane1)/\(Ident.card1)/attachments/shot.png"
)) == Data([0x01, 0x02, 0x03]))
}
@Test("The board being shared is never written to")
func originalIsUntouched() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let before = tree(of: board.root)
let destination = board.fixture.url("staged")
try BoardTreeCopy.createDirectory(at: destination)
try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })
#expect(tree(of: board.root) == before)
}
}
// MARK: - End to end
@Suite("BoardShareStager — stage(boardAt:titled:)")
struct BoardShareStagerEndToEndTests {
@Test("The zip lands named from the title, not the folder")
func zipIsNamedFromTheTitle() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Q1 Roadmap")
defer { BoardShareStager.cleanup(archive) }
#expect(archive.zipURL.lastPathComponent == "Q1 Roadmap.zip",
"the board's own display title — the folder is 'Roadmap.kanban', the title is not")
#expect(FileManager.default.fileExists(atPath: archive.zipURL.path))
}
@Test("The decompressed tree does not survive the zip — one file is left for the picker")
func onlyTheZipRemains() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap")
defer { BoardShareStager.cleanup(archive) }
let entries = try FileManager.default.contentsOfDirectory(atPath: archive.root.path)
#expect(
entries == ["Roadmap.zip"],
"the intermediate decompressed copy — trash and attachments included — must not sit in temp storage for the picker's whole lifetime"
)
}
@Test("cleanup(_:) removes the whole staging directory")
func cleanupRemovesEverything() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap")
#expect(FileManager.default.fileExists(atPath: archive.root.path))
BoardShareStager.cleanup(archive)
#expect(!FileManager.default.fileExists(atPath: archive.root.path))
}
@Test("A share cancelled before it began leaves no staging directory at all")
func cancellingBeforeTheFirstItemCreatesNothing() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)
let failure = shareFailure {
_ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: { true })
}
#expect(failure == .cancelled)
#expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before,
"no 'dev.rzen.indie.Kanban-share-…' folder survives — a cancelled share never happened")
}
@Test("Cancelling mid-walk removes the partial staging directory")
func cancellingMidWalkRemovesThePartial() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)
var reads = 0
let failure = shareFailure {
_ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: {
reads += 1
return reads > 3
})
}
#expect(failure == .cancelled)
#expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before)
}
@Test("A board that isn't there fails as a share, naming the operation, and leaves nothing behind")
func missingSourceFailsInTheShareVocabulary() throws {
let board = try ShareableBoard()
defer { board.tearDown() }
let missing = board.fixture.url("Never.kanban")
let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)
let failure = shareFailure {
_ = try BoardShareStager.stage(boardAt: missing, titled: "Never")
}
guard case let .failed(error) = failure else {
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
return
}
#expect(error.operation == .shareBoard(title: "Never"),
"the user pressed Share; the banner must say so")
#expect(BannerCenter.headline(for: error).hasPrefix("Couldn't share 'Never'"))
#expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before,
"a half-staged share is residue — it goes, and the banner is what remains")
}
}
// MARK: - Menu validation
@Suite("ShareBoardCommand — validation")
struct ShareBoardCommandValidationTests {
@Test("Needs both a focused board window and its session ref")
func needsStoreAndRef() {
#expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: false, isEditingInline: false))
#expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: false, isEditingInline: false))
#expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: true, isEditingInline: false))
#expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
}
@Test("An open inline title editor holds a pending change no flush can reach")
func inlineEditingDisablesIt() {
#expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: true))
}
@Test("Unlike Duplicate and Save as Template, the read-only lock never gates it — a share is a read")
func theLockIsNeverConsulted() {
// `isEnabled` takes no lock parameter at all this test pins that omission as
// deliberate: adding one back would be the change to catch here, not a passing assertion.
#expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
}
}
+55
View File
@@ -0,0 +1,55 @@
import Foundation
import Testing
@testable import Kanban
/// **The sandbox verification the card's design ruling asks for**: "prefer `Process`+`/usr/bin/
/// ditto` VERIFY in a sandboxed build".
///
/// `KanbanTests` is host-application-hosted (`TEST_HOST` in `project.yml` points at the built
/// `Lanework.app`), so this suite runs *inside* the real, sandboxed app process
/// `Kanban.entitlements`'s `com.apple.security.app-sandbox` and all rather than in a bare XCTest
/// bundle with none of the app's confinement. A green run here is empirical: `/usr/bin/ditto`,
/// spawned as this app's child, can read a folder this process owns and write a `.zip` beside it,
/// under the same entitlements a real File Share run would have. That is the closest this suite
/// can get to the real button without driving AppKit (`BoardSharePresentation`'s own boundary).
@Suite("DittoZipArchiver")
struct DittoZipArchiverTests {
@Test("A folder zips inside the sandbox, and the result is a real zip")
func zipsInsideTheSandbox() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let source = fixture.url("Payload")
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
try Data("hello from the sandbox\n".utf8).write(to: source.appendingPathComponent("note.txt"))
try FileManager.default.createDirectory(
at: source.appendingPathComponent("nested", isDirectory: true),
withIntermediateDirectories: true
)
try Data([0x01, 0x02, 0x03]).write(to: source.appendingPathComponent("nested/data.bin"))
let destination = fixture.url("Payload.zip")
try DittoZipArchiver.zip(contentsOf: source, to: destination)
#expect(FileManager.default.fileExists(atPath: destination.path))
let bytes = try Data(contentsOf: destination)
#expect(!bytes.isEmpty)
// The local file header signature `PK\x03\x04` proof this is an actual zip archive and
// not, say, an empty file `ditto` merely touched.
#expect(bytes.prefix(4) == Data([0x50, 0x4B, 0x03, 0x04]))
}
@Test("A source that doesn't exist is a failure, not a silent empty archive")
func missingSourceFails() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let missing = fixture.url("Never")
let destination = fixture.url("Never.zip")
#expect(throws: DittoZipArchiver.Failure.self) {
try DittoZipArchiver.zip(contentsOf: missing, to: destination)
}
#expect(!FileManager.default.fileExists(atPath: destination.path))
}
}