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:
@@ -32,6 +32,9 @@ public final class AppModel {
|
|||||||
public private(set) var isSending: Bool = false
|
public private(set) var isSending: Bool = false
|
||||||
public private(set) var outboxDisplayName: String
|
public private(set) var outboxDisplayName: String
|
||||||
public private(set) var watchFolderDisplayName: 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.
|
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
||||||
public private(set) var outboxURL: URL
|
public private(set) var outboxURL: URL
|
||||||
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
||||||
@@ -81,12 +84,15 @@ public final class AppModel {
|
|||||||
)
|
)
|
||||||
self.region = Self.loadPersistedRegion()
|
self.region = Self.loadPersistedRegion()
|
||||||
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
||||||
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths — never .standard().
|
// Seeded from TransportSettings.effectiveFolders() — the one place that combines
|
||||||
let folders = FolderSettings.resolve()
|
// the transport choice with FolderSettings/OneDriveLocator. Never call
|
||||||
|
// FolderSettings.resolve() directly outside that function.
|
||||||
|
let folders = TransportSettings.effectiveFolders()
|
||||||
self.outboxURL = folders.outbox
|
self.outboxURL = folders.outbox
|
||||||
self.watchFolderURL = folders.watch
|
self.watchFolderURL = folders.watch
|
||||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||||
|
self.transport = folders.transport
|
||||||
self.captureHotkey = HotkeyPreference.load()
|
self.captureHotkey = HotkeyPreference.load()
|
||||||
self.updateChecker = UpdateChecker()
|
self.updateChecker = UpdateChecker()
|
||||||
self.updateChecker.onChecked = { [weak self] in
|
self.updateChecker.onChecked = { [weak self] in
|
||||||
@@ -118,6 +124,7 @@ public final class AppModel {
|
|||||||
watchFolderURL = watch
|
watchFolderURL = watch
|
||||||
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
||||||
}
|
}
|
||||||
|
func setTransport(_ value: SendTransport) { transport = value }
|
||||||
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
||||||
|
|
||||||
/// True when a last-composed PDF path is known this run, or the newest
|
/// 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.
|
// 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 {
|
do {
|
||||||
try await watcher.start { [weak self] _ in
|
try await watcher.start { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -208,6 +225,8 @@ public final class AppModel {
|
|||||||
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
|
||||||
if !skipSchedule {
|
if !skipSchedule {
|
||||||
updateChecker.startSchedule()
|
updateChecker.startSchedule()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ struct MenuBarView: View {
|
|||||||
model.setStatus("Send is not available in this build.")
|
model.setStatus("Send is not available in this build.")
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
actionLabel("Send…")
|
actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…")
|
||||||
}
|
}
|
||||||
.disabled(model.session.isEmpty || model.isSending)
|
.disabled(model.session.isEmpty || model.isSending)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ extension AppModel: SendCapable {
|
|||||||
guard !session.isEmpty, !isSending else { return }
|
guard !session.isEmpty, !isSending else { return }
|
||||||
setSending(true)
|
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
|
let pending: ComposedSend
|
||||||
do {
|
do {
|
||||||
pending = try await composePDFForSend()
|
pending = try await composePDFForSend()
|
||||||
@@ -27,32 +52,51 @@ extension AppModel: SendCapable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let anchor else {
|
switch transport {
|
||||||
handleDidFailToShareItems(fileName: pending.fileName)
|
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)
|
setSending(false)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
case .airDrop:
|
||||||
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
|
guard let anchor else {
|
||||||
guard let self else { return }
|
handleDidFailToShareItems(fileName: pending.fileName)
|
||||||
if success {
|
setSending(false)
|
||||||
await self.handleDidShareItems(
|
return
|
||||||
fileName: pending.fileName,
|
}
|
||||||
pageCount: pending.pageCount
|
|
||||||
)
|
do {
|
||||||
} else {
|
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
|
||||||
self.handleDidFailToShareItems(fileName: pending.fileName)
|
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
|
/// 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.
|
/// and does not present AirDrop — that happens only after the share completes.
|
||||||
func composePDFForSend() async throws -> ComposedSend {
|
func composePDFForSend() async throws -> ComposedSend {
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ struct SettingsView: View {
|
|||||||
.frame(minHeight: 22)
|
.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 {
|
GridRow {
|
||||||
Text("Folders")
|
Text("Folders")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -41,17 +60,48 @@ struct SettingsView: View {
|
|||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
GridRow(alignment: .center) {
|
if model.transport == .airDrop {
|
||||||
fieldLabel("Watch folder")
|
GridRow(alignment: .center) {
|
||||||
folderValue(path: model.watchFolderURL.path) {
|
fieldLabel("Watch folder")
|
||||||
model.chooseWatchFolder()
|
folderValue(path: model.watchFolderURL.path) {
|
||||||
|
model.chooseWatchFolder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
GridRow(alignment: .center) {
|
GridRow(alignment: .center) {
|
||||||
fieldLabel("Output folder")
|
fieldLabel("Output folder")
|
||||||
folderValue(path: model.outboxURL.path) {
|
folderValue(path: model.outboxURL.path) {
|
||||||
model.chooseOutboxFolder()
|
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() }
|
.onDisappear { disarmHotkeyRecorder() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var transportBinding: Binding<SendTransport> {
|
||||||
|
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() {
|
private func armHotkeyRecorder() {
|
||||||
guard !isRecordingHotkey else { return }
|
guard !isRecordingHotkey else { return }
|
||||||
isRecordingHotkey = true
|
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? {
|
private func chooseDirectory(startingAt directory: URL) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseDirectories = true
|
panel.canChooseDirectories = true
|
||||||
|
|||||||
Reference in New Issue
Block a user