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
Two issues: (1) directoryExists(at:) only checked existence + isDirectory, so
an existing-but-unwritable OneDrive folder skipped the
oneDriveFolderUnavailable branch and surfaced as a generic pdfCompositionFailed
message instead — now uses OneDriveLocator.isWritableDirectory(at:). (2)
send() read `self.outboxURL` again inside composePDFForSend() after at least
one await had already run, so a concurrent chooseTransport() call
(SettingsView.swift) could flip transport/outboxURL/recordUncommented
mid-send, landing the PDF under one transport's folder while the
archive/status branch ran the other's.
Fix: send() now snapshots BOTH transport and the destination folder into
local `let`s once, before any await, and passes the folder explicitly into
the renamed composePDFForSend(outbox:) — which no longer reads
self.outboxURL at all. The archive/status switch already used the frozen
`transport` local. (chooseTransport/chooseOneDriveFolder additionally refuse
outright while isSending is true — see the SettingsView.swift commit — so in
practice this race can no longer even be triggered, but the snapshot is the
actual structural fix regardless.)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
SettingsView's OneDrive row called OneDriveLocator.resolveOneDriveFolder()
directly with the real UserDefaults.standard and the real home directory,
which made that row impossible to drive from a fake/isolated environment.
Moved that resolution into AppModel as a tracked resolvedOneDriveFolder
property (nil means "no OneDrive folder found"), refreshed at init,
bootstrap, chooseTransport, chooseOneDriveFolder, and inside send()'s live
folder check. SettingsView and chooseOneDriveFolder's picker-start path now
read model.resolvedOneDriveFolder instead of calling OneDriveLocator
directly — state flows through the model like everything else in this app.
PanelSnapshot (SHOTDECK_SNAPSHOT_DIR) adds three panels on a SEPARATE
isolated model so the transport switch never bleeds into the six existing
AirDrop-mode panels:
- panel-07-settings-onedrive.png: transport=oneDrive with a resolved folder,
built by pointing OneDriveLocator.defaultRedlineFolder at a fake home tree
(Library/CloudStorage/OneDrive-MMDGROUP under this snapshot's own temp
root) so the displayed path is shaped like the real default without ever
touching the real home.
- panel-08-settings-onedrive-missing.png: a fake home with no
Library/CloudStorage at all, resolved through a throwaway UserDefaults
suite (never .standard) so the "no OneDrive folder found" state and its
still-usable Choose... button are exercised for real.
- panel-09-captures-present-onedrive.png: 3 captures + transport=oneDrive,
confirming the menu row reads "Send to OneDrive".
Extracted the 3-swatch capture seeding (panel 04) into addSampleCaptures(to:)
so panel 09 reuses it instead of duplicating the loop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
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