Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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() {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user