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
4 changed files with 202 additions and 31 deletions
Showing only changes of commit 6449c72b3b - Show all commits
+21 -2
View File
@@ -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()
}
+1 -1
View File
@@ -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)
+63 -19
View File
@@ -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 {
+117 -9
View File
@@ -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