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 transport = TransportSettings.transport() // OneDrive mode: verify the real destination exists RIGHT NOW, before composing // anything. `outboxURL` is kept in sync with the resolved OneDrive folder by // bootstrap/chooseTransport/chooseOneDriveFolder, but this is re-resolved fresh // here (never trusted stale) so a folder that vanished since then (OneDrive // signed out, external volume unmounted, folder deleted) is caught instead of // silently writing into whatever `outboxURL` happens to hold. if transport == .oneDrive { guard let folder = OneDriveLocator.resolveOneDriveFolder(), Self.directoryExists(at: folder) else { let path = OneDriveLocator.resolveOneDriveFolder()?.path ?? TransportSettings.storedOneDriveFolderPath() ?? "no OneDrive folder found" setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription) setSending(false) return } if outboxURL != folder || watchFolderURL != folder { setFolderURLs(outbox: folder, watch: folder) try? await watcher.updateWatchFolder(folder) } } 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 } switch transport { case .oneDrive: // No AirDrop, no anchor needed — the PDF is already in the watched // OneDrive folder. Archive immediately; the iPad marks it up in place. await handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount) let pageWord = pending.pageCount == 1 ? "page" : "pages" setStatus( "Saved to OneDrive — \(pending.pageCount) \(pageWord). Open it in Files on your iPad." ) setSending(false) case .airDrop: 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) } } } private static func directoryExists(at url: URL) -> Bool { var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) return exists && isDirectory.boolValue } /// 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)." ) } }