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