feature: OneDrive folder as a second send transport

send(anchor:) now branches on TransportSettings.transport(). OneDrive mode
skips AirDrop entirely: it verifies the resolved OneDrive folder exists right
before composing (never trusts stale state), archives the session immediately
after the PDF lands, and sets "Saved to OneDrive — N page(s). Open it in Files
on your iPad." AirDrop's existing behaviour, including handleDidFailToShareItems,
is untouched and only reached from the .airDrop branch.

AppModel seeds outbox/watch from TransportSettings.effectiveFolders() instead
of FolderSettings.resolve() directly, tracks the live `transport`, and
bootstrap() creates the OneDrive folder and sets the watcher's
recordUncommented flag (true only for AirDrop) before the watcher starts.

Settings gets a "Send via" segmented picker above Folders. AirDrop shows the
existing watch/output rows; OneDrive shows a single read-only OneDrive folder
row (Choose... reuses the existing directory picker) plus one caption
explaining the same-folder round trip, or a "No OneDrive folder found" prompt
when nothing resolves (Choose... stays usable). Switching transport re-points
the watcher's folder and recordUncommended live; AirDrop's own folder
overrides are stored separately and are untouched by a OneDrive-and-back
round trip.

Menu's "Send..." row reads "Send to OneDrive" when that transport is active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
2026-09-05 09:01:13 +04:00
co-authored by Claude Fable 5.1
parent 299d55e884
commit 6449c72b3b
4 changed files with 202 additions and 31 deletions
+63 -19
View File
@@ -16,6 +16,31 @@ extension AppModel: SendCapable {
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()
@@ -27,32 +52,51 @@ extension AppModel: SendCapable {
return
}
guard let anchor else {
handleDidFailToShareItems(fileName: pending.fileName)
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)
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)
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)
}
self.setSending(false)
} catch {
// canPerform false, no service, or no visible window: same as cancel.
handleDidFailToShareItems(fileName: pending.fileName)
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 {