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
136 lines
6.8 KiB
Swift
136 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|