Compare commits

..
Author SHA1 Message Date
kua-agentandClaude Fable 5.1 80a128b667 selftest: ONEDRIVE-SELFTEST phase, proven against the real OneDrive sync root
New phase chained after UPDATE-SELFTEST (so a full SHOTDECK_PICKER_SELFTEST
chain now prints all five PASS lines) and also runnable standalone via
REDLINE_SELFTEST_PHASE=onedrive, since the harness has no other per-phase
selector.

Points TransportSettings at a NEW "Redline-selftest-<Dubai timestamp>" folder
under the real /Users/.../OneDrive-MMDGROUP sync root (never a fake home tree
— that proves the transport against the actual OneDrive file provider), drives
a seeded session through the OneDrive branch of send(anchor: nil), asserts the
PDF landed, the session archived, and the status starts with "Saved to
OneDrive", then confirms the watcher does NOT report the fresh unmarked PDF
as returned. It then adds a real PDFKit ink annotation to that PDF in place —
what the iPad does — saves it, and confirms the watcher now reports it as
commented. Prints "ONEDRIVE-SELFTEST PASS path=<folder>". Never deletes
anything under OneDrive; the created folder and PDF are left in place.

UserDefaults.standard's transport/oneDriveFolder keys are snapshotted and
restored around the phase, the same pattern runRegionPersistPhase already
uses for CaptureRegion — there is no separate defaults-suite threading
through AppModel/send(), so this is the only way to drive the real send()
path without leaving the real app pointed at the selftest folder afterward.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:29 +04:00
kua-agentandClaude Fable 5.1 78cc7d5b1a test: OneDrive transport settings/locator unit tests + ReturnWatcher recordUncommended coverage
TransportSettingsTests: default airDrop, set/get round-trip, garbage stored
value falls back to airDrop, oneDriveFolder store/reset, effectiveFolders for
both transports, oneDriveFolderUnavailable's errorDescription contains the path.

OneDriveLocatorTests: fake home tree under Library/CloudStorage — syncRoots
returns only real OneDrive-* directories (ignores a same-named plain file and
a GoogleDrive-* one), MMD-named root sorts first; no CloudStorage dir means
empty roots and a nil defaultRedlineFolder; resolveOneDriveFolder prefers an
existing stored override and falls back to the default when the stored path
no longer exists. All against temp dirs, never the real home.

ReturnWatcherTests: recordUncommended defaults to true and still records an
unmarked PDF (existing AirDrop tests are unaffected); with it set false, an
unmarked PDF is neither recorded nor returned by scanNow, and marking it up
in place with a real PDFKit ink annotation then re-scanning does record it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:21 +04:00
kua-agentandClaude Fable 5.1 6449c72b3b feature: OneDrive folder as a second send transport
send(anchor:) now branches on TransportSettings.transport(). OneDrive mode
skips AirDrop entirely: it verifies the resolved OneDrive folder exists right
before composing (never trusts stale state), archives the session immediately
after the PDF lands, and sets "Saved to OneDrive — N page(s). Open it in Files
on your iPad." AirDrop's existing behaviour, including handleDidFailToShareItems,
is untouched and only reached from the .airDrop branch.

AppModel seeds outbox/watch from TransportSettings.effectiveFolders() instead
of FolderSettings.resolve() directly, tracks the live `transport`, and
bootstrap() creates the OneDrive folder and sets the watcher's
recordUncommented flag (true only for AirDrop) before the watcher starts.

Settings gets a "Send via" segmented picker above Folders. AirDrop shows the
existing watch/output rows; OneDrive shows a single read-only OneDrive folder
row (Choose... reuses the existing directory picker) plus one caption
explaining the same-folder round trip, or a "No OneDrive folder found" prompt
when nothing resolves (Choose... stays usable). Switching transport re-points
the watcher's folder and recordUncommended live; AirDrop's own folder
overrides are stored separately and are untouched by a OneDrive-and-back
round trip.

Menu's "Send..." row reads "Send to OneDrive" when that transport is active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:13 +04:00
kua-agentandClaude Fable 5.1 299d55e884 feature(core): OneDrive transport settings, locator, and watcher recordUncommented flag
Adds SendTransport/TransportSettings (UserDefaults-backed, mirrors FolderSettings)
and OneDriveLocator, which finds a OneDrive-* sync root under
~/Library/CloudStorage and resolves the Redline send/watch folder inside it
(MMD-named roots preferred). TransportSettings.effectiveFolders() is the one
function that combines the transport choice with FolderSettings/OneDriveLocator.

ReturnWatcher gains recordUncommented (default true, today's AirDrop behaviour):
when false, a document with zero human marks is neither recorded into the
ledger nor returned by scanNow. This is needed because in OneDrive mode the
outbox and watch folder are the same folder, so a freshly written, unmarked
PDF must not be treated as a return — only a later, actually marked-up save
of the same file should be.

Adds ShotdeckError.oneDriveFolderUnavailable(path:) for when the OneDrive
folder is missing or unwritable at send time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:04 +04:00
kua-agent 380b704f8a Merge pull request 'Redline v0.2.0 — complete app (integration branch → main)' (#22) from feat/shotdeck-20260830 into main 2026-09-02 06:09:38 +00:00
15 changed files with 861 additions and 990 deletions
+22 -6
View File
@@ -32,6 +32,9 @@ public final class AppModel {
public private(set) var isSending: Bool = false
public private(set) var outboxDisplayName: String
public private(set) var watchFolderDisplayName: String
/// Live transport choice; WP-onedrive reads this to pick the send path and to drive
/// the Settings "Send via" picker and the menu's "Send" label.
public private(set) var transport: SendTransport
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
@@ -52,7 +55,6 @@ public final class AppModel {
let picker: RegionPickerController
let ledger: ReturnLedger
let watcher: ReturnWatcher
let historyStore: HistoryStore
let updateChecker: UpdateChecker
public init(
@@ -64,7 +66,7 @@ public final class AppModel {
picker: RegionPickerController,
ledger: ReturnLedger,
watcher: ReturnWatcher
) throws {
) {
self.paths = paths
self.spool = spool
self.composer = composer
@@ -73,7 +75,6 @@ public final class AppModel {
self.picker = picker
self.ledger = ledger
self.watcher = watcher
self.historyStore = try HistoryStore(paths: paths)
self.session = CaptureSession(
id: UUID(),
createdAt: Date(),
@@ -83,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
@@ -120,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
@@ -187,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
@@ -210,7 +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_HISTORY_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
if !skipSchedule {
updateChecker.startSchedule()
}
+1 -1
View File
@@ -91,7 +91,7 @@ struct MenuBarView: View {
model.setStatus("Send is not available in this build.")
}
} label: {
actionLabel("Send…")
actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…")
}
.disabled(model.session.isEmpty || model.isSending)
+1 -1
View File
@@ -163,7 +163,7 @@ enum PanelSnapshot {
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let ledger = try ReturnLedger(paths: paths)
let model = try AppModel(
let model = AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
+201 -170
View File
@@ -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`.
@@ -30,20 +31,6 @@ enum PickerSelfTest {
}
}
/// Standalone HISTORY phase when `SHOTDECK_HISTORY_SELFTEST` is set without
/// the picker chain. Waits for NSApp like the other phases, then exits.
static func runHistoryIfRequested() {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"],
!raw.isEmpty
else { return }
_ = raw
DispatchQueue.main.async {
MainActor.assumeIsolated {
runHistoryPhase()
}
}
}
private static func execute(outputDirectory: URL) {
do {
try FileManager.default.createDirectory(
@@ -131,8 +118,7 @@ enum PickerSelfTest {
runRegionPersistPhase()
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
// exit(0) here runSendTruthPhase prints its own PASS/FAIL, then
// chains to HISTORY, then UPDATE-SELFTEST (or exits if that phase is
// not requested).
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
runSendTruthPhase()
}
@@ -181,166 +167,21 @@ enum PickerSelfTest {
/// Fail path must leave the session open in the temp spool; success path archives
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
/// function is called from inside `execute()` a nested run-loop wait would never
/// let the Task start. On success, chains to HISTORY instead of exiting.
/// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting.
private static func runSendTruthPhase() {
Task { @MainActor in
do {
try await executeSendTruth()
print("SEND-TRUTH PASS")
fflush(stdout)
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
exit(0)
}
} catch {
print("SEND-TRUTH FAIL \(error)")
fflush(stdout)
exit(1)
}
runHistoryPhase()
}
}
/// Phase 4: drive HistoryStore record/prune in a temp root. Env-gated by
/// `SHOTDECK_HISTORY_SELFTEST` the same way UPDATE is gated, and also runs
/// whenever the picker self-test chain is already in flight so a full
/// self-test prints HISTORY PASS|FAIL. Chains to UPDATE-SELFTEST (or exits).
private static func runHistoryPhase() {
let historyRequested = ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"]
let pickerRequested = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"]
let shouldRun = (historyRequested.map { !$0.isEmpty } ?? false)
|| (pickerRequested.map { !$0.isEmpty } ?? false)
guard shouldRun else {
if !startUpdateSelfTestIfRequested() {
exit(0)
}
return
}
Task { @MainActor in
do {
try await executeHistorySelfTest()
print("HISTORY PASS")
fflush(stdout)
if !startUpdateSelfTestIfRequested() {
exit(0)
}
} catch {
print("HISTORY FAIL \(error)")
fflush(stdout)
exit(1)
}
}
}
private static func executeHistorySelfTest() async throws {
let fm = FileManager.default
let root = fm.temporaryDirectory
.appendingPathComponent("shotdeck-history-selftest-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: root) }
let paths = try AppSupportPaths(
root: root.appendingPathComponent("root", isDirectory: true),
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let store = try HistoryStore(paths: paths)
let sources = root.appendingPathComponent("user-sources", isDirectory: true)
try fm.createDirectory(at: sources, withIntermediateDirectories: true)
var recorded: [HistoryEntry] = []
for i in 0..<12 {
let source = sources.appendingPathComponent("source-\(i).pdf")
try Data("%PDF-1.4\n%hist-\(i)\n%%EOF\n".utf8).write(to: source)
let entry = try await store.recordSentPDF(
sourceURL: source,
sessionID: UUID(),
pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_000_000 + TimeInterval(i))
)
recorded.append(entry)
let onDisk = try Data(contentsOf: source)
guard onDisk == Data("%PDF-1.4\n%hist-\(i)\n%%EOF\n".utf8) else {
throw HistorySelfTestError.detail("user source PDF was modified: \(source.path)")
}
}
let listed = await store.listPDFs()
guard listed.count == 10 else {
throw HistorySelfTestError.detail("listPDFs count \(listed.count) want 10")
}
let wantNewest = Array(recorded.suffix(10).reversed())
guard listed.map(\.id) == wantNewest.map(\.id) else {
throw HistorySelfTestError.detail("listPDFs did not return the 10 newest")
}
let pdfsDir = paths.root.appendingPathComponent("history/pdfs", isDirectory: true)
for entry in recorded.prefix(2) {
let gone = pdfsDir.appendingPathComponent(entry.fileName)
guard !fm.fileExists(atPath: gone.path) else {
throw HistorySelfTestError.detail("oldest PDF still on disk: \(gone.path)")
}
}
let same = sources.appendingPathComponent("same.pdf")
try Data("%PDF-1.4\n%same\n%%EOF\n".utf8).write(to: same)
let first = try await store.recordSentPDF(
sourceURL: same, sessionID: nil, pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_000_100)
)
let second = try await store.recordSentPDF(
sourceURL: same, sessionID: nil, pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_000_101)
)
guard first.id != second.id, first.fileURL.path != second.fileURL.path,
fm.fileExists(atPath: first.fileURL.path),
fm.fileExists(atPath: second.fileURL.path),
fm.fileExists(atPath: same.path)
else {
throw HistorySelfTestError.detail("re-record same source did not produce two distinct copies")
}
let spool = try SpoolStore(paths: paths)
let png = try makeTinyPNGData()
let base = Date(timeIntervalSince1970: 1_800_100_000)
var n = 0
var oldestSessionID: UUID?
for count in [5, 15, 15] {
if n == 0 {
oldestSessionID = try await spool.currentSession().id
}
for _ in 0..<count {
_ = try await spool.append(
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1,
capturedAt: base.addingTimeInterval(TimeInterval(n))
)
n += 1
}
_ = try await spool.archiveCurrent(pdfFileName: "Redline-hist-\(n).pdf")
}
let openCapture = try await spool.append(
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1,
capturedAt: Date(timeIntervalSince1970: 1_900_000_000)
)
let openSession = try await spool.currentSession()
let openPNG = paths.sessionDirectory(openSession.id).appendingPathComponent(openCapture.fileName)
let openBytes = try Data(contentsOf: openPNG)
try await store.pruneImages()
let images = try await store.listImages(limit: 50)
guard images.count == 30 else {
throw HistorySelfTestError.detail("listImages count \(images.count) want 30")
}
if let oldestSessionID {
let oldDir = paths.archiveDirectory(oldestSessionID)
guard !fm.fileExists(atPath: oldDir.path) else {
throw HistorySelfTestError.detail("emptied archive session still on disk")
}
}
guard fm.fileExists(atPath: openPNG.path), try Data(contentsOf: openPNG) == openBytes else {
throw HistorySelfTestError.detail("open session PNG was touched")
}
let remainingOpen = try await spool.currentSession()
guard remainingOpen.id == openSession.id,
remainingOpen.captures.map(\.id) == [openCapture.id]
else {
throw HistorySelfTestError.detail("open session manifest was touched")
}
}
@@ -356,7 +197,7 @@ enum PickerSelfTest {
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let ledger = try ReturnLedger(paths: paths)
let model = try AppModel(
let model = AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
@@ -460,7 +301,7 @@ enum PickerSelfTest {
exit(1)
}
/// Phase 5: builds a fake 99.0.0 bundle, serves a local appcast, stages via
/// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via
/// `checkNow`, then `installStaged` into the env dir never `/Applications`.
/// Returns true when the async phase was scheduled (it calls `exit` itself).
@discardableResult
@@ -475,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 {
@@ -599,7 +442,7 @@ enum PickerSelfTest {
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let ledger = try ReturnLedger(paths: paths)
let model = try AppModel(
let model = AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
@@ -633,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(
@@ -699,7 +730,7 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible {
}
}
private enum HistorySelfTestError: Error, CustomStringConvertible {
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
case detail(String)
var description: String {
switch self {
+63 -35
View File
@@ -16,6 +16,31 @@ extension AppModel: SendCapable {
guard !session.isEmpty, !isSending else { return }
setSending(true)
let transport = TransportSettings.transport()
// OneDrive mode: verify the real destination exists RIGHT NOW, before composing
// anything. `outboxURL` is kept in sync with the resolved OneDrive folder by
// bootstrap/chooseTransport/chooseOneDriveFolder, but this is re-resolved fresh
// here (never trusted stale) so a folder that vanished since then (OneDrive
// signed out, external volume unmounted, folder deleted) is caught instead of
// silently writing into whatever `outboxURL` happens to hold.
if transport == .oneDrive {
guard let folder = OneDriveLocator.resolveOneDriveFolder(),
Self.directoryExists(at: folder)
else {
let path = OneDriveLocator.resolveOneDriveFolder()?.path
?? TransportSettings.storedOneDriveFolderPath()
?? "no OneDrive folder found"
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
setSending(false)
return
}
if outboxURL != folder || watchFolderURL != folder {
setFolderURLs(outbox: folder, watch: folder)
try? await watcher.updateWatchFolder(folder)
}
}
let pending: ComposedSend
do {
pending = try await composePDFForSend()
@@ -27,32 +52,51 @@ extension AppModel: SendCapable {
return
}
guard let anchor else {
handleDidFailToShareItems(fileName: pending.fileName)
switch transport {
case .oneDrive:
// No AirDrop, no anchor needed the PDF is already in the watched
// OneDrive folder. Archive immediately; the iPad marks it up in place.
await handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount)
let pageWord = pending.pageCount == 1 ? "page" : "pages"
setStatus(
"Saved to OneDrive — \(pending.pageCount) \(pageWord). Open it in Files on your iPad."
)
setSending(false)
return
}
do {
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
guard let self else { return }
if success {
await self.handleDidShareItems(
fileName: pending.fileName,
pageCount: pending.pageCount
)
} else {
self.handleDidFailToShareItems(fileName: pending.fileName)
case .airDrop:
guard let anchor else {
handleDidFailToShareItems(fileName: pending.fileName)
setSending(false)
return
}
do {
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
guard let self else { return }
if success {
await self.handleDidShareItems(
fileName: pending.fileName,
pageCount: pending.pageCount
)
} else {
self.handleDidFailToShareItems(fileName: pending.fileName)
}
self.setSending(false)
}
self.setSending(false)
} catch {
// canPerform false, no service, or no visible window: same as cancel.
handleDidFailToShareItems(fileName: pending.fileName)
setSending(false)
}
} catch {
// canPerform false, no service, or no visible window: same as cancel.
handleDidFailToShareItems(fileName: pending.fileName)
setSending(false)
}
}
private static func directoryExists(at url: URL) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
return exists && isDirectory.boolValue
}
/// Writes the PDF to the outbox and records its path. Does not archive the session
/// and does not present AirDrop that happens only after the share completes.
func composePDFForSend() async throws -> ComposedSend {
@@ -100,8 +144,6 @@ extension AppModel: SendCapable {
/// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam.
func handleDidShareItems(fileName: String, pageCount: Int) async {
guard !session.isEmpty else { return }
let sentSessionID = session.id
let sourceURL = lastComposedPDFURL ?? outboxURL.appendingPathComponent(fileName)
do {
_ = try await spool.archiveCurrent(pdfFileName: fileName)
replaceSession(try await spool.currentSession())
@@ -109,20 +151,6 @@ extension AppModel: SendCapable {
setStatus("Sent — \(pageCount) \(pageWord).")
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.")
return
}
do {
_ = try await historyStore.recordSentPDF(
sourceURL: sourceURL,
sessionID: sentSessionID,
pageCount: pageCount,
sentAt: Date()
)
try await historyStore.pruneImages()
} catch {
Log.spool.error(
"Could not record sent PDF into history at \(sourceURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
}
}
+117 -9
View File
@@ -33,6 +33,25 @@ struct SettingsView: View {
.frame(minHeight: 22)
}
GridRow {
Text("Send via")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
.padding(.top, 6)
}
GridRow {
Picker("Send via", selection: transportBinding) {
ForEach(SendTransport.allCases, id: \.self) { transport in
Text(transport.displayName).tag(transport)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.gridCellColumns(2)
}
GridRow {
Text("Folders")
.font(.headline)
@@ -41,17 +60,48 @@ struct SettingsView: View {
.padding(.top, 6)
}
GridRow(alignment: .center) {
fieldLabel("Watch folder")
folderValue(path: model.watchFolderURL.path) {
model.chooseWatchFolder()
if model.transport == .airDrop {
GridRow(alignment: .center) {
fieldLabel("Watch folder")
folderValue(path: model.watchFolderURL.path) {
model.chooseWatchFolder()
}
}
}
GridRow(alignment: .center) {
fieldLabel("Output folder")
folderValue(path: model.outboxURL.path) {
model.chooseOutboxFolder()
GridRow(alignment: .center) {
fieldLabel("Output folder")
folderValue(path: model.outboxURL.path) {
model.chooseOutboxFolder()
}
}
} else {
GridRow(alignment: .center) {
fieldLabel("OneDrive folder")
if let folder = resolvedOneDriveFolder {
folderValue(path: folder.path) {
model.chooseOneDriveFolder()
}
} else {
HStack(spacing: 8) {
Text("No OneDrive folder found — sign in to OneDrive or choose a folder.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { model.chooseOneDriveFolder() }
}
.frame(minHeight: 22)
}
}
GridRow {
Text(
"The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.gridCellColumns(2)
}
}
@@ -68,6 +118,17 @@ struct SettingsView: View {
.onDisappear { disarmHotkeyRecorder() }
}
private var transportBinding: Binding<SendTransport> {
Binding(get: { model.transport }, set: { model.chooseTransport($0) })
}
/// Ground truth from OneDriveLocator, not `model.outboxURL` the model may be
/// showing an AirDrop-folder fallback when no real OneDrive folder resolves, and
/// the Settings row must say so plainly rather than repeat that fallback path.
private var resolvedOneDriveFolder: URL? {
OneDriveLocator.resolveOneDriveFolder()
}
private func armHotkeyRecorder() {
guard !isRecordingHotkey else { return }
isRecordingHotkey = true
@@ -190,6 +251,53 @@ extension AppModel: SettingsWindowPresenting {
}
}
/// Settings "Send via" picker action. Persists the choice, recomputes the effective
/// outbox/watch folder for the new transport, creates the OneDrive folder if it
/// doesn't exist yet, and re-points the running watcher (folder + recordUncommented)
/// at the new state. Switching back to AirDrop restores its own stored overrides
/// untouched, since AirDrop and OneDrive folder settings are stored under separate keys.
func chooseTransport(_ value: SendTransport) {
guard value != transport else { return }
TransportSettings.setTransport(value)
setTransport(value)
let folders = TransportSettings.effectiveFolders()
if value == .oneDrive {
try? FileManager.default.createDirectory(
at: folders.outbox, withIntermediateDirectories: true
)
}
setFolderURLs(outbox: folders.outbox, watch: folders.watch)
Task {
await watcher.setRecordUncommented(value == .airDrop)
do {
try await watcher.updateWatchFolder(folders.watch)
} catch {
setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
)
}
}
}
func chooseOneDriveFolder() {
let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser
guard let url = chooseDirectory(startingAt: start) else { return }
TransportSettings.setOneDriveFolder(url)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
guard transport == .oneDrive else { return }
setFolderURLs(outbox: url, watch: url)
Task {
do {
try await watcher.updateWatchFolder(url)
setStatus("OneDrive folder set to \(url.lastPathComponent).")
} catch {
setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
)
}
}
}
private func chooseDirectory(startingAt directory: URL) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
+4 -3
View File
@@ -7,10 +7,11 @@ 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() }
} else if ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"] != nil {
MainActor.assumeIsolated { PickerSelfTest.runHistoryIfRequested() }
}
ShotdeckApp.main()
@@ -69,7 +70,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private static func makeModel(paths: AppSupportPaths) throws -> AppModel {
let ledger = try ReturnLedger(paths: paths)
return try AppModel(
return AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
@@ -1,446 +0,0 @@
import Foundation
/// Retention ruling (2026-09-02). Ben's instruction is the authority that
/// supersedes the earlier D-11 never-delete rule FOR THIS STORE only:
///
/// "the last 10 files are saved, and the past 30 images, and cleared up
/// afterwards. user should be able to select from those and send again."
///
/// App-managed copies live under `AppSupportPaths.root/history/`. Pruning
/// unlinks files under `history/pdfs/` and archived capture PNGs beyond the
/// newest 30. It never deletes a user file, never touches the outbox PDF, and
/// never touches the open spool session (D-11 still applies there).
/// One sent-PDF copy retained for re-send. The file at `fileURL` is the
/// app-managed copy under `history/pdfs/`, not the user's original.
public struct HistoryEntry: Codable, Sendable, Equatable, Identifiable {
public let id: UUID
public let fileName: String
public let fileURL: URL
public let originalFileName: String
public let sessionID: UUID?
public let pageCount: Int
public let sentAt: Date
public init(
id: UUID,
fileName: String,
fileURL: URL,
originalFileName: String,
sessionID: UUID?,
pageCount: Int,
sentAt: Date
) {
self.id = id
self.fileName = fileName
self.fileURL = fileURL
self.originalFileName = originalFileName
self.sessionID = sessionID
self.pageCount = pageCount
self.sentAt = sentAt
}
}
/// A capture PNG on disk under an archived session, listed for re-send.
/// `path` is the real file; HistoryStore never copies images.
public struct ImageRef: Sendable, Equatable {
public let path: URL
public let capturedAt: Date
public let sessionID: UUID
public init(path: URL, capturedAt: Date, sessionID: UUID) {
self.path = path
self.capturedAt = capturedAt
self.sessionID = sessionID
}
}
public actor HistoryStore {
public static let pdfRetentionCount = 10
public static let imageRetentionCount = 30
private let paths: AppSupportPaths
private let historyRoot: URL
private let pdfsDirectory: URL
private let manifestURL: URL
private var entries: [HistoryEntry]
public init(paths: AppSupportPaths) throws {
self.paths = paths
self.historyRoot = paths.root.appendingPathComponent("history", isDirectory: true)
self.pdfsDirectory = historyRoot.appendingPathComponent("pdfs", isDirectory: true)
self.manifestURL = historyRoot.appendingPathComponent("history.json")
do {
try FileManager.default.createDirectory(at: historyRoot, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: pdfsDirectory, withIntermediateDirectories: true)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: historyRoot.path,
underlying: error.localizedDescription
)
}
self.entries = try Self.loadEntries(from: manifestURL, pdfsDirectory: pdfsDirectory)
}
/// Copies `sourceURL` into `history/pdfs/` (never moves or writes the
/// user's file), appends an entry, then keeps the 10 newest PDF copies.
public func recordSentPDF(
sourceURL: URL,
sessionID: UUID?,
pageCount: Int,
sentAt: Date
) throws -> HistoryEntry {
let fm = FileManager.default
guard fm.fileExists(atPath: sourceURL.path) else {
throw ShotdeckError.spoolWriteFailed(
path: sourceURL.path,
underlying: "source PDF does not exist"
)
}
let data: Data
do {
data = try Data(contentsOf: sourceURL)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: sourceURL.path,
underlying: error.localizedDescription
)
}
let id = UUID()
let fileName = "\(DubaiTime.fileStamp(sentAt))-\(id.uuidString.lowercased()).pdf"
let destURL = pdfsDirectory.appendingPathComponent(fileName)
try AtomicFile.write(data, to: destURL)
let entry = HistoryEntry(
id: id,
fileName: fileName,
fileURL: destURL,
originalFileName: sourceURL.lastPathComponent,
sessionID: sessionID,
pageCount: pageCount,
sentAt: sentAt
)
entries.append(entry)
try prunePDFEntries()
return entry
}
/// Newest first, at most `pdfRetentionCount`.
public func listPDFs() -> [HistoryEntry] {
Array(sortedPDFs().prefix(Self.pdfRetentionCount))
}
/// The newest capture PNGs across `archive/` sessions (including
/// `removed/`). Does not copy files and does not look at the open spool.
public func listImages(limit: Int = 30) throws -> [ImageRef] {
let images = try collectArchivedImages()
return images.prefix(max(limit, 0)).map(\.ref)
}
/// Across archived sessions only: keep the 30 newest PNGs total (including
/// `removed/`); delete older PNGs, drop them from `session.json`, and
/// remove a session directory left with zero PNGs. Never touches spool/.
public func pruneImages() throws {
let fm = FileManager.default
let images = try collectArchivedImages()
let keepCount = Self.imageRetentionCount
let doomed = Array(images.dropFirst(keepCount))
guard !doomed.isEmpty else { return }
var remainingBySession: [UUID: CaptureSession] = [:]
var dirBySession: [UUID: URL] = [:]
for item in images {
remainingBySession[item.session.id] = item.session
dirBySession[item.session.id] = item.sessionDir
}
var droppedIDs: [UUID: Set<UUID>] = [:]
for item in doomed {
try deleteIfPrunableImage(item.ref.path)
if let captureID = item.captureID {
droppedIDs[item.session.id, default: []].insert(captureID)
}
}
for (sessionID, ids) in droppedIDs {
guard var session = remainingBySession[sessionID] else { continue }
for id in ids {
session = session.removing(captureID: id)
}
remainingBySession[sessionID] = session
}
let touchedIDs = Set(doomed.map(\.session.id))
for sessionID in touchedIDs {
guard let sessionDir = dirBySession[sessionID] else { continue }
guard isUnderArchive(sessionDir), !isUnderSpool(sessionDir) else { continue }
if pngsRemaining(in: sessionDir).isEmpty {
if fm.fileExists(atPath: sessionDir.path) {
try fm.removeItem(at: sessionDir)
Log.spool.warning("Pruned \(sessionDir.path, privacy: .public)")
}
try AtomicFile.fsyncDirectory(at: paths.archive)
continue
}
if let session = remainingBySession[sessionID], droppedIDs[sessionID] != nil {
try AtomicFile.writeJSON(
session,
to: sessionDir.appendingPathComponent("session.json")
)
}
}
}
// MARK: - PDF retention
private func prunePDFEntries() throws {
let sorted = sortedPDFs()
let kept = Array(sorted.prefix(Self.pdfRetentionCount))
let discarded = sorted.dropFirst(Self.pdfRetentionCount)
for entry in discarded {
let url = pdfsDirectory.appendingPathComponent(entry.fileName)
try deleteIfAppManagedPDF(url)
}
entries = kept
try persistEntries()
}
private func sortedPDFs() -> [HistoryEntry] {
entries.sorted { lhs, rhs in
if lhs.sentAt != rhs.sentAt { return lhs.sentAt > rhs.sentAt }
return lhs.id.uuidString > rhs.id.uuidString
}
}
private func persistEntries() throws {
try AtomicFile.writeJSON(entries, to: manifestURL)
}
private func deleteIfAppManagedPDF(_ url: URL) throws {
guard isUnderPDFs(url) else { return }
let fm = FileManager.default
guard fm.fileExists(atPath: url.path) else { return }
do {
try fm.removeItem(at: url)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: url.path,
underlying: error.localizedDescription
)
}
Log.spool.warning("Pruned \(url.path, privacy: .public)")
try AtomicFile.fsyncDirectory(at: pdfsDirectory)
}
// MARK: - Archived images
private struct ArchivedImage {
let ref: ImageRef
let session: CaptureSession
let sessionDir: URL
let captureID: UUID?
}
private func collectArchivedImages() throws -> [ArchivedImage] {
let dirs = try archivedSessionDirectories()
var collected: [ArchivedImage] = []
for dir in dirs {
let sessionID = UUID(uuidString: dir.lastPathComponent) ?? UUID()
let session = (try? loadSession(at: dir, id: sessionID))
?? CaptureSession(
id: sessionID,
createdAt: fileDate(dir) ?? Date(),
state: .archived,
captures: [],
pdfFileName: nil
)
var referenced = Set<String>()
for capture in session.captures {
let url = dir.appendingPathComponent(capture.fileName)
guard FileManager.default.fileExists(atPath: url.path) else { continue }
referenced.insert(capture.fileName)
collected.append(
ArchivedImage(
ref: ImageRef(path: url, capturedAt: capture.capturedAt, sessionID: session.id),
session: session,
sessionDir: dir,
captureID: capture.id
)
)
}
let removedDir = dir.appendingPathComponent("removed", isDirectory: true)
for url in pngFiles(in: dir) where !referenced.contains(url.lastPathComponent) {
collected.append(
ArchivedImage(
ref: ImageRef(
path: url,
capturedAt: fileDate(url) ?? .distantPast,
sessionID: session.id
),
session: session,
sessionDir: dir,
captureID: nil
)
)
}
for url in pngFiles(in: removedDir) {
collected.append(
ArchivedImage(
ref: ImageRef(
path: url,
capturedAt: fileDate(url) ?? .distantPast,
sessionID: session.id
),
session: session,
sessionDir: dir,
captureID: nil
)
)
}
}
return collected.sorted { lhs, rhs in
if lhs.ref.capturedAt != rhs.ref.capturedAt {
return lhs.ref.capturedAt > rhs.ref.capturedAt
}
return lhs.ref.path.path > rhs.ref.path.path
}
}
private func archivedSessionDirectories() throws -> [URL] {
let fm = FileManager.default
let entries: [URL]
do {
entries = try fm.contentsOfDirectory(
at: paths.archive,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: paths.archive.path,
underlying: error.localizedDescription
)
}
return entries.filter { url in
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
return isDirectory && UUID(uuidString: url.lastPathComponent) != nil
}
}
private func loadSession(at dir: URL, id: UUID) throws -> CaptureSession {
let url = dir.appendingPathComponent("session.json")
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let session = try decoder.decode(CaptureSession.self, from: data)
guard session.id == id else {
throw ShotdeckError.manifestCorrupt(path: url.path)
}
return session
}
private func pngFiles(in dir: URL) -> [URL] {
let fm = FileManager.default
guard fm.fileExists(atPath: dir.path) else { return [] }
let entries = (try? fm.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
)) ?? []
return entries.filter { url in
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
return !isDirectory && url.pathExtension.lowercased() == "png"
}
}
private func pngsRemaining(in sessionDir: URL) -> [URL] {
pngFiles(in: sessionDir)
+ pngFiles(in: sessionDir.appendingPathComponent("removed", isDirectory: true))
}
private func deleteIfPrunableImage(_ url: URL) throws {
guard isUnderArchive(url), !isUnderSpool(url) else { return }
let fm = FileManager.default
guard fm.fileExists(atPath: url.path) else { return }
do {
try fm.removeItem(at: url)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: url.path,
underlying: error.localizedDescription
)
}
Log.spool.warning("Pruned \(url.path, privacy: .public)")
try AtomicFile.fsyncDirectory(at: url.deletingLastPathComponent())
}
private func fileDate(_ url: URL) -> Date? {
let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
return values?.creationDate ?? values?.contentModificationDate
}
// MARK: - Path guards
private func isUnderPDFs(_ url: URL) -> Bool {
isUnderDirectory(url, parent: pdfsDirectory)
}
private func isUnderArchive(_ url: URL) -> Bool {
isUnderDirectory(url, parent: paths.archive)
}
private func isUnderSpool(_ url: URL) -> Bool {
isUnderDirectory(url, parent: paths.spool)
}
private func isUnderDirectory(_ url: URL, parent: URL) -> Bool {
let parentPath = parent.standardizedFileURL.path
let path = url.standardizedFileURL.path
if path == parentPath { return true }
let prefix = parentPath.hasSuffix("/") ? parentPath : parentPath + "/"
return path.hasPrefix(prefix)
}
// MARK: - Manifest load
private static func loadEntries(from url: URL, pdfsDirectory: URL) throws -> [HistoryEntry] {
let fm = FileManager.default
guard fm.fileExists(atPath: url.path) else { return [] }
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: url.path,
underlying: error.localizedDescription
)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
let decoded = try decoder.decode([HistoryEntry].self, from: data)
return decoded.map { entry in
HistoryEntry(
id: entry.id,
fileName: entry.fileName,
fileURL: pdfsDirectory.appendingPathComponent(entry.fileName),
originalFileName: entry.originalFileName,
sessionID: entry.sessionID,
pageCount: entry.pageCount,
sentAt: entry.sentAt
)
}
} catch {
let corruptURL = url.deletingLastPathComponent()
.appendingPathComponent("history.json.corrupt-\(DubaiTime.fileStamp(Date()))")
try? fm.moveItem(at: url, to: corruptURL)
Log.spool.error(
"history.json could not be decoded; moved to \(corruptURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
return []
}
}
}
@@ -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)
}
}
@@ -1,319 +0,0 @@
import CoreGraphics
import Foundation
import ImageIO
import Testing
import ShotdeckCore
@Test
func recordTwelvePDFsKeepsTenNewestAndDeletesOldestCopies() async throws {
let (root, paths) = try makeHistoryPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try HistoryStore(paths: paths)
let sources = root.appendingPathComponent("user-sources", isDirectory: true)
try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true)
var recorded: [HistoryEntry] = []
for i in 0..<12 {
let source = sources.appendingPathComponent("source-\(i).pdf")
try writeDummyPDF(to: source, marker: "pdf-\(i)")
let sentAt = Date(timeIntervalSince1970: 1_800_000_000 + TimeInterval(i))
let entry = try await store.recordSentPDF(
sourceURL: source,
sessionID: UUID(),
pageCount: i + 1,
sentAt: sentAt
)
recorded.append(entry)
}
let listed = await store.listPDFs()
#expect(listed.count == 10)
#expect(listed.map(\.id) == recorded.suffix(10).reversed().map(\.id))
#expect(listed.map(\.sentAt) == recorded.suffix(10).reversed().map(\.sentAt))
let pdfsDir = paths.root.appendingPathComponent("history/pdfs", isDirectory: true)
for entry in recorded.prefix(2) {
#expect(!FileManager.default.fileExists(atPath: pdfsDir.appendingPathComponent(entry.fileName).path))
}
for entry in recorded.suffix(10) {
#expect(FileManager.default.fileExists(atPath: pdfsDir.appendingPathComponent(entry.fileName).path))
}
}
@Test
func pruneImagesKeepsThirtyNewestAcrossThreeArchivedSessionsAndLeavesOpenSessionAlone() async throws {
let (root, paths) = try makeHistoryPaths()
defer { try? FileManager.default.removeItem(at: root) }
let spool = try SpoolStore(paths: paths)
let base = Date(timeIntervalSince1970: 1_800_100_000)
var captures: [(id: UUID, capturedAt: Date, sessionID: UUID)] = []
// 5 oldest + 15 + 15 = 35 archived PNGs. The oldest session is emptied by prune.
let perSession = [5, 15, 15]
var index = 0
for count in perSession {
let sessionID = try await spool.currentSession().id
for _ in 0..<count {
let capturedAt = base.addingTimeInterval(TimeInterval(index))
let capture = try await spool.append(
pngData: try makeHistoryPNGData(width: 6, height: 4, red: 0.2, green: 0.3, blue: 0.4),
pixelWidth: 6,
pixelHeight: 4,
scale: 1.0,
capturedAt: capturedAt
)
captures.append((capture.id, capturedAt, sessionID))
index += 1
}
_ = try await spool.archiveCurrent(pdfFileName: "Redline-hist-\(sessionID.uuidString).pdf")
}
let openBefore = try await spool.currentSession()
let openCapture = try await spool.append(
pngData: try makeHistoryPNGData(width: 8, height: 6, red: 0.9, green: 0.1, blue: 0.1),
pixelWidth: 8,
pixelHeight: 6,
scale: 1.0,
capturedAt: Date(timeIntervalSince1970: 1_900_000_000)
)
let openSession = try await spool.currentSession()
#expect(openSession.id == openBefore.id)
let openDir = paths.sessionDirectory(openSession.id)
let openPNG = openDir.appendingPathComponent(openCapture.fileName)
let openPNGBytes = try Data(contentsOf: openPNG)
let openManifest = try Data(contentsOf: openDir.appendingPathComponent("session.json"))
let store = try HistoryStore(paths: paths)
try await store.pruneImages()
let remaining = try await store.listImages(limit: 50)
#expect(remaining.count == 30)
let newestThirty = Array(captures.suffix(30))
let remainingDates = Set(remaining.map(\.capturedAt))
#expect(remainingDates == Set(newestThirty.map(\.capturedAt)))
#expect(remaining.map(\.sessionID).allSatisfy { $0 != openSession.id })
let oldestFive = Array(captures.prefix(5))
for old in oldestFive {
let archiveDir = paths.archiveDirectory(old.sessionID)
#expect(!FileManager.default.fileExists(atPath: archiveDir.path))
}
#expect(pngFiles(under: paths.archive).count == 30)
try assertManifestsMatchDisk(archiveRoot: paths.archive)
#expect(FileManager.default.fileExists(atPath: openPNG.path))
#expect(try Data(contentsOf: openPNG) == openPNGBytes)
#expect(try Data(contentsOf: openDir.appendingPathComponent("session.json")) == openManifest)
#expect(try await spool.currentSession().captures.map(\.id) == [openCapture.id])
}
@Test
func recordSentPDFLeavesTheUserSourceUntouched() async throws {
let (root, paths) = try makeHistoryPaths()
defer { try? FileManager.default.removeItem(at: root) }
let source = root.appendingPathComponent("outside-appsupport.pdf")
let payload = Data("%PDF-1.4\n%user-original\n%%EOF\n".utf8)
try payload.write(to: source)
let store = try HistoryStore(paths: paths)
let entry = try await store.recordSentPDF(
sourceURL: source,
sessionID: UUID(),
pageCount: 2,
sentAt: Date(timeIntervalSince1970: 1_800_200_000)
)
#expect(FileManager.default.fileExists(atPath: source.path))
#expect(try Data(contentsOf: source) == payload)
#expect(entry.fileURL.path != source.path)
#expect(try Data(contentsOf: entry.fileURL) == payload)
#expect(entry.fileURL.path.hasPrefix(
paths.root.appendingPathComponent("history/pdfs", isDirectory: true).path
))
}
@Test
func recordingTheSameSourceTwiceMakesTwoDistinctCopies() async throws {
let (root, paths) = try makeHistoryPaths()
defer { try? FileManager.default.removeItem(at: root) }
let source = root.appendingPathComponent("resend.pdf")
try writeDummyPDF(to: source, marker: "same-source")
let store = try HistoryStore(paths: paths)
let first = try await store.recordSentPDF(
sourceURL: source,
sessionID: nil,
pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_300_000)
)
let second = try await store.recordSentPDF(
sourceURL: source,
sessionID: nil,
pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_300_001)
)
#expect(first.id != second.id)
#expect(first.fileName != second.fileName)
#expect(first.fileURL.path != second.fileURL.path)
#expect(FileManager.default.fileExists(atPath: first.fileURL.path))
#expect(FileManager.default.fileExists(atPath: second.fileURL.path))
let firstBytes = try Data(contentsOf: first.fileURL)
let secondBytes = try Data(contentsOf: second.fileURL)
#expect(firstBytes == secondBytes)
#expect(FileManager.default.fileExists(atPath: source.path))
let listed = await store.listPDFs()
#expect(listed.count == 2)
#expect(Set(listed.map(\.id)) == [first.id, second.id])
}
@Test
func pruneImagesDeletesOlderPNGsInRemovedFolders() async throws {
let (root, paths) = try makeHistoryPaths()
defer { try? FileManager.default.removeItem(at: root) }
let spool = try SpoolStore(paths: paths)
let base = Date(timeIntervalSince1970: 1_800_400_000)
for i in 0..<30 {
_ = try await spool.append(
pngData: try makeHistoryPNGData(width: 4, height: 4, red: 0.1, green: 0.2, blue: 0.3),
pixelWidth: 4,
pixelHeight: 4,
scale: 1.0,
capturedAt: base.addingTimeInterval(TimeInterval(10 + i))
)
}
_ = try await spool.archiveCurrent(pdfFileName: "Redline-keep.pdf")
let oldSessionID = UUID()
let oldDir = paths.archiveDirectory(oldSessionID)
let removedDir = oldDir.appendingPathComponent("removed", isDirectory: true)
try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true)
let oldPNG = removedDir.appendingPathComponent("001-DEADBEEF.png")
try makeHistoryPNGData(width: 4, height: 4, red: 0.5, green: 0.5, blue: 0.5).write(to: oldPNG)
try FileManager.default.setAttributes(
[.creationDate: base],
ofItemAtPath: oldPNG.path
)
let oldSession = CaptureSession(
id: oldSessionID,
createdAt: base,
state: .archived,
captures: [],
pdfFileName: "Redline-old.pdf"
)
try AtomicFile.writeJSON(oldSession, to: oldDir.appendingPathComponent("session.json"))
let store = try HistoryStore(paths: paths)
try await store.pruneImages()
#expect(!FileManager.default.fileExists(atPath: oldPNG.path))
#expect(pngFiles(under: paths.archive).count == 30)
}
// MARK: - Fixtures
private func makeHistoryPaths() throws -> (root: URL, paths: AppSupportPaths) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-history-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: root.appendingPathComponent("root", isDirectory: true),
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
return (root, paths)
}
private func writeDummyPDF(to url: URL, marker: String) throws {
try Data("%PDF-1.4\n%\(marker)\n%%EOF\n".utf8).write(to: url)
}
private func makeHistoryPNGData(
width: Int,
height: Int,
red: CGFloat,
green: CGFloat,
blue: CGFloat
) throws -> Data {
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw HistoryFixtureError.pngGenerationFailed
}
context.setFillColor(red: red, green: green, blue: blue, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let image = context.makeImage() else {
throw HistoryFixtureError.pngGenerationFailed
}
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else {
throw HistoryFixtureError.pngGenerationFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw HistoryFixtureError.pngGenerationFailed
}
return buffer as Data
}
private func pngFiles(under root: URL) -> [URL] {
let fm = FileManager.default
guard let enumerator = fm.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: []
) else { return [] }
var urls: [URL] = []
for case let url as URL in enumerator {
let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) ?? false
if isFile, url.pathExtension.lowercased() == "png" {
urls.append(url)
}
}
return urls
}
private func assertManifestsMatchDisk(archiveRoot: URL) throws {
let fm = FileManager.default
let sessions = (try fm.contentsOfDirectory(
at: archiveRoot,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
)).filter {
((try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false)
&& UUID(uuidString: $0.lastPathComponent) != nil
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
for dir in sessions {
let manifestURL = dir.appendingPathComponent("session.json")
#expect(fm.fileExists(atPath: manifestURL.path))
let session = try decoder.decode(CaptureSession.self, from: Data(contentsOf: manifestURL))
for capture in session.captures {
let url = dir.appendingPathComponent(capture.fileName)
#expect(fm.fileExists(atPath: url.path))
}
let topLevelPNGs = (try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: []))
.filter { $0.pathExtension.lowercased() == "png" }
let manifestNames = Set(session.captures.map(\.fileName))
#expect(Set(topLevelPNGs.map(\.lastPathComponent)) == manifestNames)
}
}
private enum HistoryFixtureError: Error {
case pngGenerationFailed
}
@@ -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
}