From 9201e74bfc80408a222910e4aff0223cb190e80f Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 16:12:58 +0400 Subject: [PATCH] WP-4b: Send pipeline + AirDrop sheet per SPEC-A2b --- Sources/Shotdeck/SendController.swift | 71 +++++++++++++++++++++++ Sources/Shotdeck/Sharing.swift | 83 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 Sources/Shotdeck/SendController.swift create mode 100644 Sources/Shotdeck/Sharing.swift diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift new file mode 100644 index 0000000..5934f05 --- /dev/null +++ b/Sources/Shotdeck/SendController.swift @@ -0,0 +1,71 @@ +import AppKit +import Darwin +import Foundation +import ShotdeckCore + +extension AppModel: SendCapable { + public func send(anchor: NSView?) async { + guard !session.isEmpty, !isSending else { return } + setSending(true) + defer { setSending(false) } + + let workingSession = session + let composer = self.composer + // Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect. + let outboxDir = outboxURL + let sourceDir = paths.sessionDirectory(workingSession.id) + let fileName = PDFComposer.fileName(for: workingSession) + let finalURL = outboxDir.appendingPathComponent(fileName) + // Same directory as the final target so the rename below is same-volume (atomic). + let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf") + let title = "Shotdeck – \(DubaiTime.stamp(workingSession.createdAt))" + + do { + // D-13: build off the main actor. Only Sendable values cross into the + // detached task — never `anchor` (NSView is not Sendable). + try await Task.detached(priority: .userInitiated) { + _ = try composer.compose( + session: workingSession, + imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, + title: title, + to: tempURL + ) + // POSIX rename onto `finalURL` replaces any same-name file in one + // directory operation; there is never a window where the PDF is gone. + if Darwin.rename(tempURL.path, finalURL.path) != 0 { + throw ShotdeckError.pdfCompositionFailed( + reason: "could not publish the PDF: \(String(cString: strerror(errno)))" + ) + } + try AtomicFile.fsyncDirectory(at: outboxDir) + }.value + + // File exists on disk now — archive only after that (D-13). A later AirDrop + // failure never deletes this file. + guard FileManager.default.fileExists(atPath: finalURL.path) else { + throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") + } + _ = try await spool.archiveCurrent(pdfFileName: fileName) + replaceSession(try await spool.currentSession()) + + let pageWord = workingSession.captures.count == 1 ? "page" : "pages" + setStatus("Sent — \(workingSession.captures.count) \(pageWord).") + + guard let anchor else { + setStatus("PDF saved to \(outboxDisplayName). Open the panel to AirDrop it.") + return + } + do { + try Sharing.airDrop(fileURL: finalURL, from: anchor) + } catch { + setStatus( + "AirDrop is not available right now — the PDF is on your \(outboxDisplayName)." + ) + } + } catch { + // Never unlink the published PDF, and never unlink `tempURL` either: + // a rename failure would leave the complete document at the temp name. + setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + } + } +} diff --git a/Sources/Shotdeck/Sharing.swift b/Sources/Shotdeck/Sharing.swift new file mode 100644 index 0000000..5b9741a --- /dev/null +++ b/Sources/Shotdeck/Sharing.swift @@ -0,0 +1,83 @@ +import AppKit +import ShotdeckCore + +@MainActor +enum Sharing { + /// Presents the AirDrop picker for `fileURL`, anchored to `view`. + /// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created, + /// `canPerform` is false, or `view` is not in a visible window (a detached view + /// never produces an on-screen sheet). + static func airDrop(fileURL: URL, from view: NSView) throws { + guard let service = NSSharingService(named: .sendViaAirDrop), + service.canPerform(withItems: [fileURL]) else { + throw ShotdeckError.airDropUnavailable + } + // Presenting from a detached NSView (no window) yields a sheet that never appears. + guard let window = view.window, window.isVisible else { + throw ShotdeckError.airDropUnavailable + } + + NSApp.activate() + window.makeKeyAndOrderFront(nil) + + service.subject = fileURL.lastPathComponent + let session = AirDropSession(service: service, window: window, view: view) + AirDropSession.keepAlive(session) + service.delegate = session + service.perform(withItems: [fileURL]) + } +} + +/// Retains the sharing service for the life of the picker and supplies the real +/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak. +@MainActor +private final class AirDropSession: NSObject, NSSharingServiceDelegate { + static var live: [AirDropSession] = [] + + let service: NSSharingService + let window: NSWindow + let view: NSView + + init(service: NSSharingService, window: NSWindow, view: NSView) { + self.service = service + self.window = window + self.view = view + } + + static func keepAlive(_ session: AirDropSession) { + live.append(session) + } + + private func drop() { + Self.live.removeAll { $0 === self } + } + + func sharingService( + _ sharingService: NSSharingService, + sourceWindowForShareItems items: [Any], + sharingContentScope: UnsafeMutablePointer + ) -> NSWindow? { + sharingContentScope.pointee = .item + return window + } + + func sharingService( + _ sharingService: NSSharingService, + sourceFrameOnScreenForShareItem item: Any + ) -> NSRect { + let inWindow = view.convert(view.bounds, to: nil) + return window.convertToScreen(inWindow) + } + + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + drop() + } + + func sharingService( + _ sharingService: NSSharingService, + didFailToShareItems items: [Any], + error: any Error + ) { + drop() + } +} -- 2.54.0