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