WP-5b: FSEvents ReturnWatcher #7
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user