import AppKit import Darwin import Foundation import ShotdeckCore /// Result of composing a send PDF. Kept so the self-test can drive the share /// outcome without presenting a real AirDrop sheet. struct ComposedSend: Sendable { let fileName: String let fileURL: URL let pageCount: Int } extension AppModel: SendCapable { public func send(anchor: NSView?) async { guard !session.isEmpty, !isSending else { return } setSending(true) let pending: ComposedSend do { pending = try await composePDFForSend() } catch { // Never unlink the published PDF, and never unlink the temp file either: // a rename failure would leave the complete document at the temp name. setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") setSending(false) return } guard let anchor else { handleDidFailToShareItems(fileName: pending.fileName) setSending(false) return } do { try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in guard let self else { return } if success { await self.handleDidShareItems( fileName: pending.fileName, pageCount: pending.pageCount ) } else { self.handleDidFailToShareItems(fileName: pending.fileName) } self.setSending(false) } } catch { // canPerform false, no service, or no visible window: same as cancel. handleDidFailToShareItems(fileName: pending.fileName) setSending(false) } } /// Writes the PDF to the outbox and records its path. Does not archive the session /// and does not present AirDrop — that happens only after the share completes. func composePDFForSend() async throws -> ComposedSend { 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 = "Redline – \(DubaiTime.stamp(workingSession.createdAt))" // 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 guard FileManager.default.fileExists(atPath: finalURL.path) else { throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") } rememberLastComposedPDF(finalURL) return ComposedSend( fileName: fileName, fileURL: finalURL, pageCount: workingSession.captures.count ) } /// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam. func handleDidShareItems(fileName: String, pageCount: Int) async { guard !session.isEmpty else { return } do { _ = try await spool.archiveCurrent(pdfFileName: fileName) replaceSession(try await spool.currentSession()) let pageWord = pageCount == 1 ? "page" : "pages" setStatus("Sent — \(pageCount) \(pageWord).") } catch { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.") } } /// `NSSharingServiceDelegate.sharingService(_:didFailToShareItems:error:)` seam, /// also used when `canPerform` is false or the user cancels. Does not archive. func handleDidFailToShareItems(fileName: String) { setStatus( "AirDrop didn't complete — nothing was sent. Your captures are still here; the PDF is on your \(outboxDisplayName) as \(fileName)." ) } }