diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index 99f0602..b033291 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -16,28 +16,44 @@ extension AppModel: SendCapable { 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 `await` in this function. chooseTransport/chooseOneDriveFolder - // now refuse (status "Finish the current send first.") while isSending is true, - // but this snapshot is the actual fix for the race: even without that guard, - // everything below operates on these frozen values — composePDFForSend(outbox:) - // takes the folder as a parameter and never re-reads `self.outboxURL` 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 (which - // switches on the same frozen `transport` local) runs the other'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 AND is writable RIGHT NOW, - // before composing anything. `outboxURL` is kept in sync with the resolved - // OneDrive folder by bootstrap/chooseTransport/chooseOneDriveFolder, but this is + // 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.isWritableDirectory(at: folder), + OneDriveLocator.probeWritable(at: folder) else { let path = OneDriveLocator.resolveOneDriveFolder()?.path ?? TransportSettings.storedOneDriveFolderPath() @@ -59,7 +75,7 @@ extension AppModel: SendCapable { let pending: ComposedSend do { - pending = try await composePDFForSend(outbox: destinationFolder) + 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. @@ -109,10 +125,16 @@ extension AppModel: SendCapable { /// 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` is passed in (a value `send(anchor:)` snapshotted before any await) - /// rather than read from `self.outboxURL` here, so a concurrent transport switch - /// mid-send can never redirect an in-flight compose to a different folder. - func composePDFForSend(outbox outboxDir: URL) async throws -> ComposedSend { + /// `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) @@ -134,14 +156,27 @@ extension AppModel: SendCapable { // 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)))" ) } - try AtomicFile.fsyncDirectory(at: outboxDir) + 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)