Redline v0.2.0 — complete app (integration branch → main) #22

Merged
kua-agent merged 45 commits from feat/shotdeck-20260830 into main 2026-09-02 06:09:38 +00:00
2 changed files with 327 additions and 0 deletions
Showing only changes of commit 8dc97d02f2 - Show all commits
@@ -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()
}
}