Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a128b667 | ||
|
|
78cc7d5b1a | ||
|
|
6449c72b3b | ||
|
|
299d55e884 | ||
|
|
380b704f8a | ||
|
|
8338216f23 | ||
|
|
f0e9b41d90 |
+7
-11
@@ -2,28 +2,24 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Identity invariants: CFBundleIdentifier stays ai.flowmaster.shotdeck and
|
||||
CFBundleExecutable stays Shotdeck. Changing either one invalidates the
|
||||
user's existing Screen Recording grant. CFBundleName is the user-facing
|
||||
product name only. -->
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.flowmaster.shotdeck</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Redline</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<string>2</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string>
|
||||
</dict>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import CoreGraphics
|
||||
import Darwin
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PDFKit
|
||||
import ShotdeckCore
|
||||
|
||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||
@@ -173,7 +174,7 @@ enum PickerSelfTest {
|
||||
try await executeSendTruth()
|
||||
print("SEND-TRUTH PASS")
|
||||
fflush(stdout)
|
||||
if !startUpdateSelfTestIfRequested() {
|
||||
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
} catch {
|
||||
@@ -315,7 +316,9 @@ enum PickerSelfTest {
|
||||
try await runUpdateSelfTest(outputDirectory: output)
|
||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
if !startOneDriveSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
} catch let error as UpdateSelfTestError {
|
||||
updateFail(error.description)
|
||||
} catch {
|
||||
@@ -473,6 +476,194 @@ enum PickerSelfTest {
|
||||
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 {
|
||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||
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 }
|
||||
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
|
||||
|
||||
@@ -7,6 +7,9 @@ import ShotdeckCore
|
||||
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
||||
case pdfCompositionFailed(reason: String)
|
||||
case airDropUnavailable
|
||||
case noCommentedReturns
|
||||
case oneDriveFolderUnavailable(path: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
||||
return "AirDrop is not available right now."
|
||||
case .noCommentedReturns:
|
||||
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 pendingScanTask: Task<Void, Never>?
|
||||
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
|
||||
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
||||
@@ -29,6 +35,12 @@ public actor ReturnWatcher {
|
||||
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
|
||||
/// when never started or already stopped. Cancels any pending debounced scan.
|
||||
public func stop() {
|
||||
@@ -77,6 +89,7 @@ public actor ReturnWatcher {
|
||||
guard let document = PDFDocument(url: url),
|
||||
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
||||
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
||||
if !recordUncommented, !inspected.isCommented { continue }
|
||||
try await ledger.record(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()
|
||||
}
|
||||
}
|
||||
|
||||
@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