Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a128b667 | ||
|
|
78cc7d5b1a | ||
|
|
6449c72b3b | ||
|
|
299d55e884 | ||
|
|
380b704f8a |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import CoreGraphics
|
|||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import PDFKit
|
||||||
import ShotdeckCore
|
import ShotdeckCore
|
||||||
|
|
||||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||||
@@ -173,7 +174,7 @@ enum PickerSelfTest {
|
|||||||
try await executeSendTruth()
|
try await executeSendTruth()
|
||||||
print("SEND-TRUTH PASS")
|
print("SEND-TRUTH PASS")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
if !startUpdateSelfTestIfRequested() {
|
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
||||||
exit(0)
|
exit(0)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -315,7 +316,9 @@ enum PickerSelfTest {
|
|||||||
try await runUpdateSelfTest(outputDirectory: output)
|
try await runUpdateSelfTest(outputDirectory: output)
|
||||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
|
if !startOneDriveSelfTestIfRequested() {
|
||||||
exit(0)
|
exit(0)
|
||||||
|
}
|
||||||
} catch let error as UpdateSelfTestError {
|
} catch let error as UpdateSelfTestError {
|
||||||
updateFail(error.description)
|
updateFail(error.description)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -473,6 +476,194 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real sync root this Mac has; the phase proves the transport against the actual
|
||||||
|
/// OneDrive file provider, never a fake home tree (that is what
|
||||||
|
/// OneDriveLocatorTests in ShotdeckCoreTests are for).
|
||||||
|
private static let realOneDriveSyncRoot = URL(
|
||||||
|
fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Phase 5: proves the OneDrive transport end to end against the real sync root.
|
||||||
|
/// Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` when chained after PICKER/SEND-TRUTH/
|
||||||
|
/// UPDATE-SELFTEST — the exact pattern `startUpdateSelfTestIfRequested` uses for its
|
||||||
|
/// own env var. Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||||
|
@discardableResult
|
||||||
|
private static func startOneDriveSelfTestIfRequested() -> Bool {
|
||||||
|
guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runOneDriveSelfTestAndExit()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for running ONLY this phase, bypassing the on-screen picker chain
|
||||||
|
/// entirely. The harness has no other per-phase selector, so this is the escape
|
||||||
|
/// hatch: `REDLINE_SELFTEST_PHASE=onedrive`.
|
||||||
|
static func runOneDriveOnlyIfRequested() {
|
||||||
|
guard ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same hop as runIfRequested(): a plain main-queue turn after NSApp starts, so
|
||||||
|
// AppKit/PDFKit calls inside the phase are not racing app launch.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
runOneDriveSelfTestAndExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runOneDriveSelfTestAndExit() {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let folder = try await executeOneDriveSelfTest()
|
||||||
|
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(0)
|
||||||
|
} catch let error as OneDriveSelfTestError {
|
||||||
|
oneDriveFail(error.description)
|
||||||
|
} catch {
|
||||||
|
oneDriveFail(String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)`
|
||||||
|
/// against a NEW folder under the real OneDrive sync root, confirms the watcher does
|
||||||
|
/// NOT report the freshly-written unmarked PDF as a return, then adds a real PDFKit
|
||||||
|
/// ink annotation in place (what the iPad does) and confirms the watcher now reports
|
||||||
|
/// it as commented. Never deletes anything under OneDrive — the created folder and
|
||||||
|
/// PDF are left in place for Ben to inspect / for the real iPad round trip.
|
||||||
|
private static func executeOneDriveSelfTest() async throws -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else {
|
||||||
|
throw OneDriveSelfTestError.detail(
|
||||||
|
"real OneDrive sync root not found at \(realOneDriveSyncRoot.path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDefaults.standard is the ONLY defaults instance send()/TransportSettings
|
||||||
|
// actually read at runtime (there is no defaults-threading through AppModel), so
|
||||||
|
// "isolated" here means snapshot-and-restore around the real keys — the same
|
||||||
|
// pattern runRegionPersistPhase already uses for CaptureRegion.defaultsKey.
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
defer {
|
||||||
|
if let previousTransport {
|
||||||
|
defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
}
|
||||||
|
if let previousFolder {
|
||||||
|
defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stamp = DubaiTime.fileStamp(Date())
|
||||||
|
let selftestFolder = realOneDriveSyncRoot
|
||||||
|
.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true)
|
||||||
|
try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: defaults)
|
||||||
|
TransportSettings.setOneDriveFolder(selftestFolder, defaults: defaults)
|
||||||
|
|
||||||
|
// Local spool root only — the outbox/watch folder is the real OneDrive folder.
|
||||||
|
let spoolRoot = fm.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-onedrive-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? fm.removeItem(at: spoolRoot) }
|
||||||
|
|
||||||
|
let paths = try AppSupportPaths(root: spoolRoot, outbox: selftestFolder, watchFolder: selftestFolder)
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
await watcher.setRecordUncommented(false) // OneDrive mode: today's default is AirDrop's `true`.
|
||||||
|
|
||||||
|
let model = AppModel(
|
||||||
|
paths: paths,
|
||||||
|
spool: try SpoolStore(paths: paths),
|
||||||
|
composer: PDFComposer(),
|
||||||
|
capturer: ScreenCapturer(),
|
||||||
|
hotkeys: HotkeyCenter(),
|
||||||
|
picker: RegionPickerController(),
|
||||||
|
ledger: ledger,
|
||||||
|
watcher: watcher
|
||||||
|
)
|
||||||
|
model.setFolderURLs(outbox: selftestFolder, watch: selftestFolder)
|
||||||
|
model.setTransport(.oneDrive)
|
||||||
|
|
||||||
|
let png = try makeTinyPNGData()
|
||||||
|
_ = try await model.spool.append(
|
||||||
|
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date()
|
||||||
|
)
|
||||||
|
model.replaceSession(try await model.spool.currentSession())
|
||||||
|
guard !model.session.isEmpty else {
|
||||||
|
throw OneDriveSelfTestError.detail("seeded session was empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
await model.send(anchor: nil)
|
||||||
|
|
||||||
|
guard let status = model.statusLine, status.hasPrefix("Saved to OneDrive") else {
|
||||||
|
throw OneDriveSelfTestError.detail(
|
||||||
|
"status did not start with 'Saved to OneDrive': \(model.statusLine ?? "nil")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard model.session.isEmpty else {
|
||||||
|
throw OneDriveSelfTestError.detail("session was not archived after the OneDrive send")
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = (try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
guard let pdfURL = written.first(where: { $0.pathExtension.lowercased() == "pdf" }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("no PDF found in \(selftestFolder.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarked so far: the watcher must not treat it as a return.
|
||||||
|
let beforeMarkup = try await watcher.scanNow()
|
||||||
|
guard !beforeMarkup.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("unmarked PDF was reported as returned by scanNow")
|
||||||
|
}
|
||||||
|
let commentedBefore = try await ledger.commented()
|
||||||
|
guard !commentedBefore.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("unmarked PDF was recorded as commented in the ledger")
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the iPad does: mark it up in place with a real ink annotation, then save.
|
||||||
|
guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else {
|
||||||
|
throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.path) to annotate it")
|
||||||
|
}
|
||||||
|
let ink = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 20, y: 20, width: 60, height: 60),
|
||||||
|
forType: .ink,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
let stroke = NSBezierPath()
|
||||||
|
stroke.move(to: NSPoint(x: 20, y: 20))
|
||||||
|
stroke.line(to: NSPoint(x: 80, y: 80))
|
||||||
|
ink.add(stroke)
|
||||||
|
page.addAnnotation(ink)
|
||||||
|
guard document.write(to: pdfURL) else {
|
||||||
|
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterMarkup = try await watcher.scanNow()
|
||||||
|
guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else {
|
||||||
|
throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow")
|
||||||
|
}
|
||||||
|
let commentedAfter = try await ledger.commented()
|
||||||
|
guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented")
|
||||||
|
}
|
||||||
|
|
||||||
|
return selftestFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func oneDriveFail(_ detail: String) -> Never {
|
||||||
|
print("ONEDRIVE-SELFTEST FAIL \(detail)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
private static func interpolate(_ step: Int) -> NSPoint {
|
private static func interpolate(_ step: Int) -> NSPoint {
|
||||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||||
return NSPoint(
|
return NSPoint(
|
||||||
@@ -538,3 +729,12 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
|
||||||
|
case detail(String)
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .detail(let s): return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +52,18 @@ extension AppModel: SendCapable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
case .airDrop:
|
||||||
guard let anchor else {
|
guard let anchor else {
|
||||||
handleDidFailToShareItems(fileName: pending.fileName)
|
handleDidFailToShareItems(fileName: pending.fileName)
|
||||||
setSending(false)
|
setSending(false)
|
||||||
@@ -52,6 +89,13 @@ extension AppModel: SendCapable {
|
|||||||
setSending(false)
|
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.
|
||||||
|
|||||||
@@ -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,6 +60,7 @@ struct SettingsView: View {
|
|||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if model.transport == .airDrop {
|
||||||
GridRow(alignment: .center) {
|
GridRow(alignment: .center) {
|
||||||
fieldLabel("Watch folder")
|
fieldLabel("Watch folder")
|
||||||
folderValue(path: model.watchFolderURL.path) {
|
folderValue(path: model.watchFolderURL.path) {
|
||||||
@@ -54,6 +74,36 @@ struct SettingsView: View {
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow {
|
||||||
Button("Reveal spool folder") { model.openSpoolFolder() }
|
Button("Reveal spool folder") { model.openSpoolFolder() }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import ShotdeckCore
|
|||||||
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||||
|
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||||
|
}
|
||||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
|||||||
case pdfCompositionFailed(reason: String)
|
case pdfCompositionFailed(reason: String)
|
||||||
case airDropUnavailable
|
case airDropUnavailable
|
||||||
case noCommentedReturns
|
case noCommentedReturns
|
||||||
|
case oneDriveFolderUnavailable(path: String)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
|||||||
return "AirDrop is not available right now."
|
return "AirDrop is not available right now."
|
||||||
case .noCommentedReturns:
|
case .noCommentedReturns:
|
||||||
return "None of the returned PDFs have comments on them."
|
return "None of the returned PDFs have comments on them."
|
||||||
|
case .oneDriveFolderUnavailable(let path):
|
||||||
|
return "Your OneDrive folder is not available: \(path). Check that OneDrive is signed in, or choose another folder in Settings."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ public actor ReturnWatcher {
|
|||||||
private var bridge: FSEventBridge?
|
private var bridge: FSEventBridge?
|
||||||
private var pendingScanTask: Task<Void, Never>?
|
private var pendingScanTask: Task<Void, Never>?
|
||||||
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
|
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
|
||||||
|
/// When false, a document with zero human marks is neither recorded into the ledger
|
||||||
|
/// nor included in scanNow's/onChange's results — needed for OneDrive mode, where the
|
||||||
|
/// outbox and watch folder are the same folder and a freshly written, unmarked PDF
|
||||||
|
/// must not be treated as a return. Defaults to true (today's AirDrop behaviour).
|
||||||
|
/// A document that IS commented is always recorded, regardless of this flag.
|
||||||
|
public var recordUncommented: Bool = true
|
||||||
|
|
||||||
/// Watch folder is `paths.watchFolder`, which production constructs from
|
/// Watch folder is `paths.watchFolder`, which production constructs from
|
||||||
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
||||||
@@ -29,6 +35,12 @@ public actor ReturnWatcher {
|
|||||||
onChange(found)
|
onChange(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets `recordUncommented`. A `func` (not a plain property set) only because
|
||||||
|
/// callers outside this actor must `await` it like any other actor mutation.
|
||||||
|
public func setRecordUncommented(_ value: Bool) {
|
||||||
|
recordUncommented = value
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
|
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
|
||||||
/// when never started or already stopped. Cancels any pending debounced scan.
|
/// when never started or already stopped. Cancels any pending debounced scan.
|
||||||
public func stop() {
|
public func stop() {
|
||||||
@@ -77,6 +89,7 @@ public actor ReturnWatcher {
|
|||||||
guard let document = PDFDocument(url: url),
|
guard let document = PDFDocument(url: url),
|
||||||
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
||||||
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
||||||
|
if !recordUncommented, !inspected.isCommented { continue }
|
||||||
try await ledger.record(inspected)
|
try await ledger.record(inspected)
|
||||||
results.append(inspected)
|
results.append(inspected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The two ways a composed PDF can reach the iPad and come back marked up.
|
||||||
|
public enum SendTransport: String, Codable, Sendable, CaseIterable {
|
||||||
|
case airDrop
|
||||||
|
case oneDrive
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .airDrop: return "AirDrop"
|
||||||
|
case .oneDrive: return "OneDrive folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-configurable transport choice plus the OneDrive folder override, backed by
|
||||||
|
/// UserDefaults the same way `FolderSettings` is. See `FolderSettings` for why a plain
|
||||||
|
/// path (not a security-scoped bookmark) is correct for this unsandboxed app.
|
||||||
|
public enum TransportSettings {
|
||||||
|
public static let transportDefaultsKey = "ai.flowmaster.shotdeck.transport"
|
||||||
|
public static let oneDriveFolderDefaultsKey = "ai.flowmaster.shotdeck.oneDriveFolder"
|
||||||
|
|
||||||
|
/// Defaults to `.airDrop` when unset or when the stored value cannot be parsed.
|
||||||
|
public static func transport(defaults: UserDefaults = .standard) -> SendTransport {
|
||||||
|
guard let raw = defaults.string(forKey: transportDefaultsKey),
|
||||||
|
let value = SendTransport(rawValue: raw)
|
||||||
|
else { return .airDrop }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setTransport(_ value: SendTransport, defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(value.rawValue, forKey: transportDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw stored path (or nil if never set / cleared). Does NOT validate that the
|
||||||
|
/// directory still exists.
|
||||||
|
public static func storedOneDriveFolderPath(defaults: UserDefaults = .standard) -> String? {
|
||||||
|
defaults.string(forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setOneDriveFolder(_ url: URL, defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(url.path, forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func resetOneDriveFolder(defaults: UserDefaults = .standard) {
|
||||||
|
defaults.removeObject(forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outbox/watch folders Redline should actually use right now, for the current
|
||||||
|
/// transport. AirDrop mode delegates to `FolderSettings.resolve()` unchanged.
|
||||||
|
/// OneDrive mode uses the SAME folder for both outbox and watch — see
|
||||||
|
/// `OneDriveLocator.resolveOneDriveFolder`. When no OneDrive folder can be resolved
|
||||||
|
/// at all (no sync root, no override), this falls back to the AirDrop folders so the
|
||||||
|
/// app always has somewhere to write; `send(anchor:)` performs its own live
|
||||||
|
/// existence check before ever composing into a OneDrive send, so that fallback is
|
||||||
|
/// never mistaken for a valid OneDrive destination.
|
||||||
|
public static func effectiveFolders(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> (outbox: URL, watch: URL, transport: SendTransport) {
|
||||||
|
let transport = transport(defaults: defaults)
|
||||||
|
switch transport {
|
||||||
|
case .airDrop:
|
||||||
|
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
|
||||||
|
return (folders.outbox, folders.watch, transport)
|
||||||
|
case .oneDrive:
|
||||||
|
if let folder = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: defaults,
|
||||||
|
home: fileManager.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: fileManager
|
||||||
|
) {
|
||||||
|
return (folder, folder, transport)
|
||||||
|
}
|
||||||
|
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
|
||||||
|
return (folders.outbox, folders.watch, transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure path logic for locating a OneDrive sync root under
|
||||||
|
/// `~/Library/CloudStorage` and the Redline folder inside it. No side effects — never
|
||||||
|
/// creates a directory. Fully unit-testable with a fake home tree.
|
||||||
|
public enum OneDriveLocator {
|
||||||
|
/// Every directory directly under `<home>/Library/CloudStorage` whose name starts
|
||||||
|
/// with "OneDrive-", sorted so a name containing "MMD" (case-insensitive) sorts
|
||||||
|
/// first, then alphabetically. Empty when CloudStorage does not exist.
|
||||||
|
public static func syncRoots(
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard fileManager.fileExists(atPath: cloudStorage.path, isDirectory: &isDirectory),
|
||||||
|
isDirectory.boolValue
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
let items = (try? fileManager.contentsOfDirectory(
|
||||||
|
at: cloudStorage,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)) ?? []
|
||||||
|
|
||||||
|
let roots = items.filter { url in
|
||||||
|
guard url.lastPathComponent.hasPrefix("OneDrive-") else { return false }
|
||||||
|
var itemIsDirectory: ObjCBool = false
|
||||||
|
let exists = fileManager.fileExists(atPath: url.path, isDirectory: &itemIsDirectory)
|
||||||
|
return exists && itemIsDirectory.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots.sorted { a, b in
|
||||||
|
let aName = a.lastPathComponent
|
||||||
|
let bName = b.lastPathComponent
|
||||||
|
let aIsMMD = aName.localizedCaseInsensitiveContains("MMD")
|
||||||
|
let bIsMMD = bName.localizedCaseInsensitiveContains("MMD")
|
||||||
|
if aIsMMD != bIsMMD { return aIsMMD }
|
||||||
|
return aName.localizedStandardCompare(bName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First sync root's "Redline" subfolder, or nil when there is no sync root at all.
|
||||||
|
public static func defaultRedlineFolder(
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
guard let first = syncRoots(home: home, fileManager: fileManager).first else { return nil }
|
||||||
|
return first.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored override when it is set AND still exists as a directory; otherwise
|
||||||
|
/// `defaultRedlineFolder`. Never creates anything.
|
||||||
|
public static func resolveOneDriveFolder(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
if let storedPath = TransportSettings.storedOneDriveFolderPath(defaults: defaults) {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory)
|
||||||
|
if exists, isDirectory.boolValue {
|
||||||
|
return URL(fileURLWithPath: storedPath, isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultRedlineFolder(home: home, fileManager: fileManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func syncRootsFindsOneDriveDirsMMDFirstIgnoresNonDirsAndOtherProviders() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: cloudStorage, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-Flowmaster", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("GoogleDrive-x", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
// A plain FILE (not a directory) named like a OneDrive root must be ignored.
|
||||||
|
FileManager.default.createFile(
|
||||||
|
atPath: cloudStorage.appendingPathComponent("OneDrive-notadir").path,
|
||||||
|
contents: Data("not a directory".utf8)
|
||||||
|
)
|
||||||
|
|
||||||
|
let roots = OneDriveLocator.syncRoots(home: home, fileManager: .default)
|
||||||
|
|
||||||
|
#expect(roots.map(\.lastPathComponent) == ["OneDrive-MMDGROUP", "OneDrive-Flowmaster"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func syncRootsEmptyAndDefaultFolderNilWithNoCloudStorageDirectory() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
// No Library/CloudStorage created at all.
|
||||||
|
|
||||||
|
let roots = OneDriveLocator.syncRoots(home: home, fileManager: .default)
|
||||||
|
#expect(roots.isEmpty)
|
||||||
|
|
||||||
|
let defaultFolder = OneDriveLocator.defaultRedlineFolder(home: home, fileManager: .default)
|
||||||
|
#expect(defaultFolder == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func defaultRedlineFolderIsFirstSyncRootPlusRedline() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-Flowmaster", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let defaultFolder = try #require(
|
||||||
|
OneDriveLocator.defaultRedlineFolder(home: home, fileManager: .default)
|
||||||
|
)
|
||||||
|
// Derive "expected" from syncRoots() itself (already covered by its own dedicated
|
||||||
|
// test) rather than hand-building the path string — FileManager's directory
|
||||||
|
// enumeration can canonicalize /var -> /private/var and the two constructions
|
||||||
|
// otherwise disagree on that even for a URL that already exists.
|
||||||
|
let expectedRoot = try #require(OneDriveLocator.syncRoots(home: home, fileManager: .default).first)
|
||||||
|
let expected = expectedRoot.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
#expect(defaultFolder.path == expected.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func resolveOneDriveFolderPrefersAnExistingStoredOverride() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let override = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-override")
|
||||||
|
defer { try? FileManager.default.removeItem(at: override) }
|
||||||
|
TransportSettings.setOneDriveFolder(override, defaults: suite.defaults)
|
||||||
|
|
||||||
|
let resolved = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: suite.defaults, home: home, fileManager: .default
|
||||||
|
)
|
||||||
|
#expect(resolved?.path == override.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func resolveOneDriveFolderIgnoresAStoredPathThatNoLongerExists() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let goneOverride = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-gone")
|
||||||
|
TransportSettings.setOneDriveFolder(goneOverride, defaults: suite.defaults)
|
||||||
|
try FileManager.default.removeItem(at: goneOverride)
|
||||||
|
|
||||||
|
let resolved = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: suite.defaults, home: home, fileManager: .default
|
||||||
|
)
|
||||||
|
let expectedRoot = try #require(OneDriveLocator.syncRoots(home: home, fileManager: .default).first)
|
||||||
|
let expected = expectedRoot.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
#expect(resolved?.path == expected.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeFakeHome() throws -> URL {
|
||||||
|
let home = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-fake-home-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
|
||||||
|
return home
|
||||||
|
}
|
||||||
@@ -230,3 +230,55 @@ func w27_fsEventsCallbackFiresOnRealArrival() async throws {
|
|||||||
await watcher.stop()
|
await watcher.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("recordUncommended defaults to true: an unmarked PDF is still recorded (AirDrop behaviour unchanged)")
|
||||||
|
func recordUncommendedDefaultTrueRecordsAnUnmarkedPDF() async throws {
|
||||||
|
let (paths, cleanup) = try makeCasePaths()
|
||||||
|
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||||
|
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260905-090000.pdf")
|
||||||
|
|
||||||
|
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline", annotations: [])
|
||||||
|
|
||||||
|
let found = try await watcher.scanNow()
|
||||||
|
#expect(found.count == 1)
|
||||||
|
#expect(found.first?.isCommented == false)
|
||||||
|
|
||||||
|
let all = try await ledger.all()
|
||||||
|
#expect(all.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("recordUncommended=false: an unmarked PDF is not recorded or returned; marking it up in place gets it recorded")
|
||||||
|
func recordUncommendedFalseSkipsUnmarkedThenRecordsAfterInPlaceMarkup() async throws {
|
||||||
|
let (paths, cleanup) = try makeCasePaths()
|
||||||
|
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||||
|
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
await watcher.setRecordUncommented(false)
|
||||||
|
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260905-091500.pdf")
|
||||||
|
|
||||||
|
// OneDrive mode: the PDF is freshly written here (by "send"), unmarked so far.
|
||||||
|
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline", annotations: [])
|
||||||
|
|
||||||
|
let beforeMarkup = try await watcher.scanNow()
|
||||||
|
#expect(beforeMarkup.isEmpty)
|
||||||
|
let allBefore = try await ledger.all()
|
||||||
|
#expect(allBefore.isEmpty)
|
||||||
|
|
||||||
|
// What the iPad does: mark it up in place, in the SAME folder, then save.
|
||||||
|
let document = try #require(PDFDocument(url: pdfURL))
|
||||||
|
let page = try #require(document.page(at: 0))
|
||||||
|
page.addAnnotation(makeAnnotation(.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)))
|
||||||
|
#expect(document.write(to: pdfURL))
|
||||||
|
|
||||||
|
let afterMarkup = try await watcher.scanNow()
|
||||||
|
#expect(afterMarkup.count == 1)
|
||||||
|
#expect(afterMarkup.first?.isCommented == true)
|
||||||
|
|
||||||
|
let commented = try await ledger.commented()
|
||||||
|
#expect(commented.count == 1)
|
||||||
|
#expect(commented.first?.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func transportDefaultsToAirDropWhenUnset() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func setTransportRoundTrips() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .oneDrive)
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.airDrop, defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func garbageStoredTransportFallsBackToAirDrop() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
|
||||||
|
suite.defaults.set("not-a-real-transport", forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oneDriveFolderStoreAndReset() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-folder")
|
||||||
|
defer { try? FileManager.default.removeItem(at: folder) }
|
||||||
|
|
||||||
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
|
||||||
|
|
||||||
|
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == folder.path)
|
||||||
|
|
||||||
|
TransportSettings.resetOneDriveFolder(defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func effectiveFoldersForAirDropMatchesFolderSettings() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let outbox = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-outbox")
|
||||||
|
defer { try? FileManager.default.removeItem(at: outbox) }
|
||||||
|
FolderSettings.setOutbox(outbox, defaults: suite.defaults)
|
||||||
|
|
||||||
|
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
|
||||||
|
let expected = FolderSettings.resolve(defaults: suite.defaults)
|
||||||
|
|
||||||
|
#expect(effective.transport == .airDrop)
|
||||||
|
#expect(effective.outbox.path == expected.outbox.path)
|
||||||
|
#expect(effective.watch.path == expected.watch.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func effectiveFoldersForOneDriveWithAResolvableFolderUsesItForBoth() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-onedrive")
|
||||||
|
defer { try? FileManager.default.removeItem(at: folder) }
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
||||||
|
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
|
||||||
|
|
||||||
|
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
|
||||||
|
|
||||||
|
#expect(effective.transport == .oneDrive)
|
||||||
|
#expect(effective.outbox.path == folder.path)
|
||||||
|
#expect(effective.watch.path == folder.path)
|
||||||
|
#expect(effective.outbox.path == effective.watch.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oneDriveFolderUnavailableErrorDescriptionContainsThePath() throws {
|
||||||
|
let path = "/Users/example/Library/CloudStorage/OneDrive-Example/Redline"
|
||||||
|
let error = ShotdeckError.oneDriveFolderUnavailable(path: path)
|
||||||
|
let description = try #require(error.errorDescription)
|
||||||
|
#expect(!description.isEmpty)
|
||||||
|
#expect(description.contains(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransportDefaultsSuite {
|
||||||
|
let name: String
|
||||||
|
let defaults: UserDefaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTransportDefaultsSuite() throws -> TransportDefaultsSuite {
|
||||||
|
let name = "shotdeck-transport-test-\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: name))
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
return TransportDefaultsSuite(name: name, defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDownTransportSuite(_ suite: TransportDefaultsSuite) {
|
||||||
|
suite.defaults.removePersistentDomain(forName: suite.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTransportTemporaryDirectory(prefix: String) throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user