Compare commits

...
12 changed files with 2713 additions and 4 deletions
+265
View File
@@ -0,0 +1,265 @@
import AppKit
import Carbon.HIToolbox
import Foundation
import Observation
import SwiftUI
import ShotdeckCore
@MainActor
public protocol SendCapable: AnyObject {
func send(anchor: NSView?) async
}
@MainActor
public protocol SettingsWindowPresenting: AnyObject {
func presentSettingsWindow()
}
@MainActor
public protocol ReturnsSectionProviding: AnyObject {
@ViewBuilder func returnsSection() -> AnyView
}
@MainActor
@Observable
public final class AppModel {
public private(set) var session: CaptureSession
public private(set) var region: CaptureRegion?
public private(set) var screenRecordingGranted: Bool
public private(set) var allReturns: [ReturnedDocument] = []
public private(set) var commentedReturns: [ReturnedDocument] = []
public private(set) var statusLine: String?
public private(set) var isCapturing: Bool = false
public private(set) var isSending: Bool = false
public private(set) var outboxDisplayName: String
public private(set) var watchFolderDisplayName: String
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
public private(set) var watchFolderURL: URL
let paths: AppSupportPaths
let spool: SpoolStore
let composer: PDFComposer
let capturer: ScreenCapturer
let hotkeys: HotkeyCenter
let picker: RegionPickerController
let ledger: ReturnLedger
let watcher: ReturnWatcher
public init(
paths: AppSupportPaths,
spool: SpoolStore,
composer: PDFComposer,
capturer: ScreenCapturer,
hotkeys: HotkeyCenter,
picker: RegionPickerController,
ledger: ReturnLedger,
watcher: ReturnWatcher
) {
self.paths = paths
self.spool = spool
self.composer = composer
self.capturer = capturer
self.hotkeys = hotkeys
self.picker = picker
self.ledger = ledger
self.watcher = watcher
self.session = CaptureSession(
id: UUID(),
createdAt: Date(),
state: .open,
captures: [],
pdfFileName: nil
)
self.region = nil
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths never .standard().
let folders = FolderSettings.resolve()
self.outboxURL = folders.outbox
self.watchFolderURL = folders.watch
self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent
}
// MARK: Seam mutators the only way a WP-4b/4c extension changes state.
func setStatus(_ text: String?) { statusLine = text }
func setSending(_ value: Bool) { isSending = value }
func setCapturing(_ value: Bool) { isCapturing = value }
func replaceSession(_ new: CaptureSession) { session = new }
func replaceRegion(_ new: CaptureRegion?) { region = new }
func setReturns(all: [ReturnedDocument], commented: [ReturnedDocument]) {
allReturns = all
commentedReturns = commented
}
func setFolderDisplayNames(outbox: String, watch: String) {
outboxDisplayName = outbox
watchFolderDisplayName = watch
}
func setFolderURLs(outbox: URL, watch: URL) {
outboxURL = outbox
watchFolderURL = watch
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
}
public var iconState: MenuIconState {
if !screenRecordingGranted { return .recordingMissing }
if isCapturing { return .capturing }
if region == nil { return .noRegion }
if session.captures.isEmpty { return .regionEmpty }
return .hasCaptures(session.captures.count)
}
public func bootstrap() async {
if let data = UserDefaults.standard.data(forKey: CaptureRegion.defaultsKey),
let decoded = try? JSONDecoder().decode(CaptureRegion.self, from: data),
decoded.isStillValid {
replaceRegion(decoded)
}
do {
let recovered = try await spool.currentSession()
replaceSession(recovered)
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not open the spool.")
}
do {
let initial = try await ledger.all()
let commented = try await ledger.commented()
setReturns(all: initial, commented: commented)
} catch {
// Empty ledger on first run is not an error.
}
do {
try await watcher.start { [weak self] _ in
Task { @MainActor in
guard let self else { return }
let all = (try? await self.ledger.all()) ?? []
let commented = (try? await self.ledger.commented()) ?? []
self.setReturns(all: all, commented: commented)
}
}
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.")
}
let registered = hotkeys.register(
id: "capture",
keyCode: UInt32(kVK_ANSI_2),
modifiers: UInt32(optionKey | shiftKey)
) { [weak self] in
Task { await self?.captureNow() }
}
if !registered {
setStatus("⌥⇧2 is already used by another app — capture only works from the menu.")
}
}
public func captureNow() async {
guard !isCapturing else { return }
setCapturing(true)
defer { setCapturing(false) }
var target = region
if target == nil {
target = await withCheckedContinuation { (cont: CheckedContinuation<CaptureRegion?, Never>) in
picker.pick { picked in cont.resume(returning: picked) }
}
guard let picked = target else {
setStatus("No region selected.")
return
}
persistRegion(picked)
}
guard let region = target else { return }
do {
let image = try await capturer.capture(region)
let capture = try await spool.append(
pngData: image.pngData,
pixelWidth: image.pixelWidth,
pixelHeight: image.pixelHeight,
scale: image.scale,
capturedAt: Date()
)
replaceSession(try await spool.currentSession())
setStatus("Captured page \(capture.sequence).")
} catch {
screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
setStatus((error as? ShotdeckError)?.errorDescription ?? "The screenshot could not be taken.")
}
}
public func rePickRegion() async {
let picked = await withCheckedContinuation { (cont: CheckedContinuation<CaptureRegion?, Never>) in
picker.pick { cont.resume(returning: $0) }
}
guard let picked else { return }
persistRegion(picked)
setStatus("Region set: \(Int(picked.rect.width)) × \(Int(picked.rect.height)).")
}
public func removeCapture(id: UUID) async {
do {
// D-11: SpoolStore.remove MOVES the PNG to <session>/removed/; it is never unlinked.
replaceSession(try await spool.remove(captureID: id))
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not remove that capture.")
}
}
public func copyCommentedLinks() async {
do {
let text = try await ledger.clipboardText()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
let count = commentedReturns.count
setStatus("Copied \(count) link\(count == 1 ? "" : "s").")
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Nothing to copy.")
}
}
public func openSpoolFolder() {
NSWorkspace.shared.open(paths.spool)
}
public func openScreenRecordingSettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else {
setStatus("Could not open System Settings.")
return
}
NSWorkspace.shared.open(url)
}
private func persistRegion(_ picked: CaptureRegion) {
replaceRegion(picked)
if let encoded = try? JSONEncoder().encode(picked) {
UserDefaults.standard.set(encoded, forKey: CaptureRegion.defaultsKey)
}
}
}
public enum MenuIconState: Equatable {
case noRegion, regionEmpty, hasCaptures(Int), capturing, recordingMissing
public var symbolName: String {
switch self {
case .noRegion: return "viewfinder"
case .regionEmpty: return "viewfinder.rectangular"
case .hasCaptures: return "viewfinder.rectangular"
case .capturing: return "viewfinder.circle.fill"
case .recordingMissing: return "exclamationmark.triangle"
}
}
public var countText: String? {
if case .hasCaptures(let n) = self { return "\(n)" }
return nil
}
}
+180
View File
@@ -0,0 +1,180 @@
import AppKit
import SwiftUI
import ShotdeckCore
struct MenuBarView: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(alignment: .leading, spacing: 8) {
statusRow
SessionStrip()
Divider()
actionsList
if !model.allReturns.isEmpty {
Divider()
returnsBlock
}
}
.padding(10)
.frame(width: 320, alignment: .leading)
.controlSize(.small)
}
@ViewBuilder
private var statusRow: some View {
if !model.screenRecordingGranted {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 4) {
Text(
ShotdeckError.screenRecordingNotGranted.errorDescription
?? "Screen Recording is turned off."
)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
Button("Open Screen Recording settings") {
model.openScreenRecordingSettings()
}
}
}
} else {
Text(model.statusLine ?? defaultStatusText)
.font(.caption)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var defaultStatusText: String {
guard let region = model.region else {
return "No region yet — press ⌥⇧2 to pick one."
}
let w = Int(region.rect.width)
let h = Int(region.rect.height)
let display = displayName(for: region)
if model.session.isEmpty {
return "Region \(w) × \(h) on \(display) · Nothing captured yet."
}
let count = model.session.captures.count
return "\(count) captures · region \(w) × \(h) on \(display)"
}
private func displayName(for region: CaptureRegion) -> String {
for (index, screen) in NSScreen.screens.enumerated() {
let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?
.uint32Value
if id == region.displayID {
return "Display \(index + 1)"
}
}
return "Display 1"
}
private var actionsList: some View {
VStack(alignment: .leading, spacing: 2) {
Button {
let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable {
Task { await sender.send(anchor: anchor) }
} else {
model.setStatus("Send is not available in this build.")
}
} label: {
actionLabel("Send…")
}
.disabled(model.session.isEmpty || model.isSending)
Button {
Task { await model.captureNow() }
} label: {
actionLabel("Capture now", trailing: "⌥⇧2")
}
.disabled(model.isCapturing)
Button {
Task { await model.rePickRegion() }
} label: {
actionLabel("Re-select area")
}
Button {
Task { await model.copyCommentedLinks() }
} label: {
actionLabel(
"Copy commented links",
trailing: model.commentedReturns.isEmpty ? nil : "\(model.commentedReturns.count)"
)
}
.disabled(model.commentedReturns.isEmpty)
Button {
model.openSpoolFolder()
} label: {
actionLabel("Open spool folder")
}
Button {
if let presenter = model as? SettingsWindowPresenting {
presenter.presentSettingsWindow()
} else {
model.setStatus("Settings is not available in this build.")
}
} label: {
actionLabel("Settings…")
}
Button {
NSApp.terminate(nil)
} label: {
actionLabel("Quit Shotdeck")
}
}
.buttonStyle(.plain)
}
private func actionLabel(_ title: String, trailing: String? = nil) -> some View {
HStack(spacing: 8) {
Text(title)
Spacer(minLength: 8)
if let trailing {
Text(trailing)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.padding(.vertical, 3)
}
@ViewBuilder
private var returnsBlock: some View {
if let provider = model as? ReturnsSectionProviding {
provider.returnsSection()
} else {
VStack(alignment: .leading, spacing: 4) {
Text("Came back from your device")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(newestReturns.prefix(8)) { doc in
HStack(spacing: 8) {
Text(doc.fileURL.lastPathComponent)
.lineLimit(1)
Spacer(minLength: 8)
Text(doc.isCommented
? "\(doc.annotatedPages.count) page\(doc.annotatedPages.count == 1 ? "" : "s") marked"
: "not marked")
.font(.caption)
.foregroundStyle(doc.isCommented ? .primary : .secondary)
}
}
}
}
}
private var newestReturns: [ReturnedDocument] {
model.allReturns.sorted { $0.detectedAt > $1.detectedAt }
}
}
+78
View File
@@ -0,0 +1,78 @@
import AppKit
import SwiftUI
import ShotdeckCore
struct SessionStrip: View {
@Environment(AppModel.self) private var model
var body: some View {
if model.session.captures.isEmpty {
Text("Nothing captured yet.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 6) {
ForEach(model.session.captures) { capture in
SessionThumb(capture: capture)
}
}
}
.frame(height: 64)
}
}
}
private struct SessionThumb: View {
@Environment(AppModel.self) private var model
let capture: Capture
@State private var hovering = false
var body: some View {
ZStack(alignment: .topLeading) {
thumbnail
Text("\(capture.sequence)")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(.black.opacity(0.65))
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
.padding(3)
if hovering {
VStack {
Spacer()
Button("Remove") {
Task { await model.removeCapture(id: capture.id) }
}
.font(.system(size: 10, weight: .semibold))
.buttonStyle(.plain)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 3)
.background(.black.opacity(0.7))
}
}
}
.frame(height: 64)
.clipped()
.onHover { hovering = $0 }
.help(DubaiTime.stamp(capture.capturedAt))
}
@ViewBuilder
private var thumbnail: some View {
let url = model.paths.sessionDirectory(model.session.id).appendingPathComponent(capture.fileName)
if let image = NSImage(contentsOf: url) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 64)
} else {
Rectangle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 64, height: 64)
}
}
}
+73 -4
View File
@@ -1,6 +1,75 @@
import AppKit
import SwiftUI
import ShotdeckCore
// WP-4 replaces this body with the real menu-bar UI.
let application = NSApplication.shared
application.setActivationPolicy(.accessory)
application.run()
// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`.
// App.main() is the equivalent entry point.
ShotdeckApp.main()
struct ShotdeckApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
MenuBarExtra {
MenuBarView()
.environment(appDelegate.model)
} label: {
let state = appDelegate.model.iconState
HStack(spacing: 4) {
Image(systemName: state.symbolName)
if let count = state.countText {
Text(count).font(.system(size: 11, weight: .semibold))
}
}
.accessibilityLabel("Shotdeck")
}
.menuBarExtraStyle(.window)
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let model: AppModel
override init() {
NSApplication.shared.setActivationPolicy(.accessory)
model = AppDelegate.makeLaunchModel()
super.init()
}
func applicationDidFinishLaunching(_ notification: Notification) {
Task { await model.bootstrap() }
}
private static func makeLaunchModel() -> AppModel {
do {
let paths = try FolderSettings.resolvedAppSupportPaths()
return try makeModel(paths: paths)
} catch {
Log.ui.critical(
"AppModel init failed: \(String(describing: error), privacy: .public)"
)
let tmp = FileManager.default.temporaryDirectory
let fallbackRoot = tmp.appendingPathComponent("Shotdeck-fallback", isDirectory: true)
// Safe: temp-dir creation for a path this process controls cannot legitimately fail.
let fallback = try! AppSupportPaths(root: fallbackRoot, outbox: tmp, watchFolder: tmp)
let model = try! makeModel(paths: fallback)
model.setStatus("Shotdeck could not access its storage folder. Captures will not persist.")
return model
}
}
private static func makeModel(paths: AppSupportPaths) throws -> AppModel {
let ledger = try ReturnLedger(paths: paths)
return AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
capturer: ScreenCapturer(),
hotkeys: HotkeyCenter(),
picker: RegionPickerController(),
ledger: ledger,
watcher: ReturnWatcher(paths: paths, ledger: ledger)
)
}
}
@@ -0,0 +1,110 @@
import Foundation
import PDFKit
public struct ReturnedDocument: Codable, Sendable, Identifiable, Equatable {
public var id: URL { fileURL }
public let fileURL: URL
/// Session UUID recovered from the PDF's subject attribute, when present and valid.
public let sessionID: UUID?
public let pageCount: Int
/// 1-based page numbers that carry at least one human mark, ascending, no duplicates.
public let annotatedPages: [Int]
public let detectedAt: Date
public var isCommented: Bool { !annotatedPages.isEmpty }
public init(
fileURL: URL,
sessionID: UUID?,
pageCount: Int,
annotatedPages: [Int],
detectedAt: Date
) {
self.fileURL = fileURL
self.sessionID = sessionID
self.pageCount = pageCount
self.annotatedPages = annotatedPages
self.detectedAt = detectedAt
}
}
public enum AnnotationInspector {
private static let humanMarkTypes: Set<String> = [
PDFAnnotationSubtype.ink.rawValue,
PDFAnnotationSubtype.highlight.rawValue,
PDFAnnotationSubtype.underline.rawValue,
PDFAnnotationSubtype.strikeOut.rawValue,
// PDFKit has no PDFAnnotationSubtype.squiggly member (unsupported renderer),
// but Apple Markup still writes Adobe /Squiggly objects that we must count.
PDFAnnotationSubtype(rawValue: "/Squiggly").rawValue,
PDFAnnotationSubtype.freeText.rawValue,
PDFAnnotationSubtype.square.rawValue,
PDFAnnotationSubtype.circle.rawValue,
PDFAnnotationSubtype.line.rawValue,
PDFAnnotationSubtype.stamp.rawValue,
PDFAnnotationSubtype.text.rawValue,
]
// .link ignored: a PDF hyperlink is structural, not a human mark.
// .popup ignored: it is always the companion of another annotation; counting it
// would double-count a single human mark as two.
// .widget ignored: a form field. Shotdeck's own PASS/FAIL boxes are page content
// (drawn by WP-2), never PDFAnnotation objects a widget seen here can only be
// introduced by a third-party tool flattening/reopening the file, and is not a
// human mark either way.
/// PDFKit's `PDFAnnotation.type` may omit the leading slash that
/// `PDFAnnotationSubtype.rawValue` includes; compare against the slash form.
private static func pdfTypeName(_ type: String) -> String {
type.hasPrefix("/") ? type : "/" + type
}
private static func isHumanMark(_ annotation: PDFAnnotation) -> Bool {
guard let raw = annotation.type else { return false }
let type = pdfTypeName(raw)
guard humanMarkTypes.contains(type) else { return false }
if type == PDFAnnotationSubtype.ink.rawValue {
let b = annotation.bounds
return b.width > 0 && b.height > 0 // zero-area ink = an undone stroke, not a mark
}
return true
}
/// Opens the PDF at fileURL and reports which pages carry a genuine human mark.
/// Throws ShotdeckError.manifestCorrupt(path: fileURL.path) if PDFDocument cannot open it.
/// File modification date is never consulted; only persisted PDFAnnotation objects count.
public static func inspect(fileURL: URL) throws -> ReturnedDocument {
guard let document = PDFDocument(url: fileURL) else {
throw ShotdeckError.manifestCorrupt(path: fileURL.path)
}
var annotatedPages: [Int] = []
for index in 0..<document.pageCount {
guard let page = document.page(at: index) else { continue }
if page.annotations.contains(where: isHumanMark) {
annotatedPages.append(index + 1) // 1-based
}
}
var sessionID: UUID?
if let subject = document.documentAttributes?[PDFDocumentAttribute.subjectAttribute] as? String {
sessionID = UUID(uuidString: subject) // nil (not thrown) if it doesn't parse
}
return ReturnedDocument(
fileURL: fileURL, sessionID: sessionID, pageCount: document.pageCount,
annotatedPages: annotatedPages, detectedAt: Date()
)
}
/// True when this PDF was produced by Shotdeck. Creator attribute is authoritative;
/// the filename fallback applies ONLY when the creator attribute is absent.
public static func isShotdeckDocument(_ document: PDFDocument) -> Bool {
if let creator = document.documentAttributes?[PDFDocumentAttribute.creatorAttribute] as? String {
return creator == "Shotdeck" // present creator is authoritative, full stop
}
// Creator ABSENT (some apps rewrite metadata on save) -> filename fallback only here.
guard let name = document.documentURL?.lastPathComponent else { return false }
// .lastPathComponent on a file URL is already percent-decoded; do not use .absoluteString.
return name.wholeMatch(of: /^Shotdeck-\d{8}-\d{6}( \d+)?\.pdf$/) != nil
// Case-sensitive by construction (Swift Regex literals are case-sensitive by default).
// The optional "( \d+)?" is macOS's duplicate-name suffix AirDrop adds when a file of
// the same name already exists in the watch folder the normal case for a return.
}
}
@@ -0,0 +1,60 @@
import Foundation
public actor ReturnLedger {
private let fileURL: URL
private var entries: [URL: ReturnedDocument]
/// Loads `returns.json` under paths.root if it exists; starts empty otherwise.
/// A file that cannot be decoded is renamed (never deleted) and the ledger starts empty.
public init(paths: AppSupportPaths) throws {
self.fileURL = paths.root.appendingPathComponent("returns.json")
if FileManager.default.fileExists(atPath: fileURL.path) {
let data = try Data(contentsOf: fileURL)
do {
let decoded = try JSONDecoder().decode([ReturnedDocument].self, from: data)
entries = Dictionary(decoded.map { ($0.fileURL, $0) }, uniquingKeysWith: { _, new in new })
} catch {
let stamp = DubaiTime.fileStamp(Date())
let corruptURL = fileURL.deletingLastPathComponent()
.appendingPathComponent("returns.json.corrupt-\(stamp)")
try FileManager.default.moveItem(at: fileURL, to: corruptURL)
Log.returns.error(
"returns.json could not be decoded; moved to \(corruptURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
entries = [:]
}
} else {
entries = [:]
}
}
/// Upserts by fileURL recording the same URL again replaces the prior entry
/// (the most recently recorded call wins, regardless of its detectedAt value).
public func record(_ document: ReturnedDocument) throws {
entries[document.fileURL] = document
try persist()
}
/// Every recorded return, newest detectedAt first.
public func all() throws -> [ReturnedDocument] {
entries.values.sorted { $0.detectedAt > $1.detectedAt }
}
/// Commented returns (isCommented == true), newest detectedAt first.
public func commented() throws -> [ReturnedDocument] {
try all().filter(\.isCommented)
}
/// Absolute POSIX paths of commented returns, newest first, one per line, no
/// trailing newline. Throws ShotdeckError.noCommentedReturns when commented() is empty.
public func clipboardText() throws -> String {
let paths = try commented().map { $0.fileURL.path }
guard !paths.isEmpty else { throw ShotdeckError.noCommentedReturns }
return paths.joined(separator: "\n")
}
private func persist() throws {
let data = try JSONEncoder().encode(Array(entries.values))
try AtomicFile.write(data, to: fileURL)
}
}
@@ -0,0 +1,154 @@
import Foundation
import PDFKit
import CoreServices // FSEventStream* APIs; system framework, no Package.swift change needed
public actor ReturnWatcher {
private let ledger: ReturnLedger
private var watchFolder: URL
private var onChange: (@Sendable ([ReturnedDocument]) -> Void)?
private var stream: FSEventStreamRef?
private var bridge: FSEventBridge?
private var pendingScanTask: Task<Void, Never>?
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
/// Watch folder is `paths.watchFolder`, which production constructs from
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
/// `updateWatchFolder` is invoked by the UI layer only.
public init(paths: AppSupportPaths, ledger: ReturnLedger) {
self.ledger = ledger
self.watchFolder = paths.watchFolder // never a literal "~/Downloads" here
}
/// Starts watching paths.watchFolder for returned PDFs. Performs one immediate
/// scanNow() before returning, then calls onChange after every subsequent debounced
/// batch (even if that batch's result is empty the caller decides what to do).
public func start(onChange: @escaping @Sendable ([ReturnedDocument]) -> Void) async throws {
self.onChange = onChange
try startStream(on: watchFolder)
let found = try await scanNow()
onChange(found)
}
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
/// when never started or already stopped. Cancels any pending debounced scan.
public func stop() {
// Idempotent: nil stream / already-stopped is a no-op; never started is the same.
pendingScanTask?.cancel()
pendingScanTask = nil
if let stream {
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
stream = nil
bridge = nil
}
/// Called by whoever owns the Settings "Choose..." folder action (WP-4c) after the user
/// picks a new watch folder. If the watcher was running, stops the old FSEventStream,
/// switches to the new folder, restarts, and performs one immediate scanNow (reporting
/// through the same onChange callback given to start()). If the watcher was never
/// started, only updates the stored folder for the next start() call.
public func updateWatchFolder(_ url: URL) async throws {
let wasRunning = stream != nil
stop()
watchFolder = url
guard wasRunning else { return }
try startStream(on: url)
let found = try await scanNow()
onChange?(found)
}
/// Scans the watch folder once, immediately, without waiting for an event. Every
/// recognized, stable, openable Shotdeck PDF present is (re-)inspected and (re-)recorded
/// into the ledger; returns exactly the documents processed in this call.
@discardableResult
public func scanNow() async throws -> [ReturnedDocument] {
let fm = FileManager.default
let candidates = (try? fm.contentsOfDirectory(
at: watchFolder, includingPropertiesForKeys: nil
)) ?? []
var results: [ReturnedDocument] = []
for url in candidates.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let name = url.lastPathComponent
// ".pdf.inprogress" already fails hasSuffix(".pdf") -> naturally skipped.
guard name.hasSuffix(".pdf"), !name.hasPrefix(".") else { continue }
guard await isStableAndReadable(url) else { continue } // leave for next event
guard let document = PDFDocument(url: url),
AnnotationInspector.isShotdeckDocument(document) else { continue }
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
try await ledger.record(inspected)
results.append(inspected)
}
return results
}
/// Size-stable: two equal byte counts 250 ms apart AND PDFDocument opens;
/// otherwise leave the file for the next event.
private func isStableAndReadable(_ url: URL) async -> Bool {
let fm = FileManager.default
guard let size1 = try? fm.attributesOfItem(atPath: url.path)[.size] as? Int else { return false }
try? await Task.sleep(for: .milliseconds(250))
guard let size2 = try? fm.attributesOfItem(atPath: url.path)[.size] as? Int else { return false }
guard size1 == size2, size1 > 0 else { return false }
return PDFDocument(url: url) != nil
}
private func startStream(on folder: URL) throws {
let bridge = FSEventBridge { [weak self] in
guard let self else { return }
Task { await self.scheduleDebouncedScan() }
}
self.bridge = bridge
var context = FSEventStreamContext()
context.version = 0
context.info = Unmanaged.passUnretained(bridge).toOpaque()
context.retain = nil
context.release = nil
context.copyDescription = nil
guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, shotdeckFSEventsCallback, &context,
[folder.path] as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.0,
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer)
) else {
throw ShotdeckError.captureFailed(underlying: "could not create FSEventStream for \(folder.path)")
}
// Dispatch queue, not a run loop: this actor has no run loop of its own, and
// FSEventStreamSetDispatchQueue is the modern replacement for
// FSEventStreamScheduleWithRunLoop. One dedicated serial queue per watcher.
FSEventStreamSetDispatchQueue(stream, eventQueue)
guard FSEventStreamStart(stream) else {
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
throw ShotdeckError.captureFailed(underlying: "FSEventStreamStart failed for \(folder.path)")
}
self.stream = stream
}
private func scheduleDebouncedScan() async {
pendingScanTask?.cancel()
pendingScanTask = Task {
try? await Task.sleep(for: .milliseconds(400)) // coalesce AirDrop's write+rename burst
guard !Task.isCancelled else { return }
guard let found = try? await self.scanNow() else { return }
// One in-flight debounced callback may land after stop(); it is a
// harmless read-only rescan (ledger upsert, no watch-folder mutation).
self.onChange?(found)
}
}
}
/// Non-actor bridge because FSEventStreamCallback is a @convention(c) function pointer and
/// cannot capture actor-isolated state directly; it hops back onto the actor via Task.
private final class FSEventBridge: @unchecked Sendable {
// @unchecked is safe: `notify` is a let, set once at init, never mutated after the
// type is immutable for its entire lifetime.
let notify: @Sendable () -> Void
init(notify: @escaping @Sendable () -> Void) { self.notify = notify }
}
private let shotdeckFSEventsCallback: FSEventStreamCallback = { _, info, _, _, _, _ in
guard let info else { return }
Unmanaged<FSEventBridge>.fromOpaque(info).takeUnretainedValue().notify()
}
+418
View File
@@ -0,0 +1,418 @@
import Foundation
import ImageIO
import CoreGraphics
import Darwin
public actor SpoolStore {
private let paths: AppSupportPaths
private var openSession: CaptureSession
public init(paths: AppSupportPaths) throws {
self.paths = paths
let fm = FileManager.default
try Self.supersedeSpoolArchiveOverlaps(paths: paths, fileManager: fm)
try Self.finishInterruptedArchives(paths: paths, fileManager: fm)
let remainingIDs = try Self.listUUIDDirectories(in: paths.spool, fileManager: fm)
if remainingIDs.isEmpty {
self.openSession = try Self.createFreshSession(paths: paths)
} else {
var candidates: [CaptureSession] = []
for id in remainingIDs {
let dir = paths.sessionDirectory(id)
candidates.append(
try Self.reconcileSessionDirectory(
at: dir, id: id, assumedStateIfRebuilt: .open, fileManager: fm))
}
self.openSession = Self.pickNewest(first: candidates[0], rest: Array(candidates.dropFirst()))
}
}
/// The session currently accepting captures. Cheap accessor all reconciliation already
/// happened once, inside init.
public func currentSession() throws -> CaptureSession {
openSession
}
/// Writes `pngData` to disk and fsyncs it BEFORE the manifest is touched, then updates and
/// durably writes the manifest. Order is non-negotiable: image durable -> manifest durable
/// -> return. Uses AtomicFile.write/writeJSON for both writes never Data.write(to:).
public func append(
pngData: Data, pixelWidth: Int, pixelHeight: Int,
scale: CGFloat, capturedAt: Date
) throws -> Capture {
let captureID = UUID()
let sequence = openSession.nextSequence
let fileName = "\(String(format: "%03d", sequence))-\(Self.hexSuffix(captureID)).png"
let sessionDir = paths.sessionDirectory(openSession.id)
let fileURL = sessionDir.appendingPathComponent(fileName)
try AtomicFile.write(pngData, to: fileURL)
let capture = Capture(
id: captureID, sequence: sequence, fileName: fileName,
pixelWidth: pixelWidth, pixelHeight: pixelHeight,
scale: scale, capturedAt: capturedAt)
let updated = openSession.appending(capture)
try AtomicFile.writeJSON(updated, to: sessionDir.appendingPathComponent("session.json"))
openSession = updated
return capture
}
/// D-11: moves the capture's PNG into `<sessionDir>/removed/` (created lazily) and drops
/// its manifest entry. NEVER unlinks/deletes a user PNG. Throws (no filesystem change) if
/// `captureID` is not present in the open session.
public func remove(captureID: UUID) throws -> CaptureSession {
guard let capture = openSession.captures.first(where: { $0.id == captureID }) else {
throw ShotdeckError.spoolWriteFailed(
path: paths.sessionDirectory(openSession.id).path,
underlying: "capture \(captureID) is not in the open session")
}
let sessionDir = paths.sessionDirectory(openSession.id)
let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true)
do {
try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true)
} catch {
throw ShotdeckError.spoolWriteFailed(path: removedDir.path, underlying: error.localizedDescription)
}
let sourceURL = sessionDir.appendingPathComponent(capture.fileName)
let destURL = removedDir.appendingPathComponent(capture.fileName)
guard rename(sourceURL.path, destURL.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: destURL.path,
underlying: "could not move the capture into removed/: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: removedDir)
let updated = openSession.removing(captureID: captureID)
try AtomicFile.writeJSON(updated, to: sessionDir.appendingPathComponent("session.json"))
openSession = updated
return updated
}
/// Closes the open session (must be non-empty), moves its directory under archive/, records
/// pdfFileName, and starts a fresh empty open session. Returns the archived one.
public func archiveCurrent(pdfFileName: String) throws -> CaptureSession {
guard !openSession.isEmpty else {
throw ShotdeckError.spoolWriteFailed(
path: paths.sessionDirectory(openSession.id).path,
underlying: "cannot archive an empty session")
}
let archived = openSession.markArchived(pdfFileName: pdfFileName)
let sessionDir = paths.sessionDirectory(openSession.id)
try AtomicFile.writeJSON(archived, to: sessionDir.appendingPathComponent("session.json"))
let archiveDir = paths.archiveDirectory(openSession.id)
guard rename(sessionDir.path, archiveDir.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: sessionDir.path,
underlying: "could not move the session into archive/: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: paths.archive)
let fresh = try Self.createFreshSession(paths: paths)
openSession = fresh
return archived
}
/// Abandons the open session only if it is empty (mints a new id/dir/manifest); throws,
/// with no filesystem change, if the open session has captures.
public func startNewSession() throws -> CaptureSession {
guard openSession.isEmpty else {
throw ShotdeckError.spoolWriteFailed(
path: paths.sessionDirectory(openSession.id).path,
underlying: "cannot start a new session: \(openSession.captures.count) capture(s) present in the open session")
}
let fresh = try Self.createFreshSession(paths: paths)
openSession = fresh
return fresh
}
/// Absolute URL of a capture's PNG, in whichever top-level directory its session lives
/// (spool/ if session.state == .open, archive/ if .archived).
public func imageURL(for capture: Capture, in session: CaptureSession) -> URL {
let dir = session.state == .open ? paths.sessionDirectory(session.id) : paths.archiveDirectory(session.id)
return dir.appendingPathComponent(capture.fileName)
}
/// Archived sessions, newest createdAt first. Lazily reconciles each archive/ directory
/// the same way init reconciles spool/ candidates (orphan recovery, missing-drop, corrupt
/// rebuild) an archived session's manifest can degrade too and must self-heal without
/// ever losing a PNG.
public func archivedSessions() throws -> [CaptureSession] {
let ids = try Self.listUUIDDirectories(in: paths.archive, fileManager: .default)
var sessions: [CaptureSession] = []
for id in ids {
sessions.append(
try Self.reconcileSessionDirectory(
at: paths.archiveDirectory(id), id: id, assumedStateIfRebuilt: .archived, fileManager: .default))
}
return sessions.sorted { ($0.createdAt, $0.id.uuidString) > ($1.createdAt, $1.id.uuidString) }
}
// MARK: - Reconciliation (static so they can run inside init)
private static func listUUIDDirectories(in parent: URL, fileManager: FileManager) throws -> [UUID] {
let entries: [URL]
do {
entries = try fileManager.contentsOfDirectory(
at: parent,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])
} catch {
throw ShotdeckError.spoolWriteFailed(path: parent.path, underlying: error.localizedDescription)
}
var ids: [UUID] = []
for url in entries {
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
guard isDirectory else { continue }
if let id = UUID(uuidString: url.lastPathComponent) {
ids.append(id)
}
}
return ids
}
private static func supersedeSpoolArchiveOverlaps(paths: AppSupportPaths, fileManager: FileManager) throws {
let spoolIDs = Set(try listUUIDDirectories(in: paths.spool, fileManager: fileManager))
let archiveIDs = Set(try listUUIDDirectories(in: paths.archive, fileManager: fileManager))
for id in spoolIDs.intersection(archiveIDs) {
let spoolDir = paths.sessionDirectory(id)
let supersededDir = paths.spool.appendingPathComponent(
"\(id.uuidString).superseded-\(DubaiTime.fileStamp(Date()))", isDirectory: true)
guard rename(spoolDir.path, supersededDir.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: spoolDir.path,
underlying: "could not supersede a duplicate spool copy: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: paths.spool)
Log.spool.warning("Found session \(id.uuidString, privacy: .public) in both spool/ and archive/; kept the archive copy and superseded the spool copy — nothing was deleted.")
}
}
private static func finishInterruptedArchives(paths: AppSupportPaths, fileManager: FileManager) throws {
for id in try listUUIDDirectories(in: paths.spool, fileManager: fileManager) {
let spoolDir = paths.sessionDirectory(id)
let manifestURL = spoolDir.appendingPathComponent("session.json")
guard let data = try? Data(contentsOf: manifestURL),
let decoded = try? decodeSession(from: data),
decoded.id == id, decoded.state == .archived
else { continue }
let archiveDir = paths.archiveDirectory(id)
guard rename(spoolDir.path, archiveDir.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: spoolDir.path,
underlying: "could not complete an interrupted archive move: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: paths.archive)
Log.spool.warning("Completed an archive move for \(id.uuidString, privacy: .public) that was interrupted before this launch.")
}
}
private static func reconcileSessionDirectory(
at dir: URL,
id: UUID,
assumedStateIfRebuilt: SessionState,
fileManager: FileManager
) throws -> CaptureSession {
let manifestURL = dir.appendingPathComponent("session.json")
let decoded: CaptureSession?
if let data = try? Data(contentsOf: manifestURL),
let session = try? decodeSession(from: data),
session.id == id {
decoded = session
} else {
decoded = nil
}
guard let session = decoded else {
return try rebuildManifest(
at: dir,
id: id,
assumedState: assumedStateIfRebuilt,
fileManager: fileManager)
}
var present: [Capture] = []
var missingCount = 0
for capture in session.captures {
let fileURL = dir.appendingPathComponent(capture.fileName)
if fileManager.fileExists(atPath: fileURL.path) {
present.append(capture)
} else {
missingCount += 1
}
}
let referenced = Set(session.captures.map(\.fileName))
let pngs = try listCapturePNGs(in: dir, fileManager: fileManager)
var recoveredOrphans: [Capture] = []
for pngURL in pngs where !referenced.contains(pngURL.lastPathComponent) {
if let recovered = recoverCapture(from: pngURL, fileManager: fileManager) {
recoveredOrphans.append(recovered)
} else {
Log.spool.error("Could not decode orphan PNG at \(pngURL.path, privacy: .public); leaving it on disk.")
}
}
let deletedTmp = deleteStrayTmpFiles(in: dir, fileManager: fileManager)
if missingCount == 0 && recoveredOrphans.isEmpty && !deletedTmp {
return session
}
let finalCaptures = (present + recoveredOrphans).sorted { $0.sequence < $1.sequence }
let updated = CaptureSession(
id: session.id,
createdAt: session.createdAt,
state: session.state,
captures: finalCaptures,
pdfFileName: session.pdfFileName)
try AtomicFile.writeJSON(updated, to: manifestURL)
Log.spool.warning("Reconciled session \(id.uuidString, privacy: .public): dropped \(missingCount) missing PNG(s), recovered \(recoveredOrphans.count) orphan(s).")
return updated
}
private static func rebuildManifest(
at dir: URL,
id: UUID,
assumedState: SessionState,
fileManager: FileManager
) throws -> CaptureSession {
let manifestURL = dir.appendingPathComponent("session.json")
if fileManager.fileExists(atPath: manifestURL.path) {
let corruptURL = dir.appendingPathComponent(
"session.json.corrupt-\(DubaiTime.fileStamp(Date()))")
do {
try fileManager.moveItem(at: manifestURL, to: corruptURL)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: manifestURL.path,
underlying: "could not quarantine a corrupt manifest: \(error.localizedDescription)")
}
}
var recovered: [Capture] = []
for pngURL in try listCapturePNGs(in: dir, fileManager: fileManager) {
if let capture = recoverCapture(from: pngURL, fileManager: fileManager) {
recovered.append(capture)
} else {
Log.spool.error("Could not decode PNG at \(pngURL.path, privacy: .public) while rebuilding the manifest; leaving it on disk.")
}
}
_ = deleteStrayTmpFiles(in: dir, fileManager: fileManager)
recovered.sort { $0.sequence < $1.sequence }
let createdAt: Date
if let earliest = recovered.map(\.capturedAt).min() {
createdAt = earliest
} else {
createdAt = (try? dir.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date()
}
let rebuilt = CaptureSession(
id: id,
createdAt: createdAt,
state: assumedState,
captures: recovered,
pdfFileName: nil)
try AtomicFile.writeJSON(rebuilt, to: dir.appendingPathComponent("session.json"))
Log.spool.warning("Rebuilt manifest for \(id.uuidString, privacy: .public) from \(recovered.count) recovered PNG(s).")
if assumedState == .archived {
Log.spool.warning("Rebuilt an archived session \(id.uuidString, privacy: .public) from PNGs; pdfFileName could not be recovered.")
}
return rebuilt
}
private static func recoverCapture(from fileURL: URL, fileManager: FileManager) -> Capture? {
guard fileManager.fileExists(atPath: fileURL.path) else { return nil }
guard let source = CGImageSourceCreateWithURL(fileURL as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?,
let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue
else { return nil }
let name = fileURL.lastPathComponent
let sequence = Int(name.prefix(3)) ?? 1
let capturedAt = (try? fileURL.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date()
return Capture(
id: UUID(),
sequence: sequence,
fileName: name,
pixelWidth: width,
pixelHeight: height,
scale: 1.0,
capturedAt: capturedAt)
}
private static func pickNewest(first: CaptureSession, rest: [CaptureSession]) -> CaptureSession {
rest.reduce(first) { current, candidate in
let currentKey = (current.createdAt, current.id.uuidString)
let candidateKey = (candidate.createdAt, candidate.id.uuidString)
return candidateKey > currentKey ? candidate : current
}
}
private static func createFreshSession(paths: AppSupportPaths) throws -> CaptureSession {
let id = UUID()
let createdAt = Date()
let dir = paths.sessionDirectory(id)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
throw ShotdeckError.spoolWriteFailed(path: dir.path, underlying: error.localizedDescription)
}
let session = CaptureSession(id: id, createdAt: createdAt, state: .open, captures: [], pdfFileName: nil)
try AtomicFile.writeJSON(session, to: dir.appendingPathComponent("session.json"))
return session
}
private static func hexSuffix(_ id: UUID) -> String {
String(id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased()
}
private static func decodeSession(from data: Data) throws -> CaptureSession {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(CaptureSession.self, from: data)
}
private static func isCapturePNGName(_ name: String) -> Bool {
guard name.hasSuffix(".png") else { return false }
let stem = String(name.dropLast(4))
let parts = stem.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2,
parts[0].count == 3,
parts[0].allSatisfy(\.isNumber),
parts[1].count == 8,
parts[1].allSatisfy(\.isHexDigit)
else { return false }
return true
}
private static func listCapturePNGs(in dir: URL, fileManager: FileManager) throws -> [URL] {
let entries: [URL]
do {
entries = try fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])
} catch {
throw ShotdeckError.spoolWriteFailed(path: dir.path, underlying: error.localizedDescription)
}
return entries.filter { url in
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
guard !isDirectory else { return false }
return isCapturePNGName(url.lastPathComponent)
}
}
private static func deleteStrayTmpFiles(in dir: URL, fileManager: FileManager) -> Bool {
let entries = (try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])) ?? []
var deleted = false
for url in entries {
guard url.lastPathComponent.hasSuffix(".tmp") else { continue }
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
guard !isDirectory else { continue }
do {
try fileManager.removeItem(at: url)
deleted = true
} catch {
Log.spool.error("Could not remove stray temp file at \(url.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
return deleted
}
}
@@ -0,0 +1,412 @@
import AppKit
import Foundation
import PDFKit
import Testing
import ShotdeckCore
private let pageBounds = CGRect(x: 0, y: 0, width: 600, height: 800)
private func makeAnnotation(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect = CGRect(x: 100, y: 100, width: 80, height: 40),
contents: String? = nil
) -> PDFAnnotation {
let annotation = PDFAnnotation(
bounds: bounds,
forType: subtype,
withProperties: nil
)
annotation.contents = contents
return annotation
}
private func makePDF(
at url: URL,
pageCount: Int,
creator: String? = "Shotdeck",
subject: String? = nil,
annotations: [(page: Int, annotation: PDFAnnotation)] = []
) throws {
let document = PDFDocument()
for index in 0..<pageCount {
let page = PDFPage()
page.setBounds(pageBounds, for: .mediaBox)
document.insert(page, at: index)
}
var attributes = [PDFDocumentAttribute: Any]()
if let creator { attributes[.creatorAttribute] = creator }
if let subject { attributes[.subjectAttribute] = subject }
document.documentAttributes = attributes
for item in annotations {
guard let page = document.page(at: item.page) else {
throw NSError(domain: "WP5Test", code: 1)
}
page.addAnnotation(item.annotation)
}
guard document.write(to: url) else {
throw NSError(domain: "WP5Test", code: 2)
}
}
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5a-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: cleanup.appendingPathComponent("root", isDirectory: true),
outbox: cleanup.appendingPathComponent("outbox", isDirectory: true),
watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true)
)
return (paths, cleanup)
}
@Test("I-1 Untouched PDF is clean")
func i1_untouchedPDFIsClean() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134200.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
creator: "Shotdeck",
subject: "11111111-1111-1111-1111-111111111111"
)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Dubai"))
let firstMTime = try #require(calendar.date(from: DateComponents(
year: 2026, month: 8, day: 30, hour: 13, minute: 42, second: 0
)))
let secondMTime = try #require(calendar.date(from: DateComponents(
year: 2026, month: 8, day: 30, hour: 13, minute: 43, second: 0
)))
try FileManager.default.setAttributes([.modificationDate: firstMTime], ofItemAtPath: pdfURL.path)
try FileManager.default.setAttributes([.modificationDate: secondMTime], ofItemAtPath: pdfURL.path)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.fileURL == pdfURL)
#expect(inspected.sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111"))
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [])
#expect(inspected.isCommented == false)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(inspected)
let all = try await ledger.all()
#expect(all.count == 1)
#expect(all[0].fileURL == pdfURL)
#expect(try await ledger.commented() == [])
let clipboardError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = clipboardError else {
Issue.record("expected noCommentedReturns, got \(clipboardError)")
return
}
}
@Test("I-2 Ink is a mark")
func i2_inkIsAMark() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134201.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
annotations: [(page: 1, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 120, y: 240, width: 140, height: 60)
))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [2])
#expect(inspected.isCommented == true)
}
@Test("I-3 Highlight is a mark")
func i3_highlightIsAMark() throws {
try expectSinglePageMark(
.highlight,
bounds: CGRect(x: 80, y: 500, width: 300, height: 24),
fileName: "Shotdeck-20260830-134202.pdf"
)
}
@Test("I-4 Underline is a mark")
func i4_underlineIsAMark() throws {
try expectSinglePageMark(
.underline,
bounds: CGRect(x: 90, y: 460, width: 280, height: 18),
fileName: "Shotdeck-20260830-134203.pdf"
)
}
@Test("I-5 Strike-out is a mark")
func i5_strikeOutIsAMark() throws {
try expectSinglePageMark(
.strikeOut,
bounds: CGRect(x: 90, y: 430, width: 280, height: 18),
fileName: "Shotdeck-20260830-134204.pdf"
)
}
@Test("I-6 Squiggly is a mark")
func i6_squigglyIsAMark() throws {
try expectSinglePageMark(
PDFAnnotationSubtype(rawValue: "/Squiggly"),
bounds: CGRect(x: 90, y: 400, width: 280, height: 18),
fileName: "Shotdeck-20260830-134205.pdf"
)
}
@Test("I-7 Free-text is a mark")
func i7_freeTextIsAMark() throws {
try expectSinglePageMark(
.freeText,
bounds: CGRect(x: 140, y: 300, width: 220, height: 80),
contents: "Typed review note",
fileName: "Shotdeck-20260830-134206.pdf"
)
}
@Test("I-8 Square is a mark")
func i8_squareIsAMark() throws {
try expectSinglePageMark(
.square,
bounds: CGRect(x: 160, y: 250, width: 100, height: 100),
fileName: "Shotdeck-20260830-134207.pdf"
)
}
@Test("I-9 Circle is a mark")
func i9_circleIsAMark() throws {
try expectSinglePageMark(
.circle,
bounds: CGRect(x: 280, y: 250, width: 100, height: 100),
fileName: "Shotdeck-20260830-134208.pdf"
)
}
@Test("I-10 Line is a mark")
func i10_lineIsAMark() throws {
try expectSinglePageMark(
.line,
bounds: CGRect(x: 100, y: 180, width: 320, height: 8),
fileName: "Shotdeck-20260830-134209.pdf"
)
}
@Test("I-11 Stamp is a mark")
func i11_stampIsAMark() throws {
try expectSinglePageMark(
.stamp,
bounds: CGRect(x: 180, y: 500, width: 120, height: 60),
contents: "Approved",
fileName: "Shotdeck-20260830-134210.pdf"
)
}
@Test("I-12 Sticky text is a mark")
func i12_stickyTextIsAMark() throws {
try expectSinglePageMark(
.text,
bounds: CGRect(x: 420, y: 620, width: 28, height: 28),
contents: "Look here",
fileName: "Shotdeck-20260830-134211.pdf"
)
}
@Test("I-13 Link alone is not a mark")
func i13_linkAloneIsNotAMark() throws {
try expectSinglePageIgnored(
.link,
bounds: CGRect(x: 80, y: 700, width: 160, height: 20),
fileName: "Shotdeck-20260830-134212.pdf"
)
}
@Test("I-14 Popup alone is not a mark")
func i14_popupAloneIsNotAMark() throws {
try expectSinglePageIgnored(
.popup,
bounds: CGRect(x: 450, y: 600, width: 100, height: 60),
fileName: "Shotdeck-20260830-134213.pdf"
)
}
@Test("I-15 Widget alone is not a mark")
func i15_widgetAloneIsNotAMark() throws {
try expectSinglePageIgnored(
.widget,
bounds: CGRect(x: 100, y: 100, width: 140, height: 32),
fileName: "Shotdeck-20260830-134214.pdf"
)
}
@Test("I-16 Zero-area ink is not a mark")
func i16_zeroAreaInkIsNotAMark() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134215.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 240, y: 240, width: 0, height: 0)
))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.annotatedPages == [])
#expect(inspected.isCommented == false)
}
@Test("I-17 Ignored objects plus one real mark count once")
func i17_ignoredObjectsPlusOneRealMarkCountOnce() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134216.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
annotations: [
(page: 1, annotation: makeAnnotation(.link, bounds: CGRect(x: 20, y: 20, width: 40, height: 20))),
(page: 1, annotation: makeAnnotation(.popup, bounds: CGRect(x: 70, y: 20, width: 40, height: 20))),
(page: 1, annotation: makeAnnotation(.widget, bounds: CGRect(x: 120, y: 20, width: 40, height: 20))),
(page: 1, annotation: makeAnnotation(.ink, bounds: CGRect(x: 200, y: 200, width: 120, height: 50))),
]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [2])
#expect(inspected.isCommented == true)
}
@Test("I-18 Page numbers are 1-based and ascending")
func i18_pageNumbersAre1BasedAndAscending() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134217.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
annotations: [
(page: 0, annotation: makeAnnotation(.circle, bounds: CGRect(x: 260, y: 200, width: 70, height: 70))),
(page: 1, annotation: makeAnnotation(.link, bounds: CGRect(x: 80, y: 700, width: 160, height: 20))),
(page: 2, annotation: makeAnnotation(.ink, bounds: CGRect(x: 100, y: 100, width: 90, height: 40))),
]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [1, 3])
#expect(inspected.isCommented == true)
}
@Test("I-19 Session UUID recovery and invalid-subject tolerance")
func i19_sessionUUIDRecoveryAndInvalidSubjectTolerance() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let validURL = paths.watchFolder.appendingPathComponent("valid-subject.pdf")
let invalidURL = paths.watchFolder.appendingPathComponent("invalid-subject.pdf")
let missingURL = paths.watchFolder.appendingPathComponent("missing-subject.pdf")
try makePDF(
at: validURL,
pageCount: 1,
creator: "Shotdeck",
subject: "22222222-2222-2222-2222-222222222222"
)
try makePDF(
at: invalidURL,
pageCount: 1,
creator: "Shotdeck",
subject: "not-a-uuid"
)
try makePDF(
at: missingURL,
pageCount: 1,
creator: "Shotdeck",
subject: nil
)
let valid = try AnnotationInspector.inspect(fileURL: validURL)
#expect(valid.sessionID == UUID(uuidString: "22222222-2222-2222-2222-222222222222"))
#expect(valid.pageCount == 1)
#expect(valid.annotatedPages == [])
#expect(valid.isCommented == false)
let invalid = try AnnotationInspector.inspect(fileURL: invalidURL)
#expect(invalid.sessionID == nil)
#expect(invalid.annotatedPages == [])
#expect(invalid.isCommented == false)
let missing = try AnnotationInspector.inspect(fileURL: missingURL)
#expect(missing.sessionID == nil)
#expect(missing.annotatedPages == [])
#expect(missing.isCommented == false)
}
@Test("I-19b Present non-Shotdeck creator beats a matching filename")
func i19b_presentNonShotdeckCreatorBeatsMatchingFilename() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134218.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: "Preview",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == false)
}
private func expectSinglePageMark(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect,
contents: String? = nil,
fileName: String
) throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent(fileName)
try makePDF(
at: pdfURL,
pageCount: 1,
annotations: [(page: 0, annotation: makeAnnotation(subtype, bounds: bounds, contents: contents))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 1)
#expect(inspected.annotatedPages == [1])
#expect(inspected.isCommented == true)
}
private func expectSinglePageIgnored(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect,
fileName: String
) throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent(fileName)
try makePDF(
at: pdfURL,
pageCount: 1,
annotations: [(page: 0, annotation: makeAnnotation(subtype, bounds: bounds))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.annotatedPages == [])
#expect(inspected.isCommented == false)
}
@@ -0,0 +1,200 @@
import Foundation
import Testing
import ShotdeckCore
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5a-ledger-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: cleanup.appendingPathComponent("root", isDirectory: true),
outbox: cleanup.appendingPathComponent("outbox", isDirectory: true),
watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true)
)
return (paths, cleanup)
}
private func document(
path: String,
annotatedPages: [Int],
detectedAt: Date,
sessionID: UUID? = nil,
pageCount: Int = 1
) -> ReturnedDocument {
ReturnedDocument(
fileURL: URL(fileURLWithPath: path),
sessionID: sessionID,
pageCount: pageCount,
annotatedPages: annotatedPages,
detectedAt: detectedAt
)
}
@Test("L-1 commented() returns only commented entries, newest first")
func l1_commentedReturnsOnlyCommentedNewestFirst() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let newer = document(
path: "/tmp/shotdeck-returns/newer.pdf",
annotatedPages: [1, 2],
detectedAt: Date(timeIntervalSince1970: 1_777_000_200)
)
let older = document(
path: "/tmp/shotdeck-returns/older.pdf",
annotatedPages: [3],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let clean = document(
path: "/tmp/shotdeck-returns/clean.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_300)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(older)
try await ledger.record(newer)
try await ledger.record(clean)
let commented = try await ledger.commented()
#expect(commented.map(\.fileURL) == [newer.fileURL, older.fileURL])
#expect(commented.map(\.annotatedPages) == [[1, 2], [3]])
}
@Test("L-2 clipboardText() exact format")
func l2_clipboardTextExactFormat() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let newer = document(
path: "/tmp/shotdeck-returns/newer.pdf",
annotatedPages: [1, 2],
detectedAt: Date(timeIntervalSince1970: 1_777_000_200)
)
let older = document(
path: "/tmp/shotdeck-returns/older.pdf",
annotatedPages: [3],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let clean = document(
path: "/tmp/shotdeck-returns/clean.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_300)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(older)
try await ledger.record(newer)
try await ledger.record(clean)
let text = try await ledger.clipboardText()
#expect(text == newer.fileURL.path + "\n" + older.fileURL.path)
#expect(text.hasSuffix("\n") == false)
}
@Test("L-3 Re-recording the same URL upserts")
func l3_rerecordingTheSameURLUpserts() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let url = URL(fileURLWithPath: "/tmp/shotdeck-returns/same.pdf")
let first = ReturnedDocument(
fileURL: url,
sessionID: nil,
pageCount: 1,
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_400)
)
let second = ReturnedDocument(
fileURL: url,
sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111"),
pageCount: 1,
annotatedPages: [1],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(first)
try await ledger.record(second)
let all = try await ledger.all()
#expect(all.count == 1)
#expect(all[0].fileURL == url)
#expect(all[0].annotatedPages == [1])
#expect(all[0].isCommented == true)
#expect(all[0].sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111"))
}
@Test("L-4 clipboardText() throws when nothing is commented")
func l4_clipboardTextThrowsWhenNothingIsCommented() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let emptyError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = emptyError else {
Issue.record("expected noCommentedReturns on an empty ledger, got \(emptyError)")
return
}
try await ledger.record(document(
path: "/tmp/shotdeck-returns/clean-only.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_500)
))
let cleanError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = cleanError else {
Issue.record("expected noCommentedReturns with only clean returns, got \(cleanError)")
return
}
}
@Test("L-5 Ledger survives reopening from disk")
func l5_ledgerSurvivesReopeningFromDisk() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let first = document(
path: "/tmp/shotdeck-returns/a.pdf",
annotatedPages: [1],
detectedAt: Date(timeIntervalSince1970: 1_777_000_700),
sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")
)
let second = document(
path: "/tmp/shotdeck-returns/b.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_600)
)
do {
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(first)
try await ledger.record(second)
}
let reopened = try ReturnLedger(paths: paths)
let all = try await reopened.all()
#expect(all == [first, second])
}
@Test("L-6 Corrupt returns.json is renamed and the ledger starts empty")
func l6_corruptReturnsJSONIsRenamedAndStartsEmpty() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let returnsURL = paths.root.appendingPathComponent("returns.json")
try Data("{not-json".utf8).write(to: returnsURL)
#expect(FileManager.default.fileExists(atPath: returnsURL.path))
let ledger = try ReturnLedger(paths: paths)
#expect(try await ledger.all() == [])
#expect(FileManager.default.fileExists(atPath: returnsURL.path) == false)
let names = try FileManager.default.contentsOfDirectory(atPath: paths.root.path)
let corrupt = names.filter { $0.hasPrefix("returns.json.corrupt-") }
#expect(corrupt.count == 1)
#expect(corrupt[0].contains(DubaiTime.fileStamp(Date()).prefix(8)))
}
@@ -0,0 +1,173 @@
import AppKit
import Foundation
import PDFKit
import Testing
import ShotdeckCore
private let pageBounds = CGRect(x: 0, y: 0, width: 600, height: 800)
private func makeAnnotation(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect = CGRect(x: 100, y: 100, width: 80, height: 40),
contents: String? = nil
) -> PDFAnnotation {
let annotation = PDFAnnotation(
bounds: bounds,
forType: subtype,
withProperties: nil
)
annotation.contents = contents
return annotation
}
private func makePDF(
at url: URL,
pageCount: Int,
creator: String? = "Shotdeck",
subject: String? = nil,
annotations: [(page: Int, annotation: PDFAnnotation)] = []
) throws {
let document = PDFDocument()
for index in 0..<pageCount {
let page = PDFPage()
page.setBounds(pageBounds, for: .mediaBox)
document.insert(page, at: index)
}
var attributes = [PDFDocumentAttribute: Any]()
if let creator { attributes[.creatorAttribute] = creator }
if let subject { attributes[.subjectAttribute] = subject }
document.documentAttributes = attributes
for item in annotations {
guard let page = document.page(at: item.page) else {
throw NSError(domain: "WP5Test", code: 1)
}
page.addAnnotation(item.annotation)
}
guard document.write(to: url) else {
throw NSError(domain: "WP5Test", code: 2)
}
}
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5b-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: cleanup.appendingPathComponent("root", isDirectory: true),
outbox: cleanup.appendingPathComponent("outbox", isDirectory: true),
watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true)
)
return (paths, cleanup)
}
/// Guards `confirmation(expectedCount: 1)` against a create+rename double-event.
private final class ConfirmOnce: @unchecked Sendable {
private let lock = NSLock()
private var fired = false
func run(_ body: () -> Void) {
lock.lock()
defer { lock.unlock() }
guard !fired else { return }
fired = true
body()
}
}
@Test("W-25 Duplicate-name suffix is the normal AirDrop return (scanNow)")
func w25_duplicateNameSuffixIsRecognizedByScanNow() 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("Shotdeck-20260830-134205 2.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: nil,
subject: "33333333-3333-3333-3333-333333333333",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let found = try await watcher.scanNow()
#expect(found.count == 1)
let doc = try #require(found.first)
#expect(doc.fileURL.lastPathComponent == "Shotdeck-20260830-134205 2.pdf")
#expect(doc.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
#expect(doc.pageCount == 1)
#expect(doc.annotatedPages == [1])
#expect(doc.isCommented == true)
let commented = try await ledger.commented()
#expect(commented.count == 1)
#expect(commented[0].fileURL.lastPathComponent == pdfURL.lastPathComponent)
#expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
}
@Test("W-26 Creator provenance negative at the scan level")
func w26_presentNonShotdeckCreatorIsIgnoredByScanNow() 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("Shotdeck-20260830-134218.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: "Preview",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let found = try await watcher.scanNow()
#expect(found.isEmpty)
let all = try await ledger.all()
#expect(all.isEmpty)
}
@Test("W-27 FSEvents live callback fires on a real file arrival")
func w27_fsEventsCallbackFiresOnRealArrival() 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("Shotdeck-20260830-140000.pdf")
let once = ConfirmOnce()
try await confirmation("watcher reports the arrived PDF", expectedCount: 1) { confirm in
do {
try await watcher.start { docs in
if docs.contains(where: { $0.fileURL.lastPathComponent == pdfURL.lastPathComponent }) {
once.run { confirm() }
}
}
// Simulate AirDrop's own write-then-rename so the watcher must survive that pattern.
let tmpURL = pdfURL.appendingPathExtension("inprogress")
try makePDF(
at: tmpURL, pageCount: 1, creator: "Shotdeck",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
try FileManager.default.moveItem(at: tmpURL, to: pdfURL)
} catch {
print("fsEventsCallbackFiresOnRealArrival error: \(error)")
await watcher.stop()
throw error
}
// Budget: 400ms debounce + FS latency + the 250ms stability re-read + PDFKit open.
// 5s is a generous, fixed ceiling never a "some time" wait.
try await Task.sleep(for: .seconds(5))
await watcher.stop()
await watcher.stop()
}
}
@@ -0,0 +1,590 @@
import CoreGraphics
import Foundation
import ImageIO
import Testing
import ShotdeckCore
// MARK: - 1. append writes a real, decodable PNG
@Test
func appendWritesARealDecodablePNGMatchingSourceDimensions() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let width = 12
let height = 7
let png = try makePNGData(width: width, height: height, red: 0.9, green: 0.1, blue: 0.2)
let store = try SpoolStore(paths: paths)
let capturedAt = Date(timeIntervalSince1970: 1_700_000_000)
let capture = try await store.append(
pngData: png, pixelWidth: width, pixelHeight: height, scale: 2.0, capturedAt: capturedAt)
let session = try await store.currentSession()
#expect(capture.pixelWidth == width)
#expect(capture.pixelHeight == height)
let url = await store.imageURL(for: capture, in: session)
let expected = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName)
#expect(url.path == expected.path)
#expect(FileManager.default.fileExists(atPath: url.path))
let onDisk = try Data(contentsOf: url)
#expect(onDisk == png)
let decoded = try #require(pngDimensions(at: url))
#expect(decoded.width == width)
#expect(decoded.height == height)
}
// MARK: - 2. remaining-manifest sequence rule (coordinator correction)
@Test
func sequencesFollowRemainingManifestMaxAndAreNotRenumbered() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let c1 = try await store.append(
pngData: try makePNGData(width: 4, height: 4, red: 1, green: 0, blue: 0),
pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date())
let c2 = try await store.append(
pngData: try makePNGData(width: 6, height: 4, red: 0, green: 1, blue: 0),
pixelWidth: 6, pixelHeight: 4, scale: 1.0, capturedAt: Date())
let c3 = try await store.append(
pngData: try makePNGData(width: 8, height: 4, red: 0, green: 0, blue: 1),
pixelWidth: 8, pixelHeight: 4, scale: 1.0, capturedAt: Date())
#expect(c1.sequence == 1)
#expect(c2.sequence == 2)
#expect(c3.sequence == 3)
_ = try await store.remove(captureID: c2.id)
let afterMiddle = try await store.currentSession()
#expect(afterMiddle.captures.map(\.sequence) == [1, 3])
#expect(afterMiddle.captures.map(\.id) == [c1.id, c3.id])
let c4 = try await store.append(
pngData: try makePNGData(width: 10, height: 4, red: 1, green: 1, blue: 0),
pixelWidth: 10, pixelHeight: 4, scale: 1.0, capturedAt: Date())
#expect(c4.sequence == 4)
_ = try await store.remove(captureID: c4.id)
let afterLast = try await store.currentSession()
#expect(afterLast.captures.map(\.sequence) == [1, 3])
let c4b = try await store.append(
pngData: try makePNGData(width: 12, height: 4, red: 1, green: 0, blue: 1),
pixelWidth: 12, pixelHeight: 4, scale: 1.0, capturedAt: Date())
#expect(c4b.sequence == 4)
#expect(c4b.fileName != c4.fileName)
#expect(c4b.id != c4.id)
let sessionDir = paths.sessionDirectory(afterLast.id)
#expect(FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4b.fileName).path))
#expect(FileManager.default.fileExists(
atPath: sessionDir.appendingPathComponent("removed", isDirectory: true)
.appendingPathComponent(c4.fileName).path))
#expect(!FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4.fileName).path))
}
// MARK: - 3. remove moves PNG into removed/
@Test
func removeMovesThePNGUnchangedIntoRemovedAndLeavesTheOriginalPathEmpty() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 8, height: 5, red: 0.2, green: 0.5, blue: 0.8)
let capture = try await store.append(
pngData: png, pixelWidth: 8, pixelHeight: 5, scale: 2.0, capturedAt: Date())
let session = try await store.currentSession()
let sessionDir = paths.sessionDirectory(session.id)
let originalURL = sessionDir.appendingPathComponent(capture.fileName)
let removedURL = sessionDir.appendingPathComponent("removed", isDirectory: true)
.appendingPathComponent(capture.fileName)
let updated = try await store.remove(captureID: capture.id)
#expect(updated.captures.contains(where: { $0.id == capture.id }) == false)
#expect(!FileManager.default.fileExists(atPath: originalURL.path))
#expect(FileManager.default.fileExists(atPath: removedURL.path))
let moved = try Data(contentsOf: removedURL)
#expect(moved == png)
}
// MARK: - 4. remove of unknown id throws with zero filesystem change
@Test
func removeOfUnknownIDThrowsAndLeavesDiskByteIdentical() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
_ = try await store.append(
pngData: try makePNGData(width: 4, height: 4, red: 0.1, green: 0.2, blue: 0.3),
pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date())
let beforeSession = try await store.currentSession()
let beforeFiles = try snapshotFiles(under: root)
do {
_ = try await store.remove(captureID: UUID())
Issue.record("expected remove of an unknown id to throw")
} catch let error as ShotdeckError {
guard case .spoolWriteFailed = error else {
Issue.record("expected spoolWriteFailed, got \(error)")
return
}
} catch {
Issue.record("expected ShotdeckError, got \(error)")
return
}
let afterSession = try await store.currentSession()
#expect(afterSession == beforeSession)
let afterFiles = try snapshotFiles(under: root)
#expect(afterFiles == beforeFiles)
}
// MARK: - 5. orphan recovery
@Test
func orphanPNGWrittenBehindTheStoreIsRecoveredOnReopen() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let session = try await store.currentSession()
let width = 11
let height = 9
let png = try makePNGData(width: width, height: height, red: 0.4, green: 0.7, blue: 0.1)
let orphanName = "007-ABCD1234.png"
let dest = paths.sessionDirectory(session.id).appendingPathComponent(orphanName)
try AtomicFile.write(png, to: dest)
let reopened = try SpoolStore(paths: paths)
let recovered = try await reopened.currentSession()
let match = try #require(recovered.captures.first { $0.fileName == orphanName })
#expect(match.pixelWidth == width)
#expect(match.pixelHeight == height)
#expect(match.sequence == 7)
#expect(match.scale == 1.0)
}
// MARK: - 6. removed/ files are not resurrected
@Test
func orphanRecoveryDoesNotResurrectFilesInRemoved() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let session = try await store.currentSession()
let sessionDir = paths.sessionDirectory(session.id)
let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true)
try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true)
let hiddenName = "009-FEEDFACE.png"
try AtomicFile.write(
try makePNGData(width: 5, height: 5, red: 0.9, green: 0.9, blue: 0.1),
to: removedDir.appendingPathComponent(hiddenName))
let reopened = try SpoolStore(paths: paths)
let reconciled = try await reopened.currentSession()
#expect(reconciled.captures.contains(where: { $0.fileName == hiddenName }) == false)
#expect(FileManager.default.fileExists(atPath: removedDir.appendingPathComponent(hiddenName).path))
}
// MARK: - 7. missing-image drop
@Test
func missingPNGReferencedByManifestIsDroppedAndOthersSurvive() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let keep = try await store.append(
pngData: try makePNGData(width: 6, height: 6, red: 0.1, green: 0.8, blue: 0.2),
pixelWidth: 6, pixelHeight: 6, scale: 1.0, capturedAt: Date())
let drop = try await store.append(
pngData: try makePNGData(width: 7, height: 7, red: 0.8, green: 0.1, blue: 0.2),
pixelWidth: 7, pixelHeight: 7, scale: 1.0, capturedAt: Date())
let session = try await store.currentSession()
try FileManager.default.removeItem(
at: paths.sessionDirectory(session.id).appendingPathComponent(drop.fileName))
let reopened = try SpoolStore(paths: paths)
let reconciled = try await reopened.currentSession()
#expect(reconciled.captures.map(\.id) == [keep.id])
#expect(reconciled.captures.contains(where: { $0.id == drop.id }) == false)
}
// MARK: - 8. corrupt manifest rebuild
@Test
func corruptManifestIsQuarantinedAndPNGsAreRebuiltIntoANewManifest() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let first = try await store.append(
pngData: try makePNGData(width: 8, height: 6, red: 0.3, green: 0.3, blue: 0.9),
pixelWidth: 8, pixelHeight: 6, scale: 2.0, capturedAt: Date())
let second = try await store.append(
pngData: try makePNGData(width: 9, height: 6, red: 0.9, green: 0.3, blue: 0.3),
pixelWidth: 9, pixelHeight: 6, scale: 2.0, capturedAt: Date())
let session = try await store.currentSession()
let sessionDir = paths.sessionDirectory(session.id)
let manifestURL = sessionDir.appendingPathComponent("session.json")
let garbage = Data("{ not json".utf8)
try AtomicFile.write(garbage, to: manifestURL)
let reopened = try SpoolStore(paths: paths)
let rebuilt = try await reopened.currentSession()
#expect(rebuilt.id == session.id)
let contents = try FileManager.default.contentsOfDirectory(at: sessionDir, includingPropertiesForKeys: nil)
let corruptFiles = contents.filter { $0.lastPathComponent.hasPrefix("session.json.corrupt-") }
#expect(corruptFiles.count == 1)
let quarantined = try Data(contentsOf: try #require(corruptFiles.first))
#expect(quarantined == garbage)
let names = Set(rebuilt.captures.map(\.fileName))
#expect(names.contains(first.fileName))
#expect(names.contains(second.fileName))
let recoveredFirst = try #require(rebuilt.captures.first { $0.fileName == first.fileName })
#expect(recoveredFirst.pixelWidth == 8)
#expect(recoveredFirst.pixelHeight == 6)
}
// MARK: - 9. archiveCurrent moves the directory
@Test
func archiveCurrentMovesTheSessionUnderArchiveAndOpensAFreshEmptySession() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 10, height: 8, red: 0.5, green: 0.1, blue: 0.6)
let capture = try await store.append(
pngData: png, pixelWidth: 10, pixelHeight: 8, scale: 1.0, capturedAt: Date())
let open = try await store.currentSession()
let archived = try await store.archiveCurrent(pdfFileName: "shotdeck-review.pdf")
#expect(archived.state == .archived)
#expect(archived.pdfFileName == "shotdeck-review.pdf")
#expect(archived.id == open.id)
#expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(open.id).path))
let archivedPNG = paths.archiveDirectory(open.id).appendingPathComponent(capture.fileName)
#expect(FileManager.default.fileExists(atPath: archivedPNG.path))
#expect(try Data(contentsOf: archivedPNG) == png)
let current = try await store.currentSession()
#expect(current.id != open.id)
#expect(current.isEmpty)
#expect(current.state == .open)
#expect(FileManager.default.fileExists(
atPath: paths.sessionDirectory(current.id).appendingPathComponent("session.json").path))
}
// MARK: - 10. archiveCurrent on empty throws
@Test
func archiveCurrentOnEmptySessionThrowsAndDoesNotMoveTheDirectory() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let session = try await store.currentSession()
let before = try snapshotFiles(under: root)
do {
_ = try await store.archiveCurrent(pdfFileName: "never.pdf")
Issue.record("expected archiveCurrent on an empty session to throw")
} catch let error as ShotdeckError {
guard case .spoolWriteFailed = error else {
Issue.record("expected spoolWriteFailed, got \(error)")
return
}
} catch {
Issue.record("expected ShotdeckError, got \(error)")
return
}
#expect(FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path))
#expect(try snapshotFiles(under: root) == before)
}
// MARK: - 11. interrupted-archive, both exist
@Test
func interruptedArchiveBothExistSupersedesTheSpoolCopyAndKeepsArchive() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 8, height: 8, red: 0.2, green: 0.2, blue: 0.8)
let capture = try await store.append(
pngData: png, pixelWidth: 8, pixelHeight: 8, scale: 1.0, capturedAt: Date())
let archived = try await store.archiveCurrent(pdfFileName: "sent.pdf")
let archiveDir = paths.archiveDirectory(archived.id)
let spoolCopy = paths.sessionDirectory(archived.id)
try FileManager.default.copyItem(at: archiveDir, to: spoolCopy)
let archivePNGBefore = try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName))
let reopened = try SpoolStore(paths: paths)
_ = try await reopened.currentSession()
#expect(FileManager.default.fileExists(atPath: archiveDir.path))
#expect(!FileManager.default.fileExists(atPath: spoolCopy.path))
#expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == archivePNGBefore)
let spoolEntries = try FileManager.default.contentsOfDirectory(
at: paths.spool, includingPropertiesForKeys: [.isDirectoryKey])
let superseded = spoolEntries.filter {
$0.lastPathComponent.hasPrefix("\(archived.id.uuidString).superseded-")
}
#expect(superseded.count == 1)
let supersededPNG = try #require(superseded.first).appendingPathComponent(capture.fileName)
#expect(FileManager.default.fileExists(atPath: supersededPNG.path))
#expect(try Data(contentsOf: supersededPNG) == png)
}
// MARK: - 12. interrupted-archive, manifest-only
@Test
func interruptedArchiveManifestOnlyCompletesTheMoveIntoArchive() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 6, height: 8, red: 0.7, green: 0.4, blue: 0.1)
let capture = try await store.append(
pngData: png, pixelWidth: 6, pixelHeight: 8, scale: 1.0, capturedAt: Date())
let session = try await store.currentSession()
let marked = session.markArchived(pdfFileName: "partial.pdf")
try AtomicFile.writeJSON(
marked, to: paths.sessionDirectory(session.id).appendingPathComponent("session.json"))
let reopened = try SpoolStore(paths: paths)
let current = try await reopened.currentSession()
#expect(current.id != session.id)
#expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path))
let archiveDir = paths.archiveDirectory(session.id)
#expect(FileManager.default.fileExists(atPath: archiveDir.path))
#expect(FileManager.default.fileExists(atPath: archiveDir.appendingPathComponent(capture.fileName).path))
#expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == png)
let archived = try await reopened.archivedSessions()
#expect(archived.contains(where: { $0.id == session.id }))
}
// MARK: - 13. archivedSessions newest createdAt first
@Test
func archivedSessionsReturnsNewestCreatedAtFirst() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let newest = UUID(uuidString: "00000000-0000-4000-8000-000000000003")!
let oldest = UUID(uuidString: "00000000-0000-4000-8000-000000000001")!
let middle = UUID(uuidString: "00000000-0000-4000-8000-000000000002")!
let tNewest = Date(timeIntervalSince1970: 1_700_000_200)
let tOldest = Date(timeIntervalSince1970: 1_700_000_000)
let tMiddle = Date(timeIntervalSince1970: 1_700_000_100)
try seedSession(id: newest, createdAt: tNewest, state: .archived, directory: paths.archiveDirectory(newest))
try seedSession(id: oldest, createdAt: tOldest, state: .archived, directory: paths.archiveDirectory(oldest))
try seedSession(id: middle, createdAt: tMiddle, state: .archived, directory: paths.archiveDirectory(middle))
let store = try SpoolStore(paths: paths)
let listed = try await store.archivedSessions()
#expect(listed.map(\.id) == [newest, middle, oldest])
}
// MARK: - 14. newest-session tie-break by greater UUID string
@Test
func newestSessionTieBreakPicksTheLexicographicallyGreaterUUID() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let smaller = UUID(uuidString: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")!
let greater = UUID(uuidString: "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB")!
let createdAt = Date(timeIntervalSince1970: 1_700_000_500)
try seedSession(id: smaller, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(smaller))
try seedSession(id: greater, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(greater))
let first = try SpoolStore(paths: paths)
let firstID = try await first.currentSession().id
let second = try SpoolStore(paths: paths)
let secondID = try await second.currentSession().id
#expect(firstID == greater)
#expect(secondID == greater)
}
// MARK: - 15. filename shape
@Test
func appendFilenameMatchesSequenceDashEightHexSuffix() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let capture = try await store.append(
pngData: try makePNGData(width: 4, height: 3, red: 0.1, green: 0.1, blue: 0.1),
pixelWidth: 4, pixelHeight: 3, scale: 1.0, capturedAt: Date())
let hex = String(capture.id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased()
#expect(capture.fileName == "001-\(hex).png")
let session = try await store.currentSession()
let url = await store.imageURL(for: capture, in: session)
#expect(url.lastPathComponent == capture.fileName)
#expect(FileManager.default.fileExists(atPath: url.path))
}
// MARK: - 16. startNewSession
@Test
func startNewSessionThrowsWhenNonEmptyAndMintsANewIdWhenEmpty() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
_ = try await store.append(
pngData: try makePNGData(width: 5, height: 5, red: 0.4, green: 0.2, blue: 0.6),
pixelWidth: 5, pixelHeight: 5, scale: 1.0, capturedAt: Date())
let before = try snapshotFiles(under: root)
let occupied = try await store.currentSession()
do {
_ = try await store.startNewSession()
Issue.record("expected startNewSession to throw while captures are present")
} catch let error as ShotdeckError {
guard case .spoolWriteFailed = error else {
Issue.record("expected spoolWriteFailed, got \(error)")
return
}
} catch {
Issue.record("expected ShotdeckError, got \(error)")
return
}
#expect(try snapshotFiles(under: root) == before)
#expect(try await store.currentSession().id == occupied.id)
_ = try await store.remove(captureID: occupied.captures[0].id)
let emptyID = try await store.currentSession().id
let fresh = try await store.startNewSession()
#expect(fresh.id != emptyID)
#expect(fresh.isEmpty)
#expect(FileManager.default.fileExists(
atPath: paths.sessionDirectory(emptyID).appendingPathComponent("session.json").path))
}
// MARK: - 17. durability-ordering smoke test
@Test
func appendReturnsOnlyAfterThePNGBytesAreAlreadyOnDisk() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 13, height: 11, red: 0.15, green: 0.55, blue: 0.95)
let capture = try await store.append(
pngData: png, pixelWidth: 13, pixelHeight: 11, scale: 2.0, capturedAt: Date())
let session = try await store.currentSession()
let url = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName)
let onDisk = try Data(contentsOf: url)
#expect(onDisk == png)
}
// MARK: - Fixtures
private func makeIsolatedPaths() throws -> (root: URL, paths: AppSupportPaths) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-spool-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: root,
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
return (root, paths)
}
private func makePNGData(
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 FixtureError.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 FixtureError.pngGenerationFailed
}
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else {
throw FixtureError.pngGenerationFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw FixtureError.pngGenerationFailed
}
return buffer as Data
}
private func pngDimensions(at url: URL) -> (width: Int, height: Int)? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?,
let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue
else { return nil }
return (width, height)
}
private func seedSession(
id: UUID,
createdAt: Date,
state: SessionState,
directory: URL
) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let session = CaptureSession(id: id, createdAt: createdAt, state: state, captures: [], pdfFileName: nil)
try AtomicFile.writeJSON(session, to: directory.appendingPathComponent("session.json"))
}
private func snapshotFiles(under root: URL) throws -> [String: Data] {
let fm = FileManager.default
var files: [String: Data] = [:]
guard let enumerator = fm.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { return files }
let rootPath = root.standardizedFileURL.path
for case let url as URL in enumerator {
let values = try url.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { continue }
var relative = url.standardizedFileURL.path
if relative.hasPrefix(rootPath) {
relative = String(relative.dropFirst(rootPath.count))
if relative.hasPrefix("/") { relative = String(relative.dropFirst()) }
}
files[relative] = try Data(contentsOf: url)
}
return files
}
private enum FixtureError: Error {
case pngGenerationFailed
}