From da5d31067383d62be624d464a2ca6e1d3c3a1ab6 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 02:11:25 -0400 Subject: [PATCH] =?UTF-8?q?File=20=E2=96=B8=20Share=E2=80=A6=20stages=20a?= =?UTF-8?q?=20board=20as=20a=20zip=20and=20hands=20it=20to=20the=20system?= =?UTF-8?q?=20share=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DESIGN/11-command-nexus.md | 1 + Kanban/App/AppCommands.swift | 156 +++++++++++- Kanban/App/BoardSharePresentation.swift | 135 +++++++++++ Kanban/App/BoardShareStager.swift | 237 ++++++++++++++++++ Kanban/App/DittoZipArchiver.swift | 87 +++++++ Kanban/KanbanApp.swift | 15 +- Kanban/LiveStore/BannerCenter.swift | 6 + Kanban/Storage/BoardWriter.swift | 12 +- KanbanTests/BoardShareStagerTests.swift | 308 ++++++++++++++++++++++++ KanbanTests/DittoZipArchiverTests.swift | 55 +++++ 10 files changed, 997 insertions(+), 15 deletions(-) create mode 100644 Kanban/App/BoardSharePresentation.swift create mode 100644 Kanban/App/BoardShareStager.swift create mode 100644 Kanban/App/DittoZipArchiver.swift create mode 100644 KanbanTests/BoardShareStagerTests.swift create mode 100644 KanbanTests/DittoZipArchiverTests.swift diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index cf87cb5..c3bc209 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -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 | 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 | 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 | 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 | diff --git a/Kanban/App/AppCommands.swift b/Kanban/App/AppCommands.swift index 982ca9d..d57d869 100644 --- a/Kanban/App/AppCommands.swift +++ b/Kanban/App/AppCommands.swift @@ -168,7 +168,7 @@ struct DuplicateBoardCommand: View { let source = store.rootURL Task { @MainActor in - let cancellation = DuplicateCancellation() + let cancellation = BoardCopyCancellation() // 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 ) async -> Result { // 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() // 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 +/// `".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() + 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 { - private var task: Task? + private var task: Task? private(set) var isCancelled = false - func attach(_ task: Task) { + func attach(_ task: Task) { self.task = task if isCancelled { task.cancel() } } diff --git a/Kanban/App/BoardSharePresentation.swift b/Kanban/App/BoardSharePresentation.swift new file mode 100644 index 0000000..50226c8 --- /dev/null +++ b/Kanban/App/BoardSharePresentation.swift @@ -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 + } +} diff --git a/Kanban/App/BoardShareStager.swift b/Kanban/App/BoardShareStager.swift new file mode 100644 index 0000000..62fceb3 --- /dev/null +++ b/Kanban/App/BoardShareStager.swift @@ -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 + + /// `".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) + } +} diff --git a/Kanban/App/DittoZipArchiver.swift b/Kanban/App/DittoZipArchiver.swift new file mode 100644 index 0000000..1ee13bb --- /dev/null +++ b/Kanban/App/DittoZipArchiver.swift @@ -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) + } + } +} diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 6dc8e8b..ceac34e 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -186,11 +186,15 @@ struct KanbanApp: App { IndieAboutCommand(configuration: AboutBox.configuration) // 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 - // Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate against - // the frontmost board through the focus system, so each is simply absent-of-effect when no - // board is in front; New Board… and Open Recent are available everywhere, including with no - // window at all. Close is the system's own item — no row of ours to add. + // Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Share…, Reveal in Finder, + // Add Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate + // against the frontmost board through the focus system, so each is simply absent-of-effect + // when no board is in front; New Board… and Open Recent are available everywhere, including + // 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) { BoardCreationCommands() NewBoardCommand(appModel: appModel) @@ -212,6 +216,7 @@ struct KanbanApp: App { DuplicateBoardCommand(appModel: appModel) SaveAsTemplateCommand(appModel: appModel) + ShareBoardCommand(appModel: appModel) RevealInFinderCommand() AddAttachmentCommand() AddCommentCommand() diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index ebb6f17..bc8f274 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -917,6 +917,12 @@ public final class BannerCenter { // 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. 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): "Couldn't import '\(filename)'" case .listAttachments: diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 3919b79..4ab1201 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -3010,6 +3010,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// `title` is the board's display name. 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 listAttachments @@ -3246,6 +3254,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .rename: .rename(title: title) case .duplicateBoard: .duplicateBoard(title: title) case .saveAsTemplate: .saveAsTemplate(title: title) + case .shareBoard: .shareBoard(title: title) case .toggleTask: .toggleTask(title: title) case .editBody: .editBody(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 // plain container reason its board-level twin does. 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, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground, @@ -3326,6 +3335,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .rename(title): Self.phrase("rename", title) case let .duplicateBoard(title): Self.phrase("duplicate board", 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 .listAttachments: "list attachments" case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash" diff --git a/KanbanTests/BoardShareStagerTests.swift b/KanbanTests/BoardShareStagerTests.swift new file mode 100644 index 0000000..b639d1f --- /dev/null +++ b/KanbanTests/BoardShareStagerTests.swift @@ -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**, `".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 { + var paths: Set = [] + 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)) + } +} diff --git a/KanbanTests/DittoZipArchiverTests.swift b/KanbanTests/DittoZipArchiverTests.swift new file mode 100644 index 0000000..c74b71c --- /dev/null +++ b/KanbanTests/DittoZipArchiverTests.swift @@ -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)) + } +}