Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
209921f084 | ||
|
|
2937d2d5e0 | ||
|
|
80a128b667 | ||
|
|
78cc7d5b1a | ||
|
|
6449c72b3b | ||
|
|
299d55e884 | ||
|
|
380b704f8a |
@@ -32,6 +32,14 @@ public final class AppModel {
|
|||||||
public private(set) var isSending: Bool = false
|
public private(set) var isSending: Bool = false
|
||||||
public private(set) var outboxDisplayName: String
|
public private(set) var outboxDisplayName: String
|
||||||
public private(set) var watchFolderDisplayName: String
|
public private(set) var watchFolderDisplayName: String
|
||||||
|
/// Live transport choice; WP-onedrive reads this to pick the send path and to drive
|
||||||
|
/// the Settings "Send via" picker and the menu's "Send…" label.
|
||||||
|
public private(set) var transport: SendTransport
|
||||||
|
/// Ground truth for the Settings OneDrive row: nil means "no OneDrive folder found".
|
||||||
|
/// Views read this instead of calling `OneDriveLocator.resolveOneDriveFolder()`
|
||||||
|
/// directly, so state (and testing with a fake home) flows through the model like
|
||||||
|
/// everything else — never a View reaching past the model for real UserDefaults/home.
|
||||||
|
public private(set) var resolvedOneDriveFolder: URL?
|
||||||
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
||||||
public private(set) var outboxURL: URL
|
public private(set) var outboxURL: URL
|
||||||
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
||||||
@@ -52,7 +60,6 @@ public final class AppModel {
|
|||||||
let picker: RegionPickerController
|
let picker: RegionPickerController
|
||||||
let ledger: ReturnLedger
|
let ledger: ReturnLedger
|
||||||
let watcher: ReturnWatcher
|
let watcher: ReturnWatcher
|
||||||
let historyStore: HistoryStore
|
|
||||||
let updateChecker: UpdateChecker
|
let updateChecker: UpdateChecker
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -64,7 +71,7 @@ public final class AppModel {
|
|||||||
picker: RegionPickerController,
|
picker: RegionPickerController,
|
||||||
ledger: ReturnLedger,
|
ledger: ReturnLedger,
|
||||||
watcher: ReturnWatcher
|
watcher: ReturnWatcher
|
||||||
) throws {
|
) {
|
||||||
self.paths = paths
|
self.paths = paths
|
||||||
self.spool = spool
|
self.spool = spool
|
||||||
self.composer = composer
|
self.composer = composer
|
||||||
@@ -73,7 +80,6 @@ public final class AppModel {
|
|||||||
self.picker = picker
|
self.picker = picker
|
||||||
self.ledger = ledger
|
self.ledger = ledger
|
||||||
self.watcher = watcher
|
self.watcher = watcher
|
||||||
self.historyStore = try HistoryStore(paths: paths)
|
|
||||||
self.session = CaptureSession(
|
self.session = CaptureSession(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -83,12 +89,16 @@ public final class AppModel {
|
|||||||
)
|
)
|
||||||
self.region = Self.loadPersistedRegion()
|
self.region = Self.loadPersistedRegion()
|
||||||
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
||||||
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths — never .standard().
|
// Seeded from TransportSettings.effectiveFolders() — the one place that combines
|
||||||
let folders = FolderSettings.resolve()
|
// the transport choice with FolderSettings/OneDriveLocator. Never call
|
||||||
|
// FolderSettings.resolve() directly outside that function.
|
||||||
|
let folders = TransportSettings.effectiveFolders()
|
||||||
self.outboxURL = folders.outbox
|
self.outboxURL = folders.outbox
|
||||||
self.watchFolderURL = folders.watch
|
self.watchFolderURL = folders.watch
|
||||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||||
|
self.transport = folders.transport
|
||||||
|
self.resolvedOneDriveFolder = OneDriveLocator.resolveOneDriveFolder()
|
||||||
self.captureHotkey = HotkeyPreference.load()
|
self.captureHotkey = HotkeyPreference.load()
|
||||||
self.updateChecker = UpdateChecker()
|
self.updateChecker = UpdateChecker()
|
||||||
self.updateChecker.onChecked = { [weak self] in
|
self.updateChecker.onChecked = { [weak self] in
|
||||||
@@ -120,6 +130,8 @@ public final class AppModel {
|
|||||||
watchFolderURL = watch
|
watchFolderURL = watch
|
||||||
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
||||||
}
|
}
|
||||||
|
func setTransport(_ value: SendTransport) { transport = value }
|
||||||
|
func setResolvedOneDriveFolder(_ value: URL?) { resolvedOneDriveFolder = value }
|
||||||
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
||||||
|
|
||||||
/// True when a last-composed PDF path is known this run, or the newest
|
/// True when a last-composed PDF path is known this run, or the newest
|
||||||
@@ -187,6 +199,17 @@ public final class AppModel {
|
|||||||
// Empty ledger on first run is not an error.
|
// Empty ledger on first run is not an error.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OneDrive mode: outbox == watch folder, so a freshly written, unmarked PDF must
|
||||||
|
// never show up as a return; only a document that already carries a mark does.
|
||||||
|
// Also make sure the resolved OneDrive folder actually exists before the
|
||||||
|
// watcher starts watching it (bootstrap is the other creation trigger besides
|
||||||
|
// chooseTransport/chooseOneDriveFolder — see TransportSettings.effectiveFolders).
|
||||||
|
if transport == .oneDrive {
|
||||||
|
try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||||
|
await watcher.setRecordUncommented(transport == .airDrop)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await watcher.start { [weak self] _ in
|
try await watcher.start { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -210,7 +233,8 @@ public final class AppModel {
|
|||||||
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
|
||||||
if !skipSchedule {
|
if !skipSchedule {
|
||||||
updateChecker.startSchedule()
|
updateChecker.startSchedule()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ struct MenuBarView: View {
|
|||||||
model.setStatus("Send is not available in this build.")
|
model.setStatus("Send is not available in this build.")
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
actionLabel("Send…")
|
actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…")
|
||||||
}
|
}
|
||||||
.disabled(model.session.isEmpty || model.isSending)
|
.disabled(model.session.isEmpty || model.isSending)
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,28 @@ enum PanelSnapshot {
|
|||||||
try renderMenuBar(model: model, to: directory, name: "03-empty-session")
|
try renderMenuBar(model: model, to: directory, name: "03-empty-session")
|
||||||
|
|
||||||
// 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode.
|
// 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode.
|
||||||
|
try await addSampleCaptures(to: model)
|
||||||
|
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
|
||||||
|
|
||||||
|
// 05 — two inspected PDFs in the temp ledger, one marked / one not.
|
||||||
|
try await seedReturns(model: model)
|
||||||
|
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
|
||||||
|
|
||||||
|
// 06 — SettingsView against the same isolated model.
|
||||||
|
try render(
|
||||||
|
SettingsView().environment(model),
|
||||||
|
to: directory.appendingPathComponent("panel-06-settings.png")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 07/08/09 — OneDrive-mode Settings + menu bar, on a SEPARATE isolated model so
|
||||||
|
// this transport switch never bleeds into the AirDrop-mode panels above.
|
||||||
|
try await captureOneDrivePanels(to: directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three real PNGs appended to the given model's temp spool so SessionStrip
|
||||||
|
/// thumbnails decode. Shared by panel 04 (AirDrop) and panel 09 (OneDrive).
|
||||||
|
@MainActor
|
||||||
|
private static func addSampleCaptures(to model: AppModel) async throws {
|
||||||
let swatches: [(CGFloat, CGFloat, CGFloat)] = [
|
let swatches: [(CGFloat, CGFloat, CGFloat)] = [
|
||||||
(0.85, 0.22, 0.18),
|
(0.85, 0.22, 0.18),
|
||||||
(0.18, 0.62, 0.32),
|
(0.18, 0.62, 0.32),
|
||||||
@@ -77,17 +99,62 @@ enum PanelSnapshot {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
model.replaceSession(try await model.spool.currentSession())
|
model.replaceSession(try await model.spool.currentSession())
|
||||||
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
|
}
|
||||||
|
|
||||||
// 05 — two inspected PDFs in the temp ledger, one marked / one not.
|
/// Panels 07-09: OneDrive transport, on its own isolated model/temp root so
|
||||||
try await seedReturns(model: model)
|
/// switching transport here never touches the AirDrop-mode model above, the real
|
||||||
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
|
/// home directory, or UserDefaults.standard. The "resolved" and "not found" states
|
||||||
|
/// are produced by calling the real OneDriveLocator functions against fake home
|
||||||
|
/// trees built under this snapshot's own temp root — never a hand-typed path.
|
||||||
|
@MainActor
|
||||||
|
private static func captureOneDrivePanels(to directory: URL) async throws {
|
||||||
|
let (model, root) = try makeIsolatedModel()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
model.setTransport(.oneDrive)
|
||||||
|
|
||||||
// 06 — SettingsView against the same isolated model.
|
// 07 — a resolved OneDrive folder, shaped like the real default
|
||||||
|
// (…/Library/CloudStorage/OneDrive-MMDGROUP/Redline): a fake home tree with a
|
||||||
|
// real OneDrive-MMDGROUP directory under it, resolved via the same pure
|
||||||
|
// OneDriveLocator function production code uses — never a hand-typed path.
|
||||||
|
let fakeHomeWithOneDrive = root.appendingPathComponent("fake-home-with-onedrive", isDirectory: true)
|
||||||
|
let syncRoot = fakeHomeWithOneDrive
|
||||||
|
.appendingPathComponent("Library/CloudStorage/OneDrive-MMDGROUP", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: syncRoot, withIntermediateDirectories: true)
|
||||||
|
guard let resolvedFolder = OneDriveLocator.defaultRedlineFolder(
|
||||||
|
home: fakeHomeWithOneDrive, fileManager: .default
|
||||||
|
) else {
|
||||||
|
throw SnapshotError.oneDriveFixtureFailed("fake OneDrive-MMDGROUP root did not resolve")
|
||||||
|
}
|
||||||
|
model.setResolvedOneDriveFolder(resolvedFolder)
|
||||||
try render(
|
try render(
|
||||||
SettingsView().environment(model),
|
SettingsView().environment(model),
|
||||||
to: directory.appendingPathComponent("panel-06-settings.png")
|
to: directory.appendingPathComponent("panel-07-settings-onedrive.png")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 08 — no OneDrive folder found: a fake home with NO Library/CloudStorage at
|
||||||
|
// all, and a throwaway UserDefaults suite (never .standard, never touched
|
||||||
|
// before) so the stored-override check also legitimately finds nothing.
|
||||||
|
let fakeHomeWithoutOneDrive = root.appendingPathComponent("fake-home-without-onedrive", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: fakeHomeWithoutOneDrive, withIntermediateDirectories: true)
|
||||||
|
let isolatedDefaults = try makeIsolatedDefaultsSuite()
|
||||||
|
defer { isolatedDefaults.defaults.removePersistentDomain(forName: isolatedDefaults.suiteName) }
|
||||||
|
let missingFolder = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: isolatedDefaults.defaults, home: fakeHomeWithoutOneDrive, fileManager: .default
|
||||||
|
)
|
||||||
|
guard missingFolder == nil else {
|
||||||
|
throw SnapshotError.oneDriveFixtureFailed("fake home without OneDrive unexpectedly resolved")
|
||||||
|
}
|
||||||
|
model.setResolvedOneDriveFolder(nil)
|
||||||
|
try render(
|
||||||
|
SettingsView().environment(model),
|
||||||
|
to: directory.appendingPathComponent("panel-08-settings-onedrive-missing.png")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 09 — menu bar panel, 3 captures present, OneDrive mode ("Send to OneDrive").
|
||||||
|
model.snapshotSetScreenRecordingGranted(true)
|
||||||
|
model.replaceRegion(sampleRegion())
|
||||||
|
try await addSampleCaptures(to: model)
|
||||||
|
try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -163,7 +230,7 @@ enum PanelSnapshot {
|
|||||||
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
||||||
)
|
)
|
||||||
let ledger = try ReturnLedger(paths: paths)
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
let model = try AppModel(
|
let model = AppModel(
|
||||||
paths: paths,
|
paths: paths,
|
||||||
spool: try SpoolStore(paths: paths),
|
spool: try SpoolStore(paths: paths),
|
||||||
composer: PDFComposer(),
|
composer: PDFComposer(),
|
||||||
@@ -177,6 +244,23 @@ enum PanelSnapshot {
|
|||||||
return (model, root)
|
return (model, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A throwaway UserDefaults suite — never `.standard` — for the panel-08 fixture,
|
||||||
|
/// the same isolation pattern ShotdeckCoreTests uses for TransportSettings/
|
||||||
|
/// OneDriveLocator tests.
|
||||||
|
private struct IsolatedDefaultsSuite {
|
||||||
|
let suiteName: String
|
||||||
|
let defaults: UserDefaults
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeIsolatedDefaultsSuite() throws -> IsolatedDefaultsSuite {
|
||||||
|
let suiteName = "shotdeck-panel-snapshot-\(UUID().uuidString)"
|
||||||
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||||
|
throw SnapshotError.oneDriveFixtureFailed("could not create isolated UserDefaults suite")
|
||||||
|
}
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
return IsolatedDefaultsSuite(suiteName: suiteName, defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func seedReturns(model: AppModel) async throws {
|
private static func seedReturns(model: AppModel) async throws {
|
||||||
let watch = model.paths.watchFolder
|
let watch = model.paths.watchFolder
|
||||||
@@ -283,6 +367,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
|
|||||||
case encodeFailed(String)
|
case encodeFailed(String)
|
||||||
case pngGenerationFailed
|
case pngGenerationFailed
|
||||||
case pdfWriteFailed(String)
|
case pdfWriteFailed(String)
|
||||||
|
case oneDriveFixtureFailed(String)
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -290,6 +375,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
|
|||||||
case .encodeFailed(let name): return "PNG encode failed for \(name)"
|
case .encodeFailed(let name): return "PNG encode failed for \(name)"
|
||||||
case .pngGenerationFailed: return "CoreGraphics PNG generation failed"
|
case .pngGenerationFailed: return "CoreGraphics PNG generation failed"
|
||||||
case .pdfWriteFailed(let name): return "could not write \(name)"
|
case .pdfWriteFailed(let name): return "could not write \(name)"
|
||||||
|
case .oneDriveFixtureFailed(let detail): return "OneDrive snapshot fixture failed: \(detail)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import CoreGraphics
|
|||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import PDFKit
|
||||||
import ShotdeckCore
|
import ShotdeckCore
|
||||||
|
|
||||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||||
@@ -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) {
|
private static func execute(outputDirectory: URL) {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
@@ -131,8 +118,7 @@ enum PickerSelfTest {
|
|||||||
runRegionPersistPhase()
|
runRegionPersistPhase()
|
||||||
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
||||||
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
|
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
|
||||||
// chains to HISTORY, then UPDATE-SELFTEST (or exits if that phase is
|
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
|
||||||
// not requested).
|
|
||||||
runSendTruthPhase()
|
runSendTruthPhase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,166 +167,21 @@ enum PickerSelfTest {
|
|||||||
/// Fail path must leave the session open in the temp spool; success path archives
|
/// 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
|
/// 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
|
/// 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() {
|
private static func runSendTruthPhase() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
try await executeSendTruth()
|
try await executeSendTruth()
|
||||||
print("SEND-TRUTH PASS")
|
print("SEND-TRUTH PASS")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
|
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("SEND-TRUTH FAIL \(error)")
|
print("SEND-TRUTH FAIL \(error)")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
exit(1)
|
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)
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
||||||
)
|
)
|
||||||
let ledger = try ReturnLedger(paths: paths)
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
let model = try AppModel(
|
let model = AppModel(
|
||||||
paths: paths,
|
paths: paths,
|
||||||
spool: try SpoolStore(paths: paths),
|
spool: try SpoolStore(paths: paths),
|
||||||
composer: PDFComposer(),
|
composer: PDFComposer(),
|
||||||
@@ -460,7 +301,7 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
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`.
|
/// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
|
||||||
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -475,7 +316,9 @@ enum PickerSelfTest {
|
|||||||
try await runUpdateSelfTest(outputDirectory: output)
|
try await runUpdateSelfTest(outputDirectory: output)
|
||||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
exit(0)
|
if !startOneDriveSelfTestIfRequested() {
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
} catch let error as UpdateSelfTestError {
|
} catch let error as UpdateSelfTestError {
|
||||||
updateFail(error.description)
|
updateFail(error.description)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -599,7 +442,7 @@ enum PickerSelfTest {
|
|||||||
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
||||||
)
|
)
|
||||||
let ledger = try ReturnLedger(paths: paths)
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
let model = try AppModel(
|
let model = AppModel(
|
||||||
paths: paths,
|
paths: paths,
|
||||||
spool: try SpoolStore(paths: paths),
|
spool: try SpoolStore(paths: paths),
|
||||||
composer: PDFComposer(),
|
composer: PDFComposer(),
|
||||||
@@ -633,6 +476,194 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real sync root this Mac has; the phase proves the transport against the actual
|
||||||
|
/// OneDrive file provider, never a fake home tree (that is what
|
||||||
|
/// OneDriveLocatorTests in ShotdeckCoreTests are for).
|
||||||
|
private static let realOneDriveSyncRoot = URL(
|
||||||
|
fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Phase 5: proves the OneDrive transport end to end against the real sync root.
|
||||||
|
/// Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` when chained after PICKER/SEND-TRUTH/
|
||||||
|
/// UPDATE-SELFTEST — the exact pattern `startUpdateSelfTestIfRequested` uses for its
|
||||||
|
/// own env var. Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||||
|
@discardableResult
|
||||||
|
private static func startOneDriveSelfTestIfRequested() -> Bool {
|
||||||
|
guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runOneDriveSelfTestAndExit()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for running ONLY this phase, bypassing the on-screen picker chain
|
||||||
|
/// entirely. The harness has no other per-phase selector, so this is the escape
|
||||||
|
/// hatch: `REDLINE_SELFTEST_PHASE=onedrive`.
|
||||||
|
static func runOneDriveOnlyIfRequested() {
|
||||||
|
guard ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same hop as runIfRequested(): a plain main-queue turn after NSApp starts, so
|
||||||
|
// AppKit/PDFKit calls inside the phase are not racing app launch.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
runOneDriveSelfTestAndExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runOneDriveSelfTestAndExit() {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let folder = try await executeOneDriveSelfTest()
|
||||||
|
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(0)
|
||||||
|
} catch let error as OneDriveSelfTestError {
|
||||||
|
oneDriveFail(error.description)
|
||||||
|
} catch {
|
||||||
|
oneDriveFail(String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)`
|
||||||
|
/// against a NEW folder under the real OneDrive sync root, confirms the watcher does
|
||||||
|
/// NOT report the freshly-written unmarked PDF as a return, then adds a real PDFKit
|
||||||
|
/// ink annotation in place (what the iPad does) and confirms the watcher now reports
|
||||||
|
/// it as commented. Never deletes anything under OneDrive — the created folder and
|
||||||
|
/// PDF are left in place for Ben to inspect / for the real iPad round trip.
|
||||||
|
private static func executeOneDriveSelfTest() async throws -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else {
|
||||||
|
throw OneDriveSelfTestError.detail(
|
||||||
|
"real OneDrive sync root not found at \(realOneDriveSyncRoot.path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDefaults.standard is the ONLY defaults instance send()/TransportSettings
|
||||||
|
// actually read at runtime (there is no defaults-threading through AppModel), so
|
||||||
|
// "isolated" here means snapshot-and-restore around the real keys — the same
|
||||||
|
// pattern runRegionPersistPhase already uses for CaptureRegion.defaultsKey.
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
defer {
|
||||||
|
if let previousTransport {
|
||||||
|
defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
}
|
||||||
|
if let previousFolder {
|
||||||
|
defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stamp = DubaiTime.fileStamp(Date())
|
||||||
|
let selftestFolder = realOneDriveSyncRoot
|
||||||
|
.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true)
|
||||||
|
try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: defaults)
|
||||||
|
TransportSettings.setOneDriveFolder(selftestFolder, defaults: defaults)
|
||||||
|
|
||||||
|
// Local spool root only — the outbox/watch folder is the real OneDrive folder.
|
||||||
|
let spoolRoot = fm.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-onedrive-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? fm.removeItem(at: spoolRoot) }
|
||||||
|
|
||||||
|
let paths = try AppSupportPaths(root: spoolRoot, outbox: selftestFolder, watchFolder: selftestFolder)
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
await watcher.setRecordUncommented(false) // OneDrive mode: today's default is AirDrop's `true`.
|
||||||
|
|
||||||
|
let model = AppModel(
|
||||||
|
paths: paths,
|
||||||
|
spool: try SpoolStore(paths: paths),
|
||||||
|
composer: PDFComposer(),
|
||||||
|
capturer: ScreenCapturer(),
|
||||||
|
hotkeys: HotkeyCenter(),
|
||||||
|
picker: RegionPickerController(),
|
||||||
|
ledger: ledger,
|
||||||
|
watcher: watcher
|
||||||
|
)
|
||||||
|
model.setFolderURLs(outbox: selftestFolder, watch: selftestFolder)
|
||||||
|
model.setTransport(.oneDrive)
|
||||||
|
|
||||||
|
let png = try makeTinyPNGData()
|
||||||
|
_ = try await model.spool.append(
|
||||||
|
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date()
|
||||||
|
)
|
||||||
|
model.replaceSession(try await model.spool.currentSession())
|
||||||
|
guard !model.session.isEmpty else {
|
||||||
|
throw OneDriveSelfTestError.detail("seeded session was empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
await model.send(anchor: nil)
|
||||||
|
|
||||||
|
guard let status = model.statusLine, status.hasPrefix("Saved to OneDrive") else {
|
||||||
|
throw OneDriveSelfTestError.detail(
|
||||||
|
"status did not start with 'Saved to OneDrive': \(model.statusLine ?? "nil")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard model.session.isEmpty else {
|
||||||
|
throw OneDriveSelfTestError.detail("session was not archived after the OneDrive send")
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = (try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
guard let pdfURL = written.first(where: { $0.pathExtension.lowercased() == "pdf" }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("no PDF found in \(selftestFolder.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarked so far: the watcher must not treat it as a return.
|
||||||
|
let beforeMarkup = try await watcher.scanNow()
|
||||||
|
guard !beforeMarkup.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("unmarked PDF was reported as returned by scanNow")
|
||||||
|
}
|
||||||
|
let commentedBefore = try await ledger.commented()
|
||||||
|
guard !commentedBefore.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("unmarked PDF was recorded as commented in the ledger")
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the iPad does: mark it up in place with a real ink annotation, then save.
|
||||||
|
guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else {
|
||||||
|
throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.path) to annotate it")
|
||||||
|
}
|
||||||
|
let ink = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 20, y: 20, width: 60, height: 60),
|
||||||
|
forType: .ink,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
let stroke = NSBezierPath()
|
||||||
|
stroke.move(to: NSPoint(x: 20, y: 20))
|
||||||
|
stroke.line(to: NSPoint(x: 80, y: 80))
|
||||||
|
ink.add(stroke)
|
||||||
|
page.addAnnotation(ink)
|
||||||
|
guard document.write(to: pdfURL) else {
|
||||||
|
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterMarkup = try await watcher.scanNow()
|
||||||
|
guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else {
|
||||||
|
throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow")
|
||||||
|
}
|
||||||
|
let commentedAfter = try await ledger.commented()
|
||||||
|
guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented")
|
||||||
|
}
|
||||||
|
|
||||||
|
return selftestFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func oneDriveFail(_ detail: String) -> Never {
|
||||||
|
print("ONEDRIVE-SELFTEST FAIL \(detail)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
private static func interpolate(_ step: Int) -> NSPoint {
|
private static func interpolate(_ step: Int) -> NSPoint {
|
||||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||||
return NSPoint(
|
return NSPoint(
|
||||||
@@ -699,7 +730,7 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum HistorySelfTestError: Error, CustomStringConvertible {
|
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
|
||||||
case detail(String)
|
case detail(String)
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
@@ -16,6 +16,33 @@ extension AppModel: SendCapable {
|
|||||||
guard !session.isEmpty, !isSending else { return }
|
guard !session.isEmpty, !isSending else { return }
|
||||||
setSending(true)
|
setSending(true)
|
||||||
|
|
||||||
|
let transport = TransportSettings.transport()
|
||||||
|
|
||||||
|
// OneDrive mode: verify the real destination exists RIGHT NOW, before composing
|
||||||
|
// anything. `outboxURL` is kept in sync with the resolved OneDrive folder by
|
||||||
|
// bootstrap/chooseTransport/chooseOneDriveFolder, but this is re-resolved fresh
|
||||||
|
// here (never trusted stale) so a folder that vanished since then (OneDrive
|
||||||
|
// signed out, external volume unmounted, folder deleted) is caught instead of
|
||||||
|
// silently writing into whatever `outboxURL` happens to hold.
|
||||||
|
if transport == .oneDrive {
|
||||||
|
guard let folder = OneDriveLocator.resolveOneDriveFolder(),
|
||||||
|
Self.directoryExists(at: folder)
|
||||||
|
else {
|
||||||
|
let path = OneDriveLocator.resolveOneDriveFolder()?.path
|
||||||
|
?? TransportSettings.storedOneDriveFolderPath()
|
||||||
|
?? "no OneDrive folder found"
|
||||||
|
setResolvedOneDriveFolder(nil)
|
||||||
|
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
|
||||||
|
setSending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResolvedOneDriveFolder(folder)
|
||||||
|
if outboxURL != folder || watchFolderURL != folder {
|
||||||
|
setFolderURLs(outbox: folder, watch: folder)
|
||||||
|
try? await watcher.updateWatchFolder(folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pending: ComposedSend
|
let pending: ComposedSend
|
||||||
do {
|
do {
|
||||||
pending = try await composePDFForSend()
|
pending = try await composePDFForSend()
|
||||||
@@ -27,32 +54,51 @@ extension AppModel: SendCapable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let anchor else {
|
switch transport {
|
||||||
handleDidFailToShareItems(fileName: pending.fileName)
|
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)
|
setSending(false)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
case .airDrop:
|
||||||
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
|
guard let anchor else {
|
||||||
guard let self else { return }
|
handleDidFailToShareItems(fileName: pending.fileName)
|
||||||
if success {
|
setSending(false)
|
||||||
await self.handleDidShareItems(
|
return
|
||||||
fileName: pending.fileName,
|
}
|
||||||
pageCount: pending.pageCount
|
|
||||||
)
|
do {
|
||||||
} else {
|
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
|
||||||
self.handleDidFailToShareItems(fileName: pending.fileName)
|
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
|
/// Writes the PDF to the outbox and records its path. Does not archive the session
|
||||||
/// and does not present AirDrop — that happens only after the share completes.
|
/// and does not present AirDrop — that happens only after the share completes.
|
||||||
func composePDFForSend() async throws -> ComposedSend {
|
func composePDFForSend() async throws -> ComposedSend {
|
||||||
@@ -100,8 +146,6 @@ extension AppModel: SendCapable {
|
|||||||
/// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam.
|
/// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam.
|
||||||
func handleDidShareItems(fileName: String, pageCount: Int) async {
|
func handleDidShareItems(fileName: String, pageCount: Int) async {
|
||||||
guard !session.isEmpty else { return }
|
guard !session.isEmpty else { return }
|
||||||
let sentSessionID = session.id
|
|
||||||
let sourceURL = lastComposedPDFURL ?? outboxURL.appendingPathComponent(fileName)
|
|
||||||
do {
|
do {
|
||||||
_ = try await spool.archiveCurrent(pdfFileName: fileName)
|
_ = try await spool.archiveCurrent(pdfFileName: fileName)
|
||||||
replaceSession(try await spool.currentSession())
|
replaceSession(try await spool.currentSession())
|
||||||
@@ -109,20 +153,6 @@ extension AppModel: SendCapable {
|
|||||||
setStatus("Sent — \(pageCount) \(pageWord).")
|
setStatus("Sent — \(pageCount) \(pageWord).")
|
||||||
} catch {
|
} catch {
|
||||||
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.")
|
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)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ struct SettingsView: View {
|
|||||||
.frame(minHeight: 22)
|
.frame(minHeight: 22)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Send via")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.gridCellColumns(2)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Picker("Send via", selection: transportBinding) {
|
||||||
|
ForEach(SendTransport.allCases, id: \.self) { transport in
|
||||||
|
Text(transport.displayName).tag(transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.gridCellColumns(2)
|
||||||
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("Folders")
|
Text("Folders")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -41,17 +60,48 @@ struct SettingsView: View {
|
|||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
GridRow(alignment: .center) {
|
if model.transport == .airDrop {
|
||||||
fieldLabel("Watch folder")
|
GridRow(alignment: .center) {
|
||||||
folderValue(path: model.watchFolderURL.path) {
|
fieldLabel("Watch folder")
|
||||||
model.chooseWatchFolder()
|
folderValue(path: model.watchFolderURL.path) {
|
||||||
|
model.chooseWatchFolder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
GridRow(alignment: .center) {
|
GridRow(alignment: .center) {
|
||||||
fieldLabel("Output folder")
|
fieldLabel("Output folder")
|
||||||
folderValue(path: model.outboxURL.path) {
|
folderValue(path: model.outboxURL.path) {
|
||||||
model.chooseOutboxFolder()
|
model.chooseOutboxFolder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GridRow(alignment: .center) {
|
||||||
|
fieldLabel("OneDrive folder")
|
||||||
|
if let folder = model.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,10 @@ struct SettingsView: View {
|
|||||||
.onDisappear { disarmHotkeyRecorder() }
|
.onDisappear { disarmHotkeyRecorder() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var transportBinding: Binding<SendTransport> {
|
||||||
|
Binding(get: { model.transport }, set: { model.chooseTransport($0) })
|
||||||
|
}
|
||||||
|
|
||||||
private func armHotkeyRecorder() {
|
private func armHotkeyRecorder() {
|
||||||
guard !isRecordingHotkey else { return }
|
guard !isRecordingHotkey else { return }
|
||||||
isRecordingHotkey = true
|
isRecordingHotkey = true
|
||||||
@@ -190,6 +244,55 @@ 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)
|
||||||
|
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||||
|
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 = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
guard let url = chooseDirectory(startingAt: start) else { return }
|
||||||
|
TransportSettings.setOneDriveFolder(url)
|
||||||
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||||
|
guard transport == .oneDrive else { return }
|
||||||
|
setFolderURLs(outbox: url, watch: url)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await watcher.updateWatchFolder(url)
|
||||||
|
setStatus("OneDrive folder set to \(url.lastPathComponent).")
|
||||||
|
} catch {
|
||||||
|
setStatus(
|
||||||
|
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func chooseDirectory(startingAt directory: URL) -> URL? {
|
private func chooseDirectory(startingAt directory: URL) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseDirectories = true
|
panel.canChooseDirectories = true
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import ShotdeckCore
|
|||||||
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||||
|
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||||
|
}
|
||||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||||
} else if ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"] != nil {
|
|
||||||
MainActor.assumeIsolated { PickerSelfTest.runHistoryIfRequested() }
|
|
||||||
}
|
}
|
||||||
ShotdeckApp.main()
|
ShotdeckApp.main()
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
private static func makeModel(paths: AppSupportPaths) throws -> AppModel {
|
private static func makeModel(paths: AppSupportPaths) throws -> AppModel {
|
||||||
let ledger = try ReturnLedger(paths: paths)
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
return try AppModel(
|
return AppModel(
|
||||||
paths: paths,
|
paths: paths,
|
||||||
spool: try SpoolStore(paths: paths),
|
spool: try SpoolStore(paths: paths),
|
||||||
composer: PDFComposer(),
|
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 pdfCompositionFailed(reason: String)
|
||||||
case airDropUnavailable
|
case airDropUnavailable
|
||||||
case noCommentedReturns
|
case noCommentedReturns
|
||||||
|
case oneDriveFolderUnavailable(path: String)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
|||||||
return "AirDrop is not available right now."
|
return "AirDrop is not available right now."
|
||||||
case .noCommentedReturns:
|
case .noCommentedReturns:
|
||||||
return "None of the returned PDFs have comments on them."
|
return "None of the returned PDFs have comments on them."
|
||||||
|
case .oneDriveFolderUnavailable(let path):
|
||||||
|
return "Your OneDrive folder is not available: \(path). Check that OneDrive is signed in, or choose another folder in Settings."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ public actor ReturnWatcher {
|
|||||||
private var bridge: FSEventBridge?
|
private var bridge: FSEventBridge?
|
||||||
private var pendingScanTask: Task<Void, Never>?
|
private var pendingScanTask: Task<Void, Never>?
|
||||||
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
|
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
|
||||||
|
/// When false, a document with zero human marks is neither recorded into the ledger
|
||||||
|
/// nor included in scanNow's/onChange's results — needed for OneDrive mode, where the
|
||||||
|
/// outbox and watch folder are the same folder and a freshly written, unmarked PDF
|
||||||
|
/// must not be treated as a return. Defaults to true (today's AirDrop behaviour).
|
||||||
|
/// A document that IS commented is always recorded, regardless of this flag.
|
||||||
|
public var recordUncommented: Bool = true
|
||||||
|
|
||||||
/// Watch folder is `paths.watchFolder`, which production constructs from
|
/// Watch folder is `paths.watchFolder`, which production constructs from
|
||||||
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
||||||
@@ -29,6 +35,12 @@ public actor ReturnWatcher {
|
|||||||
onChange(found)
|
onChange(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets `recordUncommented`. A `func` (not a plain property set) only because
|
||||||
|
/// callers outside this actor must `await` it like any other actor mutation.
|
||||||
|
public func setRecordUncommented(_ value: Bool) {
|
||||||
|
recordUncommented = value
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
|
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
|
||||||
/// when never started or already stopped. Cancels any pending debounced scan.
|
/// when never started or already stopped. Cancels any pending debounced scan.
|
||||||
public func stop() {
|
public func stop() {
|
||||||
@@ -77,6 +89,7 @@ public actor ReturnWatcher {
|
|||||||
guard let document = PDFDocument(url: url),
|
guard let document = PDFDocument(url: url),
|
||||||
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
||||||
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
||||||
|
if !recordUncommented, !inspected.isCommented { continue }
|
||||||
try await ledger.record(inspected)
|
try await ledger.record(inspected)
|
||||||
results.append(inspected)
|
results.append(inspected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The two ways a composed PDF can reach the iPad and come back marked up.
|
||||||
|
public enum SendTransport: String, Codable, Sendable, CaseIterable {
|
||||||
|
case airDrop
|
||||||
|
case oneDrive
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .airDrop: return "AirDrop"
|
||||||
|
case .oneDrive: return "OneDrive folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-configurable transport choice plus the OneDrive folder override, backed by
|
||||||
|
/// UserDefaults the same way `FolderSettings` is. See `FolderSettings` for why a plain
|
||||||
|
/// path (not a security-scoped bookmark) is correct for this unsandboxed app.
|
||||||
|
public enum TransportSettings {
|
||||||
|
public static let transportDefaultsKey = "ai.flowmaster.shotdeck.transport"
|
||||||
|
public static let oneDriveFolderDefaultsKey = "ai.flowmaster.shotdeck.oneDriveFolder"
|
||||||
|
|
||||||
|
/// Defaults to `.airDrop` when unset or when the stored value cannot be parsed.
|
||||||
|
public static func transport(defaults: UserDefaults = .standard) -> SendTransport {
|
||||||
|
guard let raw = defaults.string(forKey: transportDefaultsKey),
|
||||||
|
let value = SendTransport(rawValue: raw)
|
||||||
|
else { return .airDrop }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setTransport(_ value: SendTransport, defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(value.rawValue, forKey: transportDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw stored path (or nil if never set / cleared). Does NOT validate that the
|
||||||
|
/// directory still exists.
|
||||||
|
public static func storedOneDriveFolderPath(defaults: UserDefaults = .standard) -> String? {
|
||||||
|
defaults.string(forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setOneDriveFolder(_ url: URL, defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(url.path, forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func resetOneDriveFolder(defaults: UserDefaults = .standard) {
|
||||||
|
defaults.removeObject(forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outbox/watch folders Redline should actually use right now, for the current
|
||||||
|
/// transport. AirDrop mode delegates to `FolderSettings.resolve()` unchanged.
|
||||||
|
/// OneDrive mode uses the SAME folder for both outbox and watch — see
|
||||||
|
/// `OneDriveLocator.resolveOneDriveFolder`. When no OneDrive folder can be resolved
|
||||||
|
/// at all (no sync root, no override), this falls back to the AirDrop folders so the
|
||||||
|
/// app always has somewhere to write; `send(anchor:)` performs its own live
|
||||||
|
/// existence check before ever composing into a OneDrive send, so that fallback is
|
||||||
|
/// never mistaken for a valid OneDrive destination.
|
||||||
|
public static func effectiveFolders(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> (outbox: URL, watch: URL, transport: SendTransport) {
|
||||||
|
let transport = transport(defaults: defaults)
|
||||||
|
switch transport {
|
||||||
|
case .airDrop:
|
||||||
|
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
|
||||||
|
return (folders.outbox, folders.watch, transport)
|
||||||
|
case .oneDrive:
|
||||||
|
if let folder = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: defaults,
|
||||||
|
home: fileManager.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: fileManager
|
||||||
|
) {
|
||||||
|
return (folder, folder, transport)
|
||||||
|
}
|
||||||
|
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
|
||||||
|
return (folders.outbox, folders.watch, transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure path logic for locating a OneDrive sync root under
|
||||||
|
/// `~/Library/CloudStorage` and the Redline folder inside it. No side effects — never
|
||||||
|
/// creates a directory. Fully unit-testable with a fake home tree.
|
||||||
|
public enum OneDriveLocator {
|
||||||
|
/// Every directory directly under `<home>/Library/CloudStorage` whose name starts
|
||||||
|
/// with "OneDrive-", sorted so a name containing "MMD" (case-insensitive) sorts
|
||||||
|
/// first, then alphabetically. Empty when CloudStorage does not exist.
|
||||||
|
public static func syncRoots(
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard fileManager.fileExists(atPath: cloudStorage.path, isDirectory: &isDirectory),
|
||||||
|
isDirectory.boolValue
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
let items = (try? fileManager.contentsOfDirectory(
|
||||||
|
at: cloudStorage,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)) ?? []
|
||||||
|
|
||||||
|
let roots = items.filter { url in
|
||||||
|
guard url.lastPathComponent.hasPrefix("OneDrive-") else { return false }
|
||||||
|
var itemIsDirectory: ObjCBool = false
|
||||||
|
let exists = fileManager.fileExists(atPath: url.path, isDirectory: &itemIsDirectory)
|
||||||
|
return exists && itemIsDirectory.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots.sorted { a, b in
|
||||||
|
let aName = a.lastPathComponent
|
||||||
|
let bName = b.lastPathComponent
|
||||||
|
let aIsMMD = aName.localizedCaseInsensitiveContains("MMD")
|
||||||
|
let bIsMMD = bName.localizedCaseInsensitiveContains("MMD")
|
||||||
|
if aIsMMD != bIsMMD { return aIsMMD }
|
||||||
|
return aName.localizedStandardCompare(bName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First sync root's "Redline" subfolder, or nil when there is no sync root at all.
|
||||||
|
public static func defaultRedlineFolder(
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
guard let first = syncRoots(home: home, fileManager: fileManager).first else { return nil }
|
||||||
|
return first.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored override when it is set AND still exists as a directory; otherwise
|
||||||
|
/// `defaultRedlineFolder`. Never creates anything.
|
||||||
|
public static func resolveOneDriveFolder(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
if let storedPath = TransportSettings.storedOneDriveFolderPath(defaults: defaults) {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory)
|
||||||
|
if exists, isDirectory.boolValue {
|
||||||
|
return URL(fileURLWithPath: storedPath, isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultRedlineFolder(home: home, fileManager: fileManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
await watcher.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("recordUncommented defaults to true: an unmarked PDF is still recorded (AirDrop behaviour unchanged)")
|
||||||
|
func recordUncommentedDefaultTrueRecordsAnUnmarkedPDF() 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("recordUncommented=false: an unmarked PDF is not recorded or returned; marking it up in place gets it recorded")
|
||||||
|
func recordUncommentedFalseSkipsUnmarkedThenRecordsAfterInPlaceMarkup() 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