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) // Wait for any IN-FLIGHT transport/folder reconcile (chooseTransport/ // chooseOneDriveFolder in SettingsView.swift) to fully settle BEFORE // snapshotting transport/folder below. Without this, a toggle immediately // followed by Send could let send() read a state that is still mid-transition // — e.g. the watcher's recordUncommented flag briefly lagging the just-chosen // transport, so a freshly-sent unmarked OneDrive PDF gets misreported as an // already-returned document. `Task.value` never throws, and // awaiting nil is an immediate no-op (AirDrop mode, or no toggle in flight). await pendingReconcileTask?.value // Snapshot BOTH the transport AND the destination folder into local `let`s // ONCE, before any further `await` in this function. chooseTransport/ // chooseOneDriveFolder also refuse outright (status "Finish the current send // first.") while isSending is true, but this snapshot is the actual fix for the // send-vs-switch race: even without that guard, everything below operates on // these frozen values — composePDFForSend(outbox:transport:) takes both as // parameters and never re-reads `self.outboxURL`/`self.transport` after a // suspension point, so a concurrent transport switch mid-send can no longer // land the PDF under one transport's folder while the archive/status branch // runs the other's. let transport = TransportSettings.transport() let destinationFolder: URL // OneDrive mode: verify the real destination exists, is writable, AND actually // accepts a real write RIGHT NOW, before composing anything. `isWritableDirectory` // alone is not enough — a OneDrive Files-On-Demand directory whose provider // domain is signed out can report as existing and POSIX-writable while an // actual write fails, so `probeWritable` writes-fsyncs-removes a tiny real probe // file to catch that. `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 or lost // its permissions since then (OneDrive signed out, external volume unmounted, // folder deleted, chmod'd unwritable) is caught instead of silently attempted // and surfacing as a generic PDF-composition failure. if transport == .oneDrive { guard let folder = OneDriveLocator.resolveOneDriveFolder(), OneDriveLocator.isWritableDirectory(at: folder), OneDriveLocator.probeWritable(at: folder) else { let path = OneDriveLocator.resolveOneDriveFolder()?.path ?? TransportSettings.storedOneDriveFolderPath() ?? "no OneDrive folder found" setResolvedOneDriveFolder(nil) setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription) setSending(false) return } destinationFolder = folder setResolvedOneDriveFolder(folder) if outboxURL != folder || watchFolderURL != folder { setFolderURLs(outbox: folder, watch: folder) try? await watcher.updateWatchFolder(folder) } } else { destinationFolder = outboxURL } let pending: ComposedSend do { pending = try await composePDFForSend(outbox: destinationFolder, transport: transport) } 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) } } } /// Writes the PDF to `outboxDir` and records its path. Does not archive the session /// and does not present AirDrop — that happens only after the share completes. /// `outboxDir`/`transport` are passed in (values `send(anchor:)` snapshotted before /// any await) rather than read from `self.outboxURL`/`self.transport` here, so a /// concurrent transport switch mid-send can never redirect an in-flight compose to /// a different folder. A write/rename failure specifically at the destination /// folder (as opposed to composer.compose()'s own session/image-content failures) /// is reported as `oneDriveFolderUnavailable` rather than the generic /// `pdfCompositionFailed` when `transport == .oneDrive` — the File Provider edge /// case where the folder looked writable moments ago in `send(anchor:)` but the /// actual write still failed (e.g. OneDrive signed out mid-write). func composePDFForSend(outbox outboxDir: URL, transport: SendTransport) async throws -> ComposedSend { let workingSession = session let composer = self.composer 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 { if transport == .oneDrive { throw ShotdeckError.oneDriveFolderUnavailable(path: outboxDir.path) } throw ShotdeckError.pdfCompositionFailed( reason: "could not publish the PDF: \(String(cString: strerror(errno)))" ) } do { try AtomicFile.fsyncDirectory(at: outboxDir) } catch { if transport == .oneDrive { throw ShotdeckError.oneDriveFolderUnavailable(path: outboxDir.path) } throw error } }.value guard FileManager.default.fileExists(atPath: finalURL.path) else { if transport == .oneDrive { throw ShotdeckError.oneDriveFolderUnavailable(path: outboxDir.path) } 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)." ) } }