send()'s OneDrive pre-flight check now also calls OneDriveLocator.probeWritable(at:) alongside isWritableDirectory — closing the File Provider edge case where a signed-out OneDrive domain reports its folder as existing and writable while a real write fails. composePDFForSend(outbox:transport:) now takes the frozen transport too (not just the folder): a write/rename failure specifically at the destination folder — Darwin.rename, AtomicFile.fsyncDirectory, or the post-write existence check — is reported as ShotdeckError.oneDriveFolderUnavailable instead of the generic pdfCompositionFailed when transport is .oneDrive. composer.compose()'s own session/image-content failures are left as generic pdfCompositionFailed regardless of transport — those aren't about the destination folder. Also: send() now `await`s `pendingReconcileTask` (AppModel.swift, set by chooseTransport/chooseOneDriveFolder in SettingsView.swift) before snapshotting transport/folder, closing the toggle-then-immediate-send race — without this, a Send issued right after a transport toggle could run before the watcher's recordUncommented flag finished catching up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
211 lines
10 KiB
Swift
211 lines
10 KiB
Swift
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<Void, Never>.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)."
|
||
)
|
||
}
|
||
}
|