From 49b1a9ea830fd88b947012cef5b7d50eb1c8557a Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 15:23:05 +0400 Subject: [PATCH] WP-5b: FSEvents ReturnWatcher per SPEC-A1c with review corrections --- .../ShotdeckCore/Returns/ReturnWatcher.swift | 154 ++++++++++++++++ .../ReturnWatcherTests.swift | 173 ++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 Sources/ShotdeckCore/Returns/ReturnWatcher.swift create mode 100644 Tests/ShotdeckCoreTests/ReturnWatcherTests.swift diff --git a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift new file mode 100644 index 0000000..e19552f --- /dev/null +++ b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift @@ -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? + 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.fromOpaque(info).takeUnretainedValue().notify() +} diff --git a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift new file mode 100644 index 0000000..5b828b3 --- /dev/null +++ b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift @@ -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.. (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() + } +}