From 6449c72b3b19a4da5ac5b4b20526e5a76f52c918 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:01:13 +0400 Subject: [PATCH] feature: OneDrive folder as a second send transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- Sources/Shotdeck/AppModel.swift | 23 ++++- Sources/Shotdeck/MenuBarView.swift | 2 +- Sources/Shotdeck/SendController.swift | 82 +++++++++++++---- Sources/Shotdeck/SettingsView.swift | 126 ++++++++++++++++++++++++-- 4 files changed, 202 insertions(+), 31 deletions(-) diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 47897ad..d3c2675 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -32,6 +32,9 @@ public final class AppModel { public private(set) var isSending: Bool = false public private(set) var outboxDisplayName: String public private(set) var watchFolderDisplayName: String + /// Live transport choice; WP-onedrive reads this to pick the send path and to drive + /// the Settings "Send via" picker and the menu's "Send…" label. + public private(set) var transport: SendTransport /// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect. public private(set) var outboxURL: URL /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. @@ -81,12 +84,15 @@ public final class AppModel { ) self.region = Self.loadPersistedRegion() self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted - // Seeded from FolderSettings.resolve() via resolvedAppSupportPaths — never .standard(). - let folders = FolderSettings.resolve() + // Seeded from TransportSettings.effectiveFolders() — the one place that combines + // the transport choice with FolderSettings/OneDriveLocator. Never call + // FolderSettings.resolve() directly outside that function. + let folders = TransportSettings.effectiveFolders() self.outboxURL = folders.outbox self.watchFolderURL = folders.watch self.outboxDisplayName = folders.outbox.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent + self.transport = folders.transport self.captureHotkey = HotkeyPreference.load() self.updateChecker = UpdateChecker() self.updateChecker.onChecked = { [weak self] in @@ -118,6 +124,7 @@ public final class AppModel { watchFolderURL = watch setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) } + func setTransport(_ value: SendTransport) { transport = value } func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } /// True when a last-composed PDF path is known this run, or the newest @@ -185,6 +192,16 @@ public final class AppModel { // Empty ledger on first run is not an error. } + // OneDrive mode: outbox == watch folder, so a freshly written, unmarked PDF must + // never show up as a return; only a document that already carries a mark does. + // Also make sure the resolved OneDrive folder actually exists before the + // watcher starts watching it (bootstrap is the other creation trigger besides + // chooseTransport/chooseOneDriveFolder — see TransportSettings.effectiveFolders). + if transport == .oneDrive { + try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true) + } + await watcher.setRecordUncommented(transport == .airDrop) + do { try await watcher.start { [weak self] _ in Task { @MainActor in @@ -208,6 +225,8 @@ public final class AppModel { ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil + || ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil + || ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil if !skipSchedule { updateChecker.startSchedule() } diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 5b239cb..cde9147 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -91,7 +91,7 @@ struct MenuBarView: View { model.setStatus("Send is not available in this build.") } } label: { - actionLabel("Send…") + actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…") } .disabled(model.session.isEmpty || model.isSending) diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index d4d818a..6dd9353 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -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 { diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index 4f33f99..af7a39d 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -33,6 +33,25 @@ struct SettingsView: View { .frame(minHeight: 22) } + GridRow { + Text("Send via") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .gridCellColumns(2) + .padding(.top, 6) + } + + GridRow { + Picker("Send via", selection: transportBinding) { + ForEach(SendTransport.allCases, id: \.self) { transport in + Text(transport.displayName).tag(transport) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .gridCellColumns(2) + } + GridRow { Text("Folders") .font(.headline) @@ -41,17 +60,48 @@ struct SettingsView: View { .padding(.top, 6) } - GridRow(alignment: .center) { - fieldLabel("Watch folder") - folderValue(path: model.watchFolderURL.path) { - model.chooseWatchFolder() + if model.transport == .airDrop { + GridRow(alignment: .center) { + fieldLabel("Watch folder") + folderValue(path: model.watchFolderURL.path) { + model.chooseWatchFolder() + } } - } - GridRow(alignment: .center) { - fieldLabel("Output folder") - folderValue(path: model.outboxURL.path) { - model.chooseOutboxFolder() + GridRow(alignment: .center) { + fieldLabel("Output folder") + folderValue(path: model.outboxURL.path) { + model.chooseOutboxFolder() + } + } + } else { + GridRow(alignment: .center) { + fieldLabel("OneDrive folder") + if let folder = resolvedOneDriveFolder { + folderValue(path: folder.path) { + model.chooseOneDriveFolder() + } + } else { + HStack(spacing: 8) { + Text("No OneDrive folder found — sign in to OneDrive or choose a folder.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Choose…") { model.chooseOneDriveFolder() } + } + .frame(minHeight: 22) + } + } + + GridRow { + 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." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .gridCellColumns(2) } } @@ -68,6 +118,17 @@ struct SettingsView: View { .onDisappear { disarmHotkeyRecorder() } } + private var transportBinding: Binding { + Binding(get: { model.transport }, set: { model.chooseTransport($0) }) + } + + /// Ground truth from OneDriveLocator, not `model.outboxURL` — the model may be + /// showing an AirDrop-folder fallback when no real OneDrive folder resolves, and + /// the Settings row must say so plainly rather than repeat that fallback path. + private var resolvedOneDriveFolder: URL? { + OneDriveLocator.resolveOneDriveFolder() + } + private func armHotkeyRecorder() { guard !isRecordingHotkey else { return } isRecordingHotkey = true @@ -190,6 +251,53 @@ extension AppModel: SettingsWindowPresenting { } } + /// Settings "Send via" picker action. Persists the choice, recomputes the effective + /// outbox/watch folder for the new transport, creates the OneDrive folder if it + /// 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 + /// untouched, since AirDrop and OneDrive folder settings are stored under separate keys. + func chooseTransport(_ value: SendTransport) { + guard value != transport else { return } + TransportSettings.setTransport(value) + setTransport(value) + let folders = TransportSettings.effectiveFolders() + if value == .oneDrive { + try? FileManager.default.createDirectory( + at: folders.outbox, withIntermediateDirectories: true + ) + } + setFolderURLs(outbox: folders.outbox, watch: folders.watch) + Task { + await watcher.setRecordUncommented(value == .airDrop) + do { + try await watcher.updateWatchFolder(folders.watch) + } catch { + setStatus( + (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." + ) + } + } + } + + func chooseOneDriveFolder() { + let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser + guard let url = chooseDirectory(startingAt: start) else { return } + TransportSettings.setOneDriveFolder(url) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + guard transport == .oneDrive else { return } + setFolderURLs(outbox: url, watch: url) + Task { + do { + try await watcher.updateWatchFolder(url) + setStatus("OneDrive folder set to \(url.lastPathComponent).") + } catch { + setStatus( + (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." + ) + } + } + } + private func chooseDirectory(startingAt directory: URL) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true