Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23

Merged
kua-agent merged 20 commits from feat/redline-onedrive-transport-20260905 into main 2026-09-05 06:17:29 +00:00
Showing only changes of commit 6484fde530 - Show all commits
+37 -9
View File
@@ -82,21 +82,20 @@ struct SettingsView: View {
model.chooseOneDriveFolder() model.chooseOneDriveFolder()
} }
} else { } else {
HStack(spacing: 8) { // One-line row, same shape as the normal path row: "Not found"
Text("No OneDrive folder found — sign in to OneDrive or choose a folder.") // where the path would be, Choose stays live. The explanation
.font(.caption) // moves to the caption below instead of wrapping this row.
.foregroundStyle(.secondary) folderValue(path: "Not found") {
.fixedSize(horizontal: false, vertical: true) model.chooseOneDriveFolder()
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { model.chooseOneDriveFolder() }
} }
.frame(minHeight: 22)
} }
} }
GridRow { GridRow {
Text( Text(
"The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive." model.resolvedOneDriveFolder != nil
? "The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive."
: "No OneDrive folder found. Sign in to OneDrive, or choose a folder."
) )
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -249,7 +248,18 @@ extension AppModel: SettingsWindowPresenting {
/// doesn't exist yet, and re-points the running watcher (folder + recordUncommented) /// doesn't exist yet, and re-points the running watcher (folder + recordUncommented)
/// at the new state. Switching back to AirDrop restores its own stored overrides /// at the new state. Switching back to AirDrop restores its own stored overrides
/// untouched, since AirDrop and OneDrive folder settings are stored under separate keys. /// untouched, since AirDrop and OneDrive folder settings are stored under separate keys.
/// Refuses while a send is in flight (send() snapshots its own folder/transport, but
/// switching mid-send is still confusing UX nothing to gain by allowing it).
/// The async reconcile below is generation-guarded: `reconcileGeneration` is bumped
/// synchronously before the Task starts, and the Task checks its own snapshot against
/// the live value before every mutating step, so rapid toggling (this function or
/// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an
/// earlier, superseded call applying its stale folder/flag after a later one already won.
func chooseTransport(_ value: SendTransport) { func chooseTransport(_ value: SendTransport) {
guard !isSending else {
setStatus("Finish the current send first.")
return
}
guard value != transport else { return } guard value != transport else { return }
TransportSettings.setTransport(value) TransportSettings.setTransport(value)
setTransport(value) setTransport(value)
@@ -261,11 +271,17 @@ extension AppModel: SettingsWindowPresenting {
} }
setFolderURLs(outbox: folders.outbox, watch: folders.watch) setFolderURLs(outbox: folders.outbox, watch: folders.watch)
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
reconcileGeneration += 1
let generation = reconcileGeneration
Task { Task {
guard generation == self.reconcileGeneration else { return }
await watcher.setRecordUncommented(value == .airDrop) await watcher.setRecordUncommented(value == .airDrop)
guard generation == self.reconcileGeneration else { return }
do { do {
try await watcher.updateWatchFolder(folders.watch) try await watcher.updateWatchFolder(folders.watch)
} catch { } catch {
guard generation == self.reconcileGeneration else { return }
setStatus( setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
) )
@@ -273,7 +289,13 @@ extension AppModel: SettingsWindowPresenting {
} }
} }
/// Refuses while a send is in flight, same reasoning as chooseTransport. See
/// chooseTransport's doc comment for the generation-guard mechanism shared here.
func chooseOneDriveFolder() { func chooseOneDriveFolder() {
guard !isSending else {
setStatus("Finish the current send first.")
return
}
let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser
guard let url = chooseDirectory(startingAt: start) else { return } guard let url = chooseDirectory(startingAt: start) else { return }
TransportSettings.setOneDriveFolder(url) TransportSettings.setOneDriveFolder(url)
@@ -281,11 +303,17 @@ extension AppModel: SettingsWindowPresenting {
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
guard transport == .oneDrive else { return } guard transport == .oneDrive else { return }
setFolderURLs(outbox: url, watch: url) setFolderURLs(outbox: url, watch: url)
reconcileGeneration += 1
let generation = reconcileGeneration
Task { Task {
guard generation == self.reconcileGeneration else { return }
do { do {
try await watcher.updateWatchFolder(url) try await watcher.updateWatchFolder(url)
guard generation == self.reconcileGeneration else { return }
setStatus("OneDrive folder set to \(url.lastPathComponent).") setStatus("OneDrive folder set to \(url.lastPathComponent).")
} catch { } catch {
guard generation == self.reconcileGeneration else { return }
setStatus( setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
) )