From 97d21be4701a57cff19fc95464546c5c8b8c8958 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sun, 30 Aug 2026 20:56:37 +0400 Subject: [PATCH 01/23] WP-0: SwiftPM scaffold, shared model, signed app bundle script --- .gitignore | 8 ++ Info.plist | 24 ++++++ Package.swift | 31 ++++++++ README.md | 23 +++++- Sources/Shotdeck/main.swift | 6 ++ Sources/ShotdeckCore/Model/Capture.swift | 43 +++++++++++ .../ShotdeckCore/Model/CaptureSession.swift | 73 ++++++++++++++++++ .../ShotdeckCore/Model/ShotdeckError.swift | 36 +++++++++ .../Support/AppSupportPaths.swift | 65 ++++++++++++++++ Sources/ShotdeckCore/Support/DubaiTime.swift | 40 ++++++++++ .../AppSupportPathsTests.swift | 75 +++++++++++++++++++ scripts/build-app.sh | 73 ++++++++++++++++++ 12 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 Info.plist create mode 100644 Package.swift create mode 100644 Sources/Shotdeck/main.swift create mode 100644 Sources/ShotdeckCore/Model/Capture.swift create mode 100644 Sources/ShotdeckCore/Model/CaptureSession.swift create mode 100644 Sources/ShotdeckCore/Model/ShotdeckError.swift create mode 100644 Sources/ShotdeckCore/Support/AppSupportPaths.swift create mode 100644 Sources/ShotdeckCore/Support/DubaiTime.swift create mode 100644 Tests/ShotdeckCoreTests/AppSupportPathsTests.swift create mode 100755 scripts/build-app.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a383133 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.build/ +.swiftpm/ +Packages/ +*.xcodeproj +xcuserdata/ +DerivedData/ +.DS_Store +.netrc diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..eb05ed5 --- /dev/null +++ b/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleIdentifier + ai.flowmaster.shotdeck + CFBundleName + Shotdeck + CFBundleExecutable + Shotdeck + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSUIElement + + LSMinimumSystemVersion + 14.0 + NSHumanReadableCopyright + Copyright © 2026 Flowmaster FZC LLC. All rights reserved. + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e8361c4 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Shotdeck", + platforms: [ + .macOS(.v14), + ], + products: [ + .library(name: "ShotdeckCore", targets: ["ShotdeckCore"]), + .executable(name: "Shotdeck", targets: ["Shotdeck"]), + ], + dependencies: [], + targets: [ + .target( + name: "ShotdeckCore", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "Shotdeck", + dependencies: ["ShotdeckCore"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "ShotdeckCoreTests", + dependencies: ["ShotdeckCore"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + ] +) diff --git a/README.md b/README.md index 3c0ab7f..9158292 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ -# shotdeck +# Shotdeck -macOS menu-bar app: hotkey region capture into a review PDF with PASS/FAIL boxes, AirDrop send, annotation-return tracking \ No newline at end of file +A macOS menu-bar app that captures a remembered screen region, builds a one-screenshot-per-page PDF, AirDrops it to an iPad for markup, then watches for the annotated file to come back. + +## Hotkeys + +- **⌥⇧1** — pick a new region, then capture it. +- **⌥⇧2** — capture the remembered region instantly (no picker). + +## Screen Recording permission + +On first launch macOS asks once for Screen Recording. Grant it at **System Settings > Privacy & Security > Screen Recording**. You never have to do this again as long as the app is not re-signed with a different identity. + +The grant is bound to the bundle identifier `ai.flowmaster.shotdeck` plus the code signature. Changing either one forces a fresh prompt. + +## Build + +```bash +swift build && swift test +./scripts/build-app.sh # signed .app for daily use +open .build/Shotdeck.app +``` diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift new file mode 100644 index 0000000..0cba481 --- /dev/null +++ b/Sources/Shotdeck/main.swift @@ -0,0 +1,6 @@ +import AppKit + +// WP-4 replaces this body with the real menu-bar UI. +let application = NSApplication.shared +application.setActivationPolicy(.accessory) +application.run() diff --git a/Sources/ShotdeckCore/Model/Capture.swift b/Sources/ShotdeckCore/Model/Capture.swift new file mode 100644 index 0000000..15eea33 --- /dev/null +++ b/Sources/ShotdeckCore/Model/Capture.swift @@ -0,0 +1,43 @@ +import Foundation + +/// One screenshot plus everything needed to place it on a PDF page. +public struct Capture: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + /// 1-based position within its session. Stable; never renumbered on delete. + public let sequence: Int + /// File name only (e.g. "001-A1B2C3D4.png"), never a path. + /// The directory is always the owning session's directory. + public let fileName: String + /// Pixel dimensions of the PNG on disk. + public let pixelWidth: Int + public let pixelHeight: Int + /// Backing scale the capture was taken at (2.0 on Retina). Needed so the PDF + /// composer lays the image out at its true point size, not its pixel size. + public let scale: CGFloat + /// When the screenshot was taken. Always stored as an absolute instant; + /// rendered in Asia/Dubai wherever a human sees it. + public let capturedAt: Date + + public init( + id: UUID, + sequence: Int, + fileName: String, + pixelWidth: Int, + pixelHeight: Int, + scale: CGFloat, + capturedAt: Date + ) { + self.id = id + self.sequence = sequence + self.fileName = fileName + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + self.scale = scale + self.capturedAt = capturedAt + } + + /// True when the image is wider than it is tall. Drives page orientation. + public var isLandscape: Bool { + pixelWidth > pixelHeight + } +} diff --git a/Sources/ShotdeckCore/Model/CaptureSession.swift b/Sources/ShotdeckCore/Model/CaptureSession.swift new file mode 100644 index 0000000..c12644e --- /dev/null +++ b/Sources/ShotdeckCore/Model/CaptureSession.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum SessionState: String, Codable, Sendable { + case open // accepting captures + case archived // its PDF has been built and sent; kept forever, never deleted +} + +/// The manifest persisted as session.json alongside the PNGs. +public struct CaptureSession: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + public let createdAt: Date + public private(set) var state: SessionState + public private(set) var captures: [Capture] + /// Set when the PDF is built, so a re-send reuses the same file. + public private(set) var pdfFileName: String? + + public init( + id: UUID, + createdAt: Date, + state: SessionState, + captures: [Capture], + pdfFileName: String? + ) { + self.id = id + self.createdAt = createdAt + self.state = state + self.captures = captures + self.pdfFileName = pdfFileName + } + + public var isEmpty: Bool { + captures.isEmpty + } + + /// max(sequence)+1, or 1 when empty. + public var nextSequence: Int { + (captures.map(\.sequence).max() ?? 0) + 1 + } + + /// Value-semantic: returns a new session; does not mutate `self`. + public func appending(_ capture: Capture) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: state, + captures: captures + [capture], + pdfFileName: pdfFileName + ) + } + + /// Value-semantic: returns a new session; does not mutate `self`. + /// Sequence numbers of remaining captures are left unchanged. + public func removing(captureID: UUID) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: state, + captures: captures.filter { $0.id != captureID }, + pdfFileName: pdfFileName + ) + } + + /// Value-semantic: returns a new session; does not mutate `self`. + public func markArchived(pdfFileName: String) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: .archived, + captures: captures, + pdfFileName: pdfFileName + ) + } +} diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift new file mode 100644 index 0000000..77a19ee --- /dev/null +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum ShotdeckError: Error, LocalizedError, Sendable { + case screenRecordingNotGranted + case noRegionRemembered + case displayNoLongerConnected(displayID: UInt32) + case captureFailed(underlying: String) + case spoolWriteFailed(path: String, underlying: String) + case manifestCorrupt(path: String) + case pdfCompositionFailed(reason: String) + case airDropUnavailable + case noCommentedReturns + + public var errorDescription: String? { + switch self { + case .screenRecordingNotGranted: + return "Screen Recording is turned off. Grant it in System Settings to capture." + case .noRegionRemembered: + return "No capture region is set. Press ⌥⇧1 to pick one." + case .displayNoLongerConnected: + return "The display used for capture is no longer connected." + case .captureFailed(let underlying): + return "The screenshot could not be taken. \(underlying)" + case .spoolWriteFailed(_, let underlying): + return "The screenshot could not be saved. \(underlying)" + case .manifestCorrupt: + return "This session's file is damaged and cannot be opened." + case .pdfCompositionFailed(let reason): + return "The PDF could not be built. \(reason)" + case .airDropUnavailable: + return "AirDrop is not available right now." + case .noCommentedReturns: + return "None of the returned PDFs have comments on them." + } + } +} diff --git a/Sources/ShotdeckCore/Support/AppSupportPaths.swift b/Sources/ShotdeckCore/Support/AppSupportPaths.swift new file mode 100644 index 0000000..7f1dccf --- /dev/null +++ b/Sources/ShotdeckCore/Support/AppSupportPaths.swift @@ -0,0 +1,65 @@ +import Foundation + +/// The only type in the app that knows the on-disk directory layout. +public struct AppSupportPaths: Sendable { + public let root: URL + public let spool: URL + public let archive: URL + public let outbox: URL + public let watchFolder: URL + + /// Production paths. + public static func standard() throws -> AppSupportPaths { + let fileManager = FileManager.default + let appSupportParent = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let desktop = try fileManager.url( + for: .desktopDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let downloads = try fileManager.url( + for: .downloadsDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let root = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true) + return try AppSupportPaths(root: root, outbox: desktop, watchFolder: downloads) + } + + /// Test paths rooted anywhere. Every directory is created if missing. + public init(root: URL, outbox: URL, watchFolder: URL) throws { + self.root = root + self.spool = root.appendingPathComponent("spool", isDirectory: true) + self.archive = root.appendingPathComponent("archive", isDirectory: true) + self.outbox = outbox + self.watchFolder = watchFolder + + try Self.createDirectory(self.root) + try Self.createDirectory(self.spool) + try Self.createDirectory(self.archive) + try Self.createDirectory(self.outbox) + try Self.createDirectory(self.watchFolder) + } + + public func sessionDirectory(_ id: UUID) -> URL { + spool.appendingPathComponent(id.uuidString, isDirectory: true) + } + + public func archiveDirectory(_ id: UUID) -> URL { + archive.appendingPathComponent(id.uuidString, isDirectory: true) + } + + private static func createDirectory(_ url: URL) throws { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + } +} diff --git a/Sources/ShotdeckCore/Support/DubaiTime.swift b/Sources/ShotdeckCore/Support/DubaiTime.swift new file mode 100644 index 0000000..77313ac --- /dev/null +++ b/Sources/ShotdeckCore/Support/DubaiTime.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum DubaiTime { + private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'") + private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss") + + public static func stamp(_ date: Date) -> String { + stampFormatter.string(from: date) + } + + public static func fileStamp(_ date: Date) -> String { + fileStampFormatter.string(from: date) + } +} + +/// DateFormatter is not Sendable. This holder is the only shared mutable state +/// around a formatter: every read is serialized by the lock, so concurrent +/// calls (one per PDF page) cannot race. +private final class LockedDateFormatter: @unchecked Sendable { + private let lock = NSLock() + private let formatter: DateFormatter + + init(dateFormat: String) { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_GB") + guard let dubai = TimeZone(identifier: "Asia/Dubai") else { + preconditionFailure("The Asia/Dubai time zone is missing from this system.") + } + formatter.timeZone = dubai + formatter.dateFormat = dateFormat + self.formatter = formatter + } + + func string(from date: Date) -> String { + lock.lock() + defer { lock.unlock() } + return formatter.string(from: date) + } +} diff --git a/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift b/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift new file mode 100644 index 0000000..3576430 --- /dev/null +++ b/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +import ShotdeckCore + +@Test +func appSupportPathsCreatesEveryNamedDirectory() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-paths-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let root = temporaryRoot.appendingPathComponent("root", isDirectory: true) + let outbox = temporaryRoot.appendingPathComponent("outbox", isDirectory: true) + let watchFolder = temporaryRoot.appendingPathComponent("watch", isDirectory: true) + + let paths = try AppSupportPaths(root: root, outbox: outbox, watchFolder: watchFolder) + + #expect(directoryExists(paths.root)) + #expect(directoryExists(paths.spool)) + #expect(directoryExists(paths.archive)) + #expect(directoryExists(paths.outbox)) + #expect(directoryExists(paths.watchFolder)) + + #expect(paths.spool.path == root.appendingPathComponent("spool", isDirectory: true).path) + #expect(paths.archive.path == root.appendingPathComponent("archive", isDirectory: true).path) +} + +@Test +func sessionAndArchiveDirectoriesAreDistinctUnderTheRightParents() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-dirs-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let paths = try AppSupportPaths( + root: temporaryRoot.appendingPathComponent("root", isDirectory: true), + outbox: temporaryRoot.appendingPathComponent("outbox", isDirectory: true), + watchFolder: temporaryRoot.appendingPathComponent("watch", isDirectory: true) + ) + + let sessionID = UUID() + let sessionDirectory = paths.sessionDirectory(sessionID) + let archiveDirectory = paths.archiveDirectory(sessionID) + + #expect(sessionDirectory.path != archiveDirectory.path) + #expect(sessionDirectory.deletingLastPathComponent().path == paths.spool.path) + #expect(archiveDirectory.deletingLastPathComponent().path == paths.archive.path) + #expect(sessionDirectory.lastPathComponent == sessionID.uuidString) + #expect(archiveDirectory.lastPathComponent == sessionID.uuidString) +} + +@Test +func dubaiTimeStampRendersAKnownInstantInAsiaDubai() throws { + var calendar = Calendar(identifier: .gregorian) + let dubai = try #require(TimeZone(identifier: "Asia/Dubai")) + calendar.timeZone = dubai + + let date = try #require( + calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 30, + hour: 13, + minute: 42, + second: 5 + )) + ) + + #expect(DubaiTime.stamp(date) == "30 Aug 2026, 13:42 Dubai") + #expect(DubaiTime.fileStamp(date) == "20260830-134205") +} + +private func directoryExists(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..b96899c --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# macOS keys the Screen Recording permission to the bundle identifier plus the +# code signature. The identity below and --identifier ai.flowmaster.shotdeck must +# never change: altering either one makes the existing grant invalid and forces +# the user to approve Screen Recording again by hand. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" +BUNDLE_ID="ai.flowmaster.shotdeck" +APP_BUNDLE="${ROOT}/.build/Shotdeck.app" + +SKIP_SIGN=0 +for arg in "$@"; do + case "${arg}" in + --skip-sign) + SKIP_SIGN=1 + ;; + *) + echo "Unknown argument: ${arg}" >&2 + echo "Usage: $0 [--skip-sign]" >&2 + exit 1 + ;; + esac +done + +echo "==> Building Shotdeck (release)" +swift build -c release --product Shotdeck + +BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck" +if [[ ! -x "${BIN_PATH}" ]]; then + echo "Release binary not found at ${BIN_PATH}" >&2 + exit 1 +fi + +echo "==> Assembling ${APP_BUNDLE}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${APP_BUNDLE}/Contents/MacOS" +mkdir -p "${APP_BUNDLE}/Contents/Resources" +cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck" +chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck" +cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist" + +if [[ "${SKIP_SIGN}" -eq 1 ]]; then + echo + echo "************************************************************************" + echo "WARNING: --skip-sign was used. This app is UNSIGNED." + echo "The resulting app will trigger a fresh Screen Recording prompt and must" + echo "not be handed to the user." + echo "************************************************************************" + echo +else + if ! security find-identity -v -p codesigning | grep -Fq "${IDENTITY}"; then + echo "The codesigning identity '${IDENTITY}' is not in this shell's keychain search list." >&2 + echo "The login keychain is not reachable from this shell." >&2 + echo "Run this script from a normal login session so the app can be signed." >&2 + echo "Refusing to produce an unsigned app." >&2 + exit 1 + fi + + echo "==> Signing ${APP_BUNDLE}" + codesign --force --options runtime \ + --sign "${IDENTITY}" \ + --identifier "${BUNDLE_ID}" \ + "${APP_BUNDLE}" +fi + +echo +echo "App path: ${APP_BUNDLE}" +echo "Launch with: open ${APP_BUNDLE}" From dc8fa0a2fe5b0ec64518d50aa7a83d818c4deb53 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:39:34 +0400 Subject: [PATCH 02/23] WP-3a: CaptureRegion geometry + Carbon HotkeyCenter per SPEC-A2a --- .../ShotdeckCore/Capture/CaptureRegion.swift | 58 ++++++ .../ShotdeckCore/Capture/HotkeyCenter.swift | 115 ++++++++++++ .../CaptureRegionTests.swift | 171 ++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 Sources/ShotdeckCore/Capture/CaptureRegion.swift create mode 100644 Sources/ShotdeckCore/Capture/HotkeyCenter.swift create mode 100644 Tests/ShotdeckCoreTests/CaptureRegionTests.swift diff --git a/Sources/ShotdeckCore/Capture/CaptureRegion.swift b/Sources/ShotdeckCore/Capture/CaptureRegion.swift new file mode 100644 index 0000000..d5a7ab2 --- /dev/null +++ b/Sources/ShotdeckCore/Capture/CaptureRegion.swift @@ -0,0 +1,58 @@ +import Foundation +import AppKit // NSScreen, NSDeviceDescriptionKey — macOS-only target, no portability concern + +/// A remembered rectangle, in CoreGraphics global display coordinates (origin top-left, +/// y increasing downward — see the conversion note below). +public struct CaptureRegion: Codable, Sendable, Equatable { + public let displayID: CGDirectDisplayID + public let rect: CGRect + public let capturedScale: CGFloat + + public init(displayID: CGDirectDisplayID, rect: CGRect, capturedScale: CGFloat) { + self.displayID = displayID + self.rect = rect + self.capturedScale = capturedScale + } + + /// Converts an AppKit rect in GLOBAL AppKit coordinates (bottom-left origin, y increasing + /// upward, anchored at the bottom-left of the PRIMARY screen — exactly what + /// `NSWindow.frame`, `NSEvent.mouseLocation` and `NSScreen.frame` already report; no + /// screen-local conversion is needed here) into global CoreGraphics coordinates + /// (top-left origin, y increasing downward, same primary-screen anchor). Both coordinate + /// systems share the SAME horizontal origin and the SAME anchor rectangle (the primary + /// screen's bounds) — only the vertical axis is mirrored around the primary's height. + /// That is why the flip below is correct for every attached screen, including one with a + /// negative AppKit x (to the left of primary) or a y that places it above the primary + /// (whose CG y then comes out negative): x is untouched, and the primary height is the + /// one fixed reference both systems agree on regardless of which physical screen the + /// rect is actually on. + public static func fromAppKit(rect: CGRect, on screen: NSScreen) -> CaptureRegion { + let displayID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber)?.uint32Value ?? CGMainDisplayID() + let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height + return fromAppKit(rect: rect, displayID: displayID, + capturedScale: screen.backingScaleFactor, primaryHeight: primaryHeight) + } + + /// Injectable overload for deterministic geometry tests — no dependency on real attached + /// displays. The public overload above is a thin adapter over this. + static func fromAppKit(rect: CGRect, displayID: CGDirectDisplayID, + capturedScale: CGFloat, primaryHeight: CGFloat) -> CaptureRegion { + let cgY = primaryHeight - rect.origin.y - rect.height + let cgRect = CGRect(x: rect.origin.x, y: cgY, width: rect.width, height: rect.height) + return CaptureRegion(displayID: displayID, rect: cgRect, capturedScale: capturedScale) + } + + /// True when `displayID` is still attached AND `rect` still lies inside its current + /// bounds. Uses `CGDisplayIsActive` (returns 0 safely for an unknown id, never traps) and + /// `CGDisplayBounds` — both real CoreGraphics calls, no injection needed since this is a + /// live-state check by design. + public var isStillValid: Bool { + guard CGDisplayIsActive(displayID) != 0 else { return false } + let bounds = CGDisplayBounds(displayID) + return bounds.contains(rect) + } + + /// Persisted in UserDefaults under this key BY THE APP (WP-4a), never by this type. + public static let defaultsKey = "ai.flowmaster.shotdeck.region" +} diff --git a/Sources/ShotdeckCore/Capture/HotkeyCenter.swift b/Sources/ShotdeckCore/Capture/HotkeyCenter.swift new file mode 100644 index 0000000..ef2e821 --- /dev/null +++ b/Sources/ShotdeckCore/Capture/HotkeyCenter.swift @@ -0,0 +1,115 @@ +import Carbon.HIToolbox +import Foundation + +/// Carbon's C event handler cannot capture Swift closures, so live handlers are kept in one +/// process-wide table keyed by the numeric EventHotKeyID Carbon hands back on fire. Safe +/// because InstallEventHandler on GetApplicationEventTarget() always delivers on the main +/// thread, and every access here happens either from a @MainActor HotkeyCenter method or from +/// the C callback below, which this process only ever invokes on the main run loop. +private final class HotkeyDispatchTable: @unchecked Sendable { + static let shared = HotkeyDispatchTable() + private var handlers: [UInt32: @MainActor () -> Void] = [:] + private init() {} + func set(_ numericID: UInt32, _ handler: @escaping @MainActor () -> Void) { + handlers[numericID] = handler + } + func remove(_ numericID: UInt32) { handlers.removeValue(forKey: numericID) } + func fire(_ numericID: UInt32) { MainActor.assumeIsolated { handlers[numericID]?() } } +} + +private func shotdeckCarbonHotkeyHandler( + _ nextHandler: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + var hotKeyID = EventHotKeyID() + let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID) + guard status == noErr else { return status } + HotkeyDispatchTable.shared.fire(hotKeyID.id) + return noErr +} + +/// Four-character creator code used for every `EventHotKeyID` this process registers. +private let shotdeckHotKeySignature: OSType = 0x53484F54 // "SHOT" + +@MainActor +public final class HotkeyCenter { + private var bindings: [String: (ref: EventHotKeyRef, numericID: UInt32)] = [:] + private var nextNumericID: UInt32 = 1 + + private static var didInstallGlobalCarbonHandler = false + private static var carbonHandlerRef: EventHandlerRef? + + public init() { HotkeyCenter.installGlobalCarbonHandlerIfNeeded() } + + /// Registers a hotkey. `id` is a caller-chosen stable identifier. Re-registering the same + /// `id` first unregisters its previous binding, then attempts the new one (idempotent). + /// Returns false when Carbon's `RegisterEventHotKey` reports a non-`noErr` status — the + /// most common cause is the same keyCode+modifiers combination already being claimed + /// system-wide by this or another process. + @discardableResult + public func register( + id: String, + keyCode: UInt32, + modifiers: UInt32, + handler: @escaping @MainActor () -> Void + ) -> Bool { + unregister(id: id) + + let hotKeyID = EventHotKeyID(signature: shotdeckHotKeySignature, id: nextNumericID) + var ref: EventHotKeyRef? + let status = RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &ref + ) + guard status == noErr, let ref else { + return false + } + bindings[id] = (ref: ref, numericID: nextNumericID) + HotkeyDispatchTable.shared.set(nextNumericID, handler) + nextNumericID += 1 + return true + } + + public func unregister(id: String) { + guard let binding = bindings.removeValue(forKey: id) else { return } + _ = UnregisterEventHotKey(binding.ref) + HotkeyDispatchTable.shared.remove(binding.numericID) + } + + public func unregisterAll() { + let ids = Array(bindings.keys) + for id in ids { + unregister(id: id) + } + } + + /// Installs the process-wide Carbon hot-key pressed handler exactly once. + private static func installGlobalCarbonHandlerIfNeeded() { + guard !didInstallGlobalCarbonHandler else { return } + didInstallGlobalCarbonHandler = true + + var handlerRef: EventHandlerRef? + var eventTypes = [ + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: OSType(kEventHotKeyPressed) + ) + ] + let status = InstallEventHandler( + GetApplicationEventTarget(), + shotdeckCarbonHotkeyHandler, + 1, + &eventTypes, + nil, + &handlerRef + ) + if status == noErr { + carbonHandlerRef = handlerRef + } + } +} diff --git a/Tests/ShotdeckCoreTests/CaptureRegionTests.swift b/Tests/ShotdeckCoreTests/CaptureRegionTests.swift new file mode 100644 index 0000000..792b9d1 --- /dev/null +++ b/Tests/ShotdeckCoreTests/CaptureRegionTests.swift @@ -0,0 +1,171 @@ +import Carbon.HIToolbox +import Foundation +import Testing +@testable import ShotdeckCore + +@Test("GEO-1 primary-only 1080-tall screen, AppKit rect (100,100,400,300)") +func geo1_primaryOnlyAppKitRectConvertsToExpectedCGRect() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 100, y: 100, width: 400, height: 300), + displayID: 1, + capturedScale: 2, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: 100, y: 680, width: 400, height: 300)) +} + +@Test("GEO-2 rect flush to AppKit bottom (y=0), h=300, primaryHeight=1080") +func geo2_flushToAppKitBottomYieldsCGY780() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 50, y: 0, width: 200, height: 300), + displayID: 1, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect.origin.y == 780) +} + +@Test("GEO-3 rect flush to AppKit top (y+h=primaryHeight), primaryHeight=1080, h=300") +func geo3_flushToAppKitTopYieldsCGY0() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 50, y: 780, width: 200, height: 300), + displayID: 1, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect.origin.y == 0) +} + +@Test("GEO-4 round-trip AppKit→CG via injectable overload, then invert recovers original") +func geo4_invertRecoverOriginalAppKitRect() { + let original = CGRect(x: 100, y: 100, width: 400, height: 300) + let primaryHeight: CGFloat = 1080 + let region = CaptureRegion.fromAppKit( + rect: original, + displayID: 1, + capturedScale: 2, + primaryHeight: primaryHeight + ) + let appKitY = primaryHeight - region.rect.origin.y - region.rect.height + let recovered = CGRect( + x: region.rect.origin.x, + y: appKitY, + width: region.rect.width, + height: region.rect.height + ) + #expect(recovered == original) +} + +@Test("GEO-5 isStillValid for displayID 999_999 is false and does not trap") +func geo5_unknownDisplayIsNotStillValid() { + let region = CaptureRegion( + displayID: 999_999, + rect: CGRect(x: 0, y: 0, width: 100, height: 100), + capturedScale: 1 + ) + #expect(region.isStillValid == false) +} + +@Test("GEO-6 CaptureRegion JSONEncoder→JSONDecoder round-trip equals original") +func geo6_codableRoundTripEqualsOriginal() throws { + let original = CaptureRegion( + displayID: 42, + rect: CGRect(x: 10, y: 20, width: 300, height: 400), + capturedScale: 2 + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CaptureRegion.self, from: data) + #expect(decoded == original) +} + +@Test("GEO-7 secondary screen negative x: AppKit (−1800,200,500,400) → CG (−1800,480,500,400)") +func geo7_secondaryNegativeXLeavesXUnchanged() { + // Secondary AppKit frame (−1920, 0, 1920, 1080); primaryHeight=1080. + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: -1800, y: 200, width: 500, height: 400), + displayID: 2, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: -1800, y: 480, width: 500, height: 400)) +} + +@Test("GEO-8 secondary screen stacked above primary: AppKit (300,1300,600,350) → CG (300,−570,600,350)") +func geo8_secondaryPositiveYProducesNegativeCGY() { + // Secondary AppKit frame (0, 1080, 1920, 1080); primaryHeight=1080. + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 300, y: 1300, width: 600, height: 350), + displayID: 2, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: 300, y: -570, width: 600, height: 350)) +} + +/// Carbon registrations are process-wide, so these cases must not run in parallel. +@Suite(.serialized) +@MainActor +struct HotkeyCenterCarbonTests { + /// kVK_ANSI_2. The app registers this combo once (WP-4a); tests only exercise the registrar. + private let captureKeyCode: UInt32 = 19 + private let captureModifiers = UInt32(optionKey) | UInt32(shiftKey) + + @Test("HK-1 register id a with Option-Shift-2 returns true") + func hk1_registerReturnsTrue() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let ok = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !ok { + Issue.record("HK-1: RegisterEventHotKey returned non-noErr inside swift test (no NSApplication). Carbon registration did not function headlessly.") + return + } + #expect(ok) + } + + @Test("HK-2 register same combo under a different id returns false") + func hk2_duplicateComboRejected() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !first { + Issue.record("HK-2: first RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate duplicate-combo rejection.") + return + } + let second = center.register(id: "b", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(second == false) + } + + @Test("HK-3 unregister frees the combination so a later register succeeds") + func hk3_unregisterFreesCombination() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !first { + Issue.record("HK-3: first RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate unregister.") + return + } + center.unregister(id: "a") + let again = center.register(id: "c", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(again) + } + + @Test("HK-4 unregisterAll frees combinations so a later register succeeds") + func hk4_unregisterAllFreesCombinations() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + let other = center.register( + id: "other", + keyCode: UInt32(kVK_ANSI_3), + modifiers: captureModifiers + ) {} + if !first { + Issue.record("HK-4: RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate unregisterAll.") + return + } + _ = other + center.unregisterAll() + let again = center.register(id: "c", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(again) + } +} From 31721b431ca6852e8895bdb635483af476ce6e25 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:40:21 +0400 Subject: [PATCH 03/23] WP-0b: AtomicFile, Log, FolderSettings foundation per SPEC-A1a; drop stale hotkey string --- .../ShotdeckCore/Model/ShotdeckError.swift | 2 +- Sources/ShotdeckCore/Support/AtomicFile.swift | 104 ++++++++++ .../ShotdeckCore/Support/FolderSettings.swift | 111 +++++++++++ Sources/ShotdeckCore/Support/Log.swift | 9 + Tests/ShotdeckCoreTests/AtomicFileTests.swift | 178 ++++++++++++++++++ .../FolderSettingsTests.swift | 168 +++++++++++++++++ 6 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 Sources/ShotdeckCore/Support/AtomicFile.swift create mode 100644 Sources/ShotdeckCore/Support/FolderSettings.swift create mode 100644 Sources/ShotdeckCore/Support/Log.swift create mode 100644 Tests/ShotdeckCoreTests/AtomicFileTests.swift create mode 100644 Tests/ShotdeckCoreTests/FolderSettingsTests.swift diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift index 77a19ee..84880c8 100644 --- a/Sources/ShotdeckCore/Model/ShotdeckError.swift +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -16,7 +16,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable { case .screenRecordingNotGranted: return "Screen Recording is turned off. Grant it in System Settings to capture." case .noRegionRemembered: - return "No capture region is set. Press ⌥⇧1 to pick one." + return "No capture region is set. Choose 'Re-select area' from the Shotdeck menu." case .displayNoLongerConnected: return "The display used for capture is no longer connected." case .captureFailed(let underlying): diff --git a/Sources/ShotdeckCore/Support/AtomicFile.swift b/Sources/ShotdeckCore/Support/AtomicFile.swift new file mode 100644 index 0000000..d9cd30a --- /dev/null +++ b/Sources/ShotdeckCore/Support/AtomicFile.swift @@ -0,0 +1,104 @@ +import Foundation +import Darwin + +public enum AtomicFile { + /// Writes `data` to `url` durably: writes to `.tmp` in the SAME directory as `url`, + /// fsyncs that file descriptor, closes it, rename()s it onto `url` (atomic same-volume + /// rename), then opens `url`'s containing directory and fsyncs THAT too (a rename is only + /// durable once its directory entry is flushed). Never uses `Data.write(to:)` — that call + /// does not fsync. + public static func write(_ data: Data, to url: URL) throws { + let finalPath = url.path + let directoryURL = url.deletingLastPathComponent() + let tmpURL = directoryURL.appendingPathComponent(url.lastPathComponent + ".tmp") + let tmpPath = tmpURL.path + + // Clear a stale .tmp left by a previous crash. ENOENT (nothing to clear) is fine. + if unlink(tmpPath) != 0 && errno != ENOENT { + throw ShotdeckError.spoolWriteFailed( + path: finalPath, + underlying: "could not clear a stale temp file: \(String(cString: strerror(errno)))") + } + + let fd = open(tmpPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard fd >= 0 else { + throw ShotdeckError.spoolWriteFailed( + path: finalPath, underlying: "open failed: \(String(cString: strerror(errno)))") + } + + var writeFailure: String? + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + var remaining = raw.count + var pointer = raw.baseAddress + while remaining > 0 { + let n = Darwin.write(fd, pointer, remaining) + if n < 0 { + if errno == EINTR { continue } + writeFailure = "write failed: \(String(cString: strerror(errno)))" + break + } + if n == 0 { break } + remaining -= n + pointer = pointer?.advanced(by: n) + } + } + if let writeFailure { + close(fd) + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: writeFailure) + } + if fsync(fd) != 0 { + let message = "fsync failed: \(String(cString: strerror(errno)))" + close(fd) + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: message) + } + if close(fd) != 0 { + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed( + path: finalPath, underlying: "close failed: \(String(cString: strerror(errno)))") + } + if rename(tmpPath, finalPath) != 0 { + let message = "rename failed: \(String(cString: strerror(errno)))" + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: message) + } + try fsyncDirectory(at: directoryURL) + } + + /// Encodes `value` with `JSONEncoder` (`.sortedKeys, .prettyPrinted`, + /// `.dateEncodingStrategy = .iso8601`) and writes it through `write(_:to:)`. + public static func writeJSON(_ value: T, to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + encoder.dateEncodingStrategy = .iso8601 + let data: Data + do { + data = try encoder.encode(value) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: url.path, underlying: "JSON encoding failed: \(error.localizedDescription)") + } + try write(data, to: url) + } + + /// Opens `url` (must be an existing directory) and fsyncs it. Used after any directory- + /// level `rename()` (moving/renaming a whole session directory) — the same durability + /// requirement as the internal directory-fsync inside `write(_:to:)`, exposed for callers + /// that rename directories themselves (SpoolStore). + public static func fsyncDirectory(at url: URL) throws { + let fd = open(url.path, O_RDONLY | O_DIRECTORY) + guard fd >= 0 else { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: "could not open directory for fsync: \(String(cString: strerror(errno)))") + } + let result = fsync(fd) + close(fd) + if result != 0 { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: "directory fsync failed: \(String(cString: strerror(errno)))") + } + } +} diff --git a/Sources/ShotdeckCore/Support/FolderSettings.swift b/Sources/ShotdeckCore/Support/FolderSettings.swift new file mode 100644 index 0000000..618b1ae --- /dev/null +++ b/Sources/ShotdeckCore/Support/FolderSettings.swift @@ -0,0 +1,111 @@ +import Foundation + +/// User-configurable overrides for the outbox (PDF send destination) and watch folder +/// (AirDrop return), backed by UserDefaults. Shotdeck is NOT App-Sandboxed (no +/// `com.apple.security.app-sandbox` entitlement in this build — it is a plain, unsandboxed +/// SwiftPM executable). Security-scoped bookmarks exist to let a SANDBOXED app retain access +/// to a user-picked file/folder outside its container across relaunches; an unsandboxed +/// process already has the invoking user's own filesystem permissions on every launch, so a +/// plain absolute path stored in UserDefaults is sufficient — including for a mounted network +/// share, which resolves by path the same as any local folder for as long as it is mounted. +public enum FolderSettings { + public static let outboxDefaultsKey = "ai.flowmaster.shotdeck.outbox" + public static let watchFolderDefaultsKey = "ai.flowmaster.shotdeck.watchFolder" + + /// Raw stored path (or nil if never set / cleared). For display in Settings — does NOT + /// validate that the path still exists. + public static func storedOutboxPath(defaults: UserDefaults = .standard) -> String? { + defaults.string(forKey: outboxDefaultsKey) + } + + public static func storedWatchFolderPath(defaults: UserDefaults = .standard) -> String? { + defaults.string(forKey: watchFolderDefaultsKey) + } + + /// Persists a user-chosen folder as its absolute POSIX path. + public static func setOutbox(_ url: URL, defaults: UserDefaults = .standard) { + defaults.set(url.path, forKey: outboxDefaultsKey) + } + + public static func setWatchFolder(_ url: URL, defaults: UserDefaults = .standard) { + defaults.set(url.path, forKey: watchFolderDefaultsKey) + } + + /// Removes the override; resolve() falls back to the default again. + public static func resetOutbox(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: outboxDefaultsKey) + } + + public static func resetWatchFolder(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: watchFolderDefaultsKey) + } + + /// Resolves both folders. A stored override wins only if it is set AND the directory it + /// names still exists; otherwise falls back to the default (Desktop / Downloads). Never + /// throws — a missing/bad override is logged via `Log.ui` and silently replaced. + public static func resolve( + defaults: UserDefaults = .standard, + fileManager: FileManager = .default + ) -> (outbox: URL, watch: URL) { + let outbox = resolveOne( + storedPath: storedOutboxPath(defaults: defaults), + fallback: defaultOutbox(fileManager: fileManager), + label: "outbox", fileManager: fileManager) + let watch = resolveOne( + storedPath: storedWatchFolderPath(defaults: defaults), + fallback: defaultWatchFolder(fileManager: fileManager), + label: "watch", fileManager: fileManager) + return (outbox, watch) + } + + /// Builds an `AppSupportPaths` using `root` (defaults to the standard + /// `~/Library/Application Support/Shotdeck`, computed the same way + /// `AppSupportPaths.standard()` does, when `root` is nil) plus whatever `resolve()` + /// returns for outbox/watch. This is the ONLY place that combines FolderSettings with + /// AppSupportPaths — call this everywhere in the app instead of `AppSupportPaths.standard()`. + /// `root` is exposed purely so tests can point it at a temporary directory instead of the + /// user's real Application Support folder. + public static func resolvedAppSupportPaths( + root: URL? = nil, + defaults: UserDefaults = .standard, + fileManager: FileManager = .default + ) throws -> AppSupportPaths { + let resolvedRoot: URL + if let root { + resolvedRoot = root + } else { + let appSupportParent = try fileManager.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + resolvedRoot = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true) + } + let folders = resolve(defaults: defaults, fileManager: fileManager) + return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch) + } + + private static func defaultOutbox(fileManager: FileManager) -> URL { + fileManager.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Desktop", isDirectory: true) + } + + private static func defaultWatchFolder(fileManager: FileManager) -> URL { + fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads", isDirectory: true) + } + + private static func resolveOne( + storedPath: String?, + fallback: URL, + label: String, + fileManager: FileManager + ) -> URL { + guard let storedPath else { return fallback } + var isDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory) + guard exists, isDirectory.boolValue else { + Log.ui.warning("Configured \(label, privacy: .public) folder \(storedPath, privacy: .public) no longer exists; falling back to the default.") + return fallback + } + return URL(fileURLWithPath: storedPath, isDirectory: true) + } +} diff --git a/Sources/ShotdeckCore/Support/Log.swift b/Sources/ShotdeckCore/Support/Log.swift new file mode 100644 index 0000000..b230dfb --- /dev/null +++ b/Sources/ShotdeckCore/Support/Log.swift @@ -0,0 +1,9 @@ +import os + +public enum Log { + public static let spool = Logger(subsystem: "ai.flowmaster.shotdeck", category: "spool") + public static let pdf = Logger(subsystem: "ai.flowmaster.shotdeck", category: "pdf") + public static let capture = Logger(subsystem: "ai.flowmaster.shotdeck", category: "capture") + public static let returns = Logger(subsystem: "ai.flowmaster.shotdeck", category: "returns") + public static let ui = Logger(subsystem: "ai.flowmaster.shotdeck", category: "ui") +} diff --git a/Tests/ShotdeckCoreTests/AtomicFileTests.swift b/Tests/ShotdeckCoreTests/AtomicFileTests.swift new file mode 100644 index 0000000..f81bb7a --- /dev/null +++ b/Tests/ShotdeckCoreTests/AtomicFileTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +import ShotdeckCore + +@Test +func atomicWriteProducesByteIdenticalFileAndLeavesNoTmp() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-write") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("payload.bin") + let data = Data("shotdeck-durable-bytes".utf8) + + try AtomicFile.write(data, to: url) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == data) + #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) +} + +@Test +func atomicWriteToTheSameURLTwiceKeepsTheSecondPayload() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-rewrite") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("payload.bin") + let first = Data("first-pass".utf8) + let second = Data("second-pass-wins".utf8) + + try AtomicFile.write(first, to: url) + try AtomicFile.write(second, to: url) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == second) + #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) +} + +@Test +func atomicWriteThrowsWhenParentDirectoryDoesNotExist() throws { + let missingParent = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-atomic-missing-\(UUID().uuidString)", isDirectory: true) + let url = missingParent.appendingPathComponent("payload.bin") + let data = Data("never-written".utf8) + + let error = try #require(throws: ShotdeckError.self) { + try AtomicFile.write(data, to: url) + } + guard case .spoolWriteFailed(let path, _) = error else { + Issue.record("expected spoolWriteFailed, got \(error)") + return + } + #expect(path == url.path) + #expect(!FileManager.default.fileExists(atPath: url.path)) +} + +@Test +func atomicWriteClearsAStaleTmpAndWritesTheNewPayload() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-stale-tmp") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("payload.bin") + let tmpURL = directory.appendingPathComponent(url.lastPathComponent + ".tmp") + let garbage = Data("stale-crash-garbage".utf8) + let newData = Data("recovered-payload".utf8) + + try garbage.write(to: tmpURL) + #expect(FileManager.default.fileExists(atPath: tmpURL.path)) + + try AtomicFile.write(newData, to: url) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == newData) + #expect(!FileManager.default.fileExists(atPath: tmpURL.path)) +} + +@Test +func writeJSONSortsKeysPrettyPrintsAndEncodesDatesAsISO8601() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-json-format") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("session.json") + let date = try iso8601Date(year: 2026, month: 8, day: 30, hour: 9, minute: 42, second: 5) + let value = JSONProbe(zebra: "last", apple: 7, capturedAt: date) + + try AtomicFile.writeJSON(value, to: url) + + let raw = try Data(contentsOf: url) + let text = try #require(String(data: raw, encoding: .utf8)) + + let apple = try #require(text.range(of: "\"apple\"")) + let capturedAt = try #require(text.range(of: "\"capturedAt\"")) + let zebra = try #require(text.range(of: "\"zebra\"")) + #expect(apple.lowerBound < capturedAt.lowerBound) + #expect(capturedAt.lowerBound < zebra.lowerBound) + #expect(text.contains("\n")) + #expect(text.contains(" \"apple\"")) + #expect(text.contains("2026-08-30T09:42:05Z")) + #expect(!text.contains("\(date.timeIntervalSinceReferenceDate)")) + #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) +} + +@Test +func writeJSONRoundTripsThroughISO8601Decoder() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-json-roundtrip") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("session.json") + let date = try iso8601Date(year: 2026, month: 8, day: 31, hour: 13, minute: 5, second: 9) + let original = JSONProbe(zebra: "keep", apple: 42, capturedAt: date) + + try AtomicFile.writeJSON(original, to: url) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(JSONProbe.self, from: Data(contentsOf: url)) + #expect(decoded == original) +} + +@Test +func fsyncDirectorySucceedsOnADirectoryAndThrowsOnMissingOrFilePaths() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-fsync-dir") + defer { try? FileManager.default.removeItem(at: directory) } + + try AtomicFile.fsyncDirectory(at: directory) + + let missing = directory.appendingPathComponent("does-not-exist", isDirectory: true) + let missingError = try #require(throws: ShotdeckError.self) { + try AtomicFile.fsyncDirectory(at: missing) + } + guard case .spoolWriteFailed = missingError else { + Issue.record("expected spoolWriteFailed for a missing path, got \(missingError)") + return + } + + let fileURL = directory.appendingPathComponent("not-a-directory.bin") + try AtomicFile.write(Data("file".utf8), to: fileURL) + let fileError = try #require(throws: ShotdeckError.self) { + try AtomicFile.fsyncDirectory(at: fileURL) + } + guard case .spoolWriteFailed = fileError else { + Issue.record("expected spoolWriteFailed for a file path, got \(fileError)") + return + } +} + +private struct JSONProbe: Codable, Equatable { + var zebra: String + var apple: Int + var capturedAt: Date +} + +private func makeTemporaryDirectory(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func iso8601Date( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int +) throws -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + return try #require( + calendar.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + )) + ) +} diff --git a/Tests/ShotdeckCoreTests/FolderSettingsTests.swift b/Tests/ShotdeckCoreTests/FolderSettingsTests.swift new file mode 100644 index 0000000..855b67e --- /dev/null +++ b/Tests/ShotdeckCoreTests/FolderSettingsTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +import ShotdeckCore + +@Test +func resolveWithoutStoredKeysReturnsDesktopAndDownloads() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + let expectedWatch = try #require( + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ) + + #expect(resolved.outbox.path == expectedOutbox.path) + #expect(resolved.watch.path == expectedWatch.path) +} + +@Test +func setOutboxToAnExistingDirectoryIsReturnedByResolve() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-outbox-set") + defer { try? FileManager.default.removeItem(at: outbox) } + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedWatch = try #require( + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ) + + #expect(resolved.outbox.path == outbox.path) + #expect(resolved.watch.path == expectedWatch.path) + #expect(FolderSettings.storedOutboxPath(defaults: suite.defaults) == outbox.path) +} + +@Test +func resolveFallsBackWhenTheStoredOutboxDirectoryIsGone() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-outbox-gone") + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + try FileManager.default.removeItem(at: outbox) + + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + #expect(resolved.outbox.path == expectedOutbox.path) +} + +@Test +func setWatchFolderMirrorsOutboxOverrideAndFallbackIndependently() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let watch = try makeTemporaryDirectory(prefix: "shotdeck-watch-set") + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + + FolderSettings.setWatchFolder(watch, defaults: suite.defaults) + let withOverride = FolderSettings.resolve(defaults: suite.defaults) + #expect(withOverride.watch.path == watch.path) + #expect(withOverride.outbox.path == expectedOutbox.path) + + try FileManager.default.removeItem(at: watch) + let afterDelete = FolderSettings.resolve(defaults: suite.defaults) + let expectedWatch = try #require( + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ) + #expect(afterDelete.watch.path == expectedWatch.path) + #expect(afterDelete.outbox.path == expectedOutbox.path) +} + +@Test +func resetOutboxClearsTheOverride() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-outbox-reset") + defer { try? FileManager.default.removeItem(at: outbox) } + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + FolderSettings.resetOutbox(defaults: suite.defaults) + + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + #expect(resolved.outbox.path == expectedOutbox.path) + #expect(FolderSettings.storedOutboxPath(defaults: suite.defaults) == nil) +} + +@Test +func resolvedAppSupportPathsUsesTheOutboxOverrideAndCreatesSpoolArchive() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let root = try makeTemporaryDirectory(prefix: "shotdeck-paths-root") + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-paths-outbox") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outbox) + } + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + let paths = try FolderSettings.resolvedAppSupportPaths(root: root, defaults: suite.defaults) + + #expect(paths.outbox.path == outbox.path) + #expect(directoryExists(paths.spool)) + #expect(directoryExists(paths.archive)) + #expect(paths.spool.deletingLastPathComponent().path == root.path) + #expect(paths.archive.deletingLastPathComponent().path == root.path) +} + +@Test +func userDefaultsSuitesDoNotLeakFolderOverrides() throws { + let suiteA = try makeDefaultsSuite() + let suiteB = try makeDefaultsSuite() + defer { + tearDown(suiteA) + tearDown(suiteB) + } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-suite-a-outbox") + defer { try? FileManager.default.removeItem(at: outbox) } + + FolderSettings.setOutbox(outbox, defaults: suiteA.defaults) + + let resolvedA = FolderSettings.resolve(defaults: suiteA.defaults) + let resolvedB = FolderSettings.resolve(defaults: suiteB.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + + #expect(resolvedA.outbox.path == outbox.path) + #expect(resolvedB.outbox.path == expectedOutbox.path) +} + +private struct DefaultsSuite { + let name: String + let defaults: UserDefaults +} + +private func makeDefaultsSuite() throws -> DefaultsSuite { + let name = "shotdeck-test-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return DefaultsSuite(name: name, defaults: defaults) +} + +private func tearDown(_ suite: DefaultsSuite) { + suite.defaults.removePersistentDomain(forName: suite.name) +} + +private func makeTemporaryDirectory(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func directoryExists(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue +} From 1b5816aa387ce8597570379f6d9867b7da739176 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:42:00 +0400 Subject: [PATCH 04/23] WP-2: PDF composer + PageLayout per SPEC-A1b (two-pass, atomic write, zero annotations) --- Sources/ShotdeckCore/PDF/PDFComposer.swift | 209 +++++++ Sources/ShotdeckCore/PDF/PageLayout.swift | 149 +++++ .../ShotdeckCoreTests/PDFComposerTests.swift | 579 ++++++++++++++++++ 3 files changed, 937 insertions(+) create mode 100644 Sources/ShotdeckCore/PDF/PDFComposer.swift create mode 100644 Sources/ShotdeckCore/PDF/PageLayout.swift create mode 100644 Tests/ShotdeckCoreTests/PDFComposerTests.swift diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift new file mode 100644 index 0000000..0e5e79c --- /dev/null +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -0,0 +1,209 @@ +import AppKit +import CoreGraphics +import CoreText +import Foundation +import ImageIO +import os + +private let pdfLog = Logger(subsystem: "ai.flowmaster.shotdeck", category: "PDF") + +public struct PDFComposer: Sendable { + public init() {} + + @discardableResult + public func compose( + session: CaptureSession, + imageURL: (Capture) -> URL, + title: String, + to outputURL: URL + ) throws -> Int { + if session.isEmpty { + throw ShotdeckError.pdfCompositionFailed(reason: "session has no captures") + } + + var loaded: [(Capture, CGImage)] = [] + loaded.reserveCapacity(session.captures.count) + for capture in session.captures { + let url = imageURL(capture) + if let image = Self.loadCGImage(from: url) { + loaded.append((capture, image)) + } else { + pdfLog.error( + "skipping capture \(capture.id.uuidString, privacy: .public) seq \(capture.sequence, privacy: .public): image unreadable at \(url.path, privacy: .public)" + ) + } + } + + guard !loaded.isEmpty else { + throw ShotdeckError.pdfCompositionFailed(reason: "no readable images in session") + } + + let pageCount = loaded.count + let tmpURL = outputURL + .deletingLastPathComponent() + .appendingPathComponent(".tmp-\(UUID().uuidString)-\(outputURL.lastPathComponent)") + + do { + try Self.writePDF( + loaded: loaded, + pageCount: pageCount, + title: title, + sessionID: session.id, + to: tmpURL + ) + + let handle = try FileHandle(forWritingTo: tmpURL) + try handle.synchronize() + try handle.close() + + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + try FileManager.default.moveItem(at: tmpURL, to: outputURL) + return pageCount + } catch { + try? FileManager.default.removeItem(at: tmpURL) + if let shotdeck = error as? ShotdeckError { + throw shotdeck + } + throw ShotdeckError.pdfCompositionFailed(reason: error.localizedDescription) + } + } + + public static func fileName(for session: CaptureSession) -> String { + "Shotdeck-\(DubaiTime.fileStamp(session.createdAt)).pdf" + } + + private static func writePDF( + loaded: [(Capture, CGImage)], + pageCount: Int, + title: String, + sessionID: UUID, + to tmpURL: URL + ) throws { + guard let consumer = CGDataConsumer(url: tmpURL as CFURL) else { + throw ShotdeckError.pdfCompositionFailed(reason: "cannot open output location") + } + + let auxiliaryInfo: [String: Any] = [ + kCGPDFContextCreator as String: "Shotdeck", + kCGPDFContextTitle as String: title, + kCGPDFContextSubject as String: sessionID.uuidString.lowercased(), + ] + + guard let context = CGContext( + consumer: consumer, + mediaBox: nil, + auxiliaryInfo as CFDictionary + ) else { + throw ShotdeckError.pdfCompositionFailed(reason: "cannot open output location") + } + + let lightGrey = CGColor(gray: 0.75, alpha: 1) + let black = CGColor(gray: 0, alpha: 1) + let timestampColor = CGColor(gray: 0.45, alpha: 1) + + for pageIndex in 1...pageCount { + let (capture, cgImage) = loaded[pageIndex - 1] + let layout = PageLayout(capture: capture, pageIndex: pageIndex, pageCount: pageCount) + + context.beginPDFPage(Self.pageInfo(mediaBox: layout.pageRect)) + + context.draw(cgImage, in: layout.imageRect) + + context.setStrokeColor(lightGrey) + context.setLineWidth(0.5) + context.move(to: CGPoint(x: layout.headerRect.minX, y: layout.headerRect.minY)) + context.addLine(to: CGPoint(x: layout.headerRect.maxX, y: layout.headerRect.minY)) + context.strokePath() + + drawText( + layout.pageNumberText, + font: layout.pageNumberFont, + color: black, + at: layout.pageNumberOrigin, + in: context + ) + drawText( + layout.timestampText, + font: layout.timestampFont, + color: timestampColor, + at: layout.timestampOrigin, + in: context + ) + drawText( + "PASS", + font: layout.tickLabelFont, + color: black, + at: layout.passLabelOrigin, + in: context + ) + drawText( + "FAIL", + font: layout.tickLabelFont, + color: black, + at: layout.failLabelOrigin, + in: context + ) + + strokeTickBox(layout.passTickBox, in: context, color: black) + strokeTickBox(layout.failTickBox, in: context, color: black) + + context.endPDFPage() + } + + context.closePDF() + } + + private static func pageInfo(mediaBox: CGRect) -> CFDictionary { + var box = mediaBox + let data = Data(bytes: &box, count: MemoryLayout.size) + return [kCGPDFContextMediaBox as String: data] as CFDictionary + } + + private static func drawText( + _ string: String, + font: NSFont, + color: CGColor, + at origin: CGPoint, + in context: CGContext + ) { + let attributes: [CFString: Any] = [ + kCTFontAttributeName: font, + kCTForegroundColorAttributeName: color, + ] + guard let attributed = CFAttributedStringCreate( + nil, + string as CFString, + attributes as CFDictionary + ) else { + return + } + let line = CTLineCreateWithAttributedString(attributed) + context.textMatrix = .identity + context.textPosition = origin + CTLineDraw(line, context) + } + + private static func strokeTickBox(_ box: CGRect, in context: CGContext, color: CGColor) { + let path = CGPath( + roundedRect: box, + cornerWidth: 2, + cornerHeight: 2, + transform: nil + ) + context.addPath(path) + context.setStrokeColor(color) + context.setLineWidth(1.0) + context.strokePath() + } + + private static func loadCGImage(from url: URL) -> CGImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetCount(source) > 0 + else { + return nil + } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } +} diff --git a/Sources/ShotdeckCore/PDF/PageLayout.swift b/Sources/ShotdeckCore/PDF/PageLayout.swift new file mode 100644 index 0000000..7cd7dfe --- /dev/null +++ b/Sources/ShotdeckCore/PDF/PageLayout.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import CoreText +import Foundation + +public struct PageLayout: Sendable, Equatable { + public let isLandscape: Bool + public let pageRect: CGRect + public let headerRect: CGRect + public let imageBoxRect: CGRect + public let imageRect: CGRect + + public let pageNumberText: String + public let pageNumberOrigin: CGPoint + public let timestampText: String + public let timestampOrigin: CGPoint + + public let passLabelOrigin: CGPoint + public let failLabelOrigin: CGPoint + public let passTickBox: CGRect + public let failTickBox: CGRect + + /// Constructed at access time so `PageLayout` stays Sendable under Swift 6 + /// (`NSFont` is not Sendable). Same `NSFont.systemFont` values used for measurement. + public var pageNumberFont: NSFont { NSFont.systemFont(ofSize: 9, weight: .semibold) } + public var timestampFont: NSFont { NSFont.systemFont(ofSize: 9, weight: .regular) } + public var tickLabelFont: NSFont { NSFont.systemFont(ofSize: 8, weight: .semibold) } + + private static let marginAll: CGFloat = 18 + private static let headerHeight: CGFloat = 26 + private static let headerImageGap: CGFloat = 10 + private static let tickBoxSize: CGFloat = 13 + private static let tickGroupGap: CGFloat = 8 + private static let portraitPage = CGSize(width: 595, height: 842) + private static let landscapePage = CGSize(width: 842, height: 595) + + /// `pageIndex` and `pageCount` are 1-based / total, over ACTUALLY-WRITTEN pages + /// (see SPEC decision D-D), not `capture.sequence` / `session.captures.count`. + public init(capture: Capture, pageIndex: Int, pageCount: Int) { + let pageNumberFont = NSFont.systemFont(ofSize: 9, weight: .semibold) + let timestampFont = NSFont.systemFont(ofSize: 9, weight: .regular) + let tickLabelFont = NSFont.systemFont(ofSize: 8, weight: .semibold) + + isLandscape = capture.isLandscape + pageRect = CGRect(origin: .zero, size: isLandscape ? Self.landscapePage : Self.portraitPage) + let W = pageRect.width + let H = pageRect.height + + let contentMinX = Self.marginAll + let contentMaxX = W - Self.marginAll + let contentMinY = Self.marginAll + let contentMaxY = H - Self.marginAll + let contentWidth = contentMaxX - contentMinX + + headerRect = CGRect( + x: contentMinX, + y: contentMaxY - Self.headerHeight, + width: contentWidth, + height: Self.headerHeight + ) + + imageBoxRect = CGRect( + x: contentMinX, + y: contentMinY, + width: contentWidth, + height: headerRect.minY - Self.headerImageGap - contentMinY + ) + + let pointSize = CGSize( + width: CGFloat(capture.pixelWidth) / capture.scale, + height: CGFloat(capture.pixelHeight) / capture.scale + ) + let fitScale = min( + imageBoxRect.width / pointSize.width, + imageBoxRect.height / pointSize.height, + 1.0 + ) + let drawnSize = CGSize( + width: pointSize.width * fitScale, + height: pointSize.height * fitScale + ) + imageRect = CGRect( + x: imageBoxRect.midX - drawnSize.width / 2, + y: imageBoxRect.midY - drawnSize.height / 2, + width: drawnSize.width, + height: drawnSize.height + ) + + let metricsFont = timestampFont as CTFont + let ascent = CTFontGetAscent(metricsFont) + let descent = CTFontGetDescent(metricsFont) + let baselineY = headerRect.minY + (headerRect.height - (ascent + descent)) / 2 + descent + + pageNumberText = "\(pageIndex) / \(pageCount)" + pageNumberOrigin = CGPoint(x: headerRect.minX, y: baselineY) + + timestampText = DubaiTime.stamp(capture.capturedAt) + let pageNumberWidth = Self.measuredWidth(pageNumberText, font: pageNumberFont) + timestampOrigin = CGPoint(x: headerRect.minX + pageNumberWidth + 10, y: baselineY) + + let groupRightX = headerRect.maxX + let failBoxMinX = groupRightX - Self.tickBoxSize + let tickBoxY = headerRect.midY - Self.tickBoxSize / 2 + failTickBox = CGRect( + x: failBoxMinX, + y: tickBoxY, + width: Self.tickBoxSize, + height: Self.tickBoxSize + ) + + let failLabelWidth = Self.measuredWidth("FAIL", font: tickLabelFont) + let failLabelMaxX = failTickBox.minX - Self.tickGroupGap + failLabelOrigin = CGPoint(x: failLabelMaxX - failLabelWidth, y: baselineY) + + let passBoxMaxX = failLabelOrigin.x - Self.tickGroupGap + passTickBox = CGRect( + x: passBoxMaxX - Self.tickBoxSize, + y: tickBoxY, + width: Self.tickBoxSize, + height: Self.tickBoxSize + ) + + let passLabelWidth = Self.measuredWidth("PASS", font: tickLabelFont) + let passLabelMaxX = passTickBox.minX - Self.tickGroupGap + passLabelOrigin = CGPoint(x: passLabelMaxX - passLabelWidth, y: baselineY) + } + + public static func == (lhs: PageLayout, rhs: PageLayout) -> Bool { + lhs.isLandscape == rhs.isLandscape + && lhs.pageRect == rhs.pageRect + && lhs.headerRect == rhs.headerRect + && lhs.imageBoxRect == rhs.imageBoxRect + && lhs.imageRect == rhs.imageRect + && lhs.pageNumberText == rhs.pageNumberText + && lhs.pageNumberOrigin == rhs.pageNumberOrigin + && lhs.timestampText == rhs.timestampText + && lhs.timestampOrigin == rhs.timestampOrigin + && lhs.passLabelOrigin == rhs.passLabelOrigin + && lhs.failLabelOrigin == rhs.failLabelOrigin + && lhs.passTickBox == rhs.passTickBox + && lhs.failTickBox == rhs.failTickBox + } + + private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { + let attributed = NSAttributedString(string: text, attributes: [.font: font]) + let line = CTLineCreateWithAttributedString(attributed) + return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + } +} diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift new file mode 100644 index 0000000..2842daf --- /dev/null +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -0,0 +1,579 @@ +import CoreGraphics +import Foundation +import ImageIO +import PDFKit +import ShotdeckCore +import Testing +import UniformTypeIdentifiers + +enum TestFixtureError: Error { case cannotCreateBitmap, cannotCreatePNG, cannotFinalizePNG } + +func writeSolidPNG( + pixelWidth: Int, + pixelHeight: Int, + red: UInt8, + green: UInt8, + blue: UInt8, + to url: URL +) throws { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw TestFixtureError.cannotCreateBitmap } + context.setFillColor(CGColor( + red: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: 1 + )) + context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) + else { throw TestFixtureError.cannotCreatePNG } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { throw TestFixtureError.cannotFinalizePNG } +} + +private let fixtureDate = Date(timeIntervalSince1970: 1_777_482_180) + +private func makeCapture( + sequence: Int, + pixelWidth: Int, + pixelHeight: Int, + scale: CGFloat = 1, + capturedAt: Date = fixtureDate, + id: UUID = UUID(), + fileName: String? = nil +) -> Capture { + Capture( + id: id, + sequence: sequence, + fileName: fileName ?? String(format: "%03d.png", sequence), + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + scale: scale, + capturedAt: capturedAt + ) +} + +private func makeSession( + captures: [Capture], + id: UUID = UUID(), + createdAt: Date = fixtureDate +) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: .open, + captures: captures, + pdfFileName: nil + ) +} + +private func makeScratchDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-pdf-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func writePNG( + for capture: Capture, + red: UInt8, + green: UInt8, + blue: UInt8, + in directory: URL +) throws -> URL { + let url = directory.appendingPathComponent(capture.fileName) + try writeSolidPNG( + pixelWidth: capture.pixelWidth, + pixelHeight: capture.pixelHeight, + red: red, + green: green, + blue: blue, + to: url + ) + return url +} + +private func imageURLMap(_ urls: [UUID: URL]) -> (Capture) -> URL { + { capture in + urls[capture.id] ?? URL(fileURLWithPath: "/nonexistent/\(capture.id.uuidString).png") + } +} + +private func openDocument(_ url: URL) throws -> PDFDocument { + let document = try #require(PDFDocument(url: url)) + return document +} + +private func pdftoppm(pdf: URL, prefix: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/pdftoppm") + process.arguments = ["-png", "-r", "110", pdf.path, prefix.path] + let err = Pipe() + process.standardError = err + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw ShotdeckError.pdfCompositionFailed(reason: "pdftoppm failed: \(message)") + } +} + +private struct RGBABitmap { + let width: Int + let height: Int + let bytesPerRow: Int + let data: Data + + func pixel(x: Int, y: Int) -> (UInt8, UInt8, UInt8)? { + guard x >= 0, y >= 0, x < width, y < height else { return nil } + let offset = y * bytesPerRow + x * 4 + guard offset + 2 < data.count else { return nil } + return (data[offset], data[offset + 1], data[offset + 2]) + } + + func containsNonWhite(x0: Int, y0: Int, x1: Int, y1: Int) -> Bool { + let xStart = max(0, x0) + let yStart = max(0, y0) + let xEnd = min(width, x1) + let yEnd = min(height, y1) + guard xStart < xEnd, yStart < yEnd else { return false } + for y in yStart.. RGBABitmap { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + let width = image.width + let height = image.height + let bytesPerRow = width * 4 + var data = Data(count: bytesPerRow * height) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw TestFixtureError.cannotCreateBitmap + } + guard let context = CGContext( + data: base, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw TestFixtureError.cannotCreateBitmap + } + context.interpolationQuality = .none + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return RGBABitmap(width: width, height: height, bytesPerRow: bytesPerRow, data: data) + } +} + +private func pixelWindow( + for rect: CGRect, + pageHeightPt: CGFloat, + scale k: CGFloat +) -> (x0: Int, y0: Int, x1: Int, y1: Int) { + let pixelX0 = Int(floor(rect.minX * k)) + let pixelX1 = Int(ceil(rect.maxX * k)) + let pixelY0 = Int(floor((pageHeightPt - rect.maxY) * k)) + let pixelY1 = Int(ceil((pageHeightPt - rect.minY) * k)) + return (pixelX0, pixelY0, pixelX1, pixelY1) +} + +@Test("Three-page basic compose") +func threePageBasicCompose() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let c1 = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let c2 = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) + let c3 = makeCapture(sequence: 3, pixelWidth: 1200, pixelHeight: 800) + let urls: [UUID: URL] = [ + c1.id: try writePNG(for: c1, red: 200, green: 20, blue: 20, in: directory), + c2.id: try writePNG(for: c2, red: 20, green: 200, blue: 20, in: directory), + c3.id: try writePNG(for: c3, red: 20, green: 20, blue: 200, in: directory), + ] + let session = makeSession(captures: [c1, c2, c3]) + let output = directory.appendingPathComponent("three.pdf") + let pageCount = try PDFComposer().compose( + session: session, + imageURL: imageURLMap(urls), + title: "Three page", + to: output + ) + #expect(pageCount == 3) + let document = try openDocument(output) + #expect(document.pageCount == 3) + let p0 = try #require(document.page(at: 0)) + let p1 = try #require(document.page(at: 1)) + let p2 = try #require(document.page(at: 2)) + #expect(p0.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) + #expect(p1.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) + #expect(p2.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) +} + +@Test("Wide/tall page geometry exact") +func wideTallPageGeometryExact() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let tall = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) + let wideURL = try writePNG(for: wide, red: 10, green: 10, blue: 10, in: directory) + let tallURL = try writePNG(for: tall, red: 10, green: 10, blue: 10, in: directory) + + let wideOut = directory.appendingPathComponent("wide.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [wide]), + imageURL: { _ in wideURL }, + title: "Wide", + to: wideOut + ) + let tallOut = directory.appendingPathComponent("tall.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [tall]), + imageURL: { _ in tallURL }, + title: "Tall", + to: tallOut + ) + + let widePage = try #require(openDocument(wideOut).page(at: 0)) + let tallPage = try #require(openDocument(tallOut).page(at: 0)) + #expect(widePage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) + #expect(tallPage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) +} + +@Test("Square resolves to portrait (Capture.swift:39-42 isLandscape strict >)") +func squareResolvesToPortrait() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let square = makeCapture(sequence: 1, pixelWidth: 1000, pixelHeight: 1000) + let png = try writePNG(for: square, red: 80, green: 80, blue: 80, in: directory) + let output = directory.appendingPathComponent("square.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [square]), + imageURL: { _ in png }, + title: "Square", + to: output + ) + let page = try #require(openDocument(output).page(at: 0)) + #expect(page.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) +} + +@Test("Retina capture uses point size, not pixel size") +func retinaCaptureUsesPointSize() throws { + let capture = makeCapture(sequence: 1, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) + let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) + let expectedScale = min(806.0 / 1440.0, 523.0 / 810.0, 1.0) + let expected = CGSize(width: 1440.0 * expectedScale, height: 810.0 * expectedScale) + #expect(abs(layout.imageRect.width - expected.width) < 0.01) + #expect(abs(layout.imageRect.height - expected.height) < 0.01) + #expect(layout.imageRect.width < 2000) + #expect(layout.isLandscape) +} + +@Test("Zero PDFAnnotation objects on every page") +func zeroPDFAnnotationObjects() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let c1 = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 600) + let c2 = makeCapture(sequence: 2, pixelWidth: 600, pixelHeight: 800) + let c3 = makeCapture(sequence: 3, pixelWidth: 1000, pixelHeight: 1000) + let urls: [UUID: URL] = [ + c1.id: try writePNG(for: c1, red: 30, green: 30, blue: 30, in: directory), + c2.id: try writePNG(for: c2, red: 40, green: 40, blue: 40, in: directory), + c3.id: try writePNG(for: c3, red: 50, green: 50, blue: 50, in: directory), + ] + let output = directory.appendingPathComponent("no-annots.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [c1, c2, c3]), + imageURL: imageURLMap(urls), + title: "No annotations", + to: output + ) + let document = try openDocument(output) + #expect(document.pageCount == 3) + for index in 0.. 150 && c1.1 < 80 && c1.2 < 80) + #expect(c2.2 > 150 && c2.0 < 80 && c2.1 < 80) +} + +@Test("An unreadable (corrupt) PNG is treated exactly like a missing one (D-A)") +func unreadablePNGIsSkippedLikeMissing() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let good = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 500) + let bad = makeCapture(sequence: 2, pixelWidth: 800, pixelHeight: 500) + let goodURL = try writePNG(for: good, red: 30, green: 140, blue: 30, in: directory) + let badURL = directory.appendingPathComponent("corrupt.png") + try Data([0x00, 0x01, 0x02, 0xFF, 0xD8, 0x00]).write(to: badURL) + let urls: [UUID: URL] = [good.id: goodURL, bad.id: badURL] + let output = directory.appendingPathComponent("skip-corrupt.pdf") + let pageCount = try PDFComposer().compose( + session: makeSession(captures: [good, bad]), + imageURL: imageURLMap(urls), + title: "Skip corrupt", + to: output + ) + #expect(pageCount == 1) + let document = try openDocument(output) + #expect(document.pageCount == 1) +} + +@Test("Document attributes round-trip exactly") +func documentAttributesRoundTrip() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let sessionID = try #require(UUID(uuidString: "deadbeef-dead-4eef-8ead-deadbeef0001")) + let capture = makeCapture(sequence: 1, pixelWidth: 640, pixelHeight: 480) + let png = try writePNG(for: capture, red: 40, green: 40, blue: 40, in: directory) + let output = directory.appendingPathComponent("attrs.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [capture], id: sessionID), + imageURL: { _ in png }, + title: "Ben review — 2026-08-30", + to: output + ) + let document = try openDocument(output) + let attributes = try #require(document.documentAttributes) + #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Shotdeck") + #expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == "Ben review — 2026-08-30") + #expect(attributes[PDFDocumentAttribute.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001") + #expect(document.pageCount == 1) + #expect(try #require(document.page(at: 0)).annotations.isEmpty) +} + +@Test("Rendered page is not blank (pdftoppm, pinned renderer + rounding)") +func renderedPageIsNotBlank() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let capture = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let png = try writePNG(for: capture, red: 128, green: 128, blue: 128, in: directory) + let output = directory.appendingPathComponent("not-blank.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [capture]), + imageURL: { _ in png }, + title: "Not blank", + to: output + ) + let prefix = directory.appendingPathComponent("not-blank") + try pdftoppm(pdf: output, prefix: prefix) + let rendered = directory.appendingPathComponent("not-blank-1.png") + let bitmap = try RGBABitmap.loadPNG(at: rendered) + let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) + let k: CGFloat = 110.0 / 72.0 + let imageWin = pixelWindow(for: layout.imageRect, pageHeightPt: layout.pageRect.height, scale: k) + let headerWin = pixelWindow(for: layout.headerRect, pageHeightPt: layout.pageRect.height, scale: k) + #expect(bitmap.containsNonWhite(x0: imageWin.x0, y0: imageWin.y0, x1: imageWin.x1, y1: imageWin.y1)) + #expect(bitmap.containsNonWhite(x0: headerWin.x0, y0: headerWin.y0, x1: headerWin.x1, y1: headerWin.y1)) +} + +@Test("Right-edge tick group geometry matches D-12 exactly, both orientations") +func rightEdgeTickGroupGeometry() throws { + let landscape = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let portrait = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) + let landscapeLayout = PageLayout(capture: landscape, pageIndex: 1, pageCount: 1) + let portraitLayout = PageLayout(capture: portrait, pageIndex: 1, pageCount: 1) + + #expect(landscapeLayout.failTickBox.maxX == 824) + #expect(landscapeLayout.headerRect.maxX == 824) + #expect(portraitLayout.failTickBox.maxX == 577) + #expect(portraitLayout.headerRect.maxX == 577) + #expect(landscapeLayout.failTickBox.width == 13) + #expect(landscapeLayout.failTickBox.height == 13) + #expect(portraitLayout.failTickBox.width == 13) + #expect(portraitLayout.failTickBox.height == 13) + #expect(landscapeLayout.passTickBox.maxX == landscapeLayout.failLabelOrigin.x - 8) + #expect(portraitLayout.passTickBox.maxX == portraitLayout.failLabelOrigin.x - 8) +} + +@Test("Writes visual sample for review") +func writesVisualSampleForReview() throws { + let verifyDir = URL(fileURLWithPath: "/Users/benjaminhippler/mmd-projects/multi-agent-runs/shotdeck-20260830/verify") + try FileManager.default.createDirectory(at: verifyDir, withIntermediateDirectories: true) + let scratch = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: scratch) } + + let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let tall = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) + let retina = makeCapture(sequence: 3, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) + let urls: [UUID: URL] = [ + wide.id: try writePNG(for: wide, red: 196, green: 42, blue: 42, in: scratch), + tall.id: try writePNG(for: tall, red: 32, green: 96, blue: 176, in: scratch), + retina.id: try writePNG(for: retina, red: 36, green: 148, blue: 84, in: scratch), + ] + let output = verifyDir.appendingPathComponent("wp2-sample.pdf") + let pageCount = try PDFComposer().compose( + session: makeSession(captures: [wide, tall, retina]), + imageURL: imageURLMap(urls), + title: "WP-2 visual sample", + to: output + ) + #expect(pageCount == 3) + let prefix = verifyDir.appendingPathComponent("wp2-sample") + try pdftoppm(pdf: output, prefix: prefix) +} From 4c4d3d6633941eea76f54c9cf67aba3c39cb9ad3 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:58:03 +0400 Subject: [PATCH 05/23] WP-1: durable SpoolStore with crash reconciliation and removed/ per SPEC-A1a --- Sources/ShotdeckCore/Spool/SpoolStore.swift | 418 +++++++++++++ Tests/ShotdeckCoreTests/SpoolStoreTests.swift | 590 ++++++++++++++++++ 2 files changed, 1008 insertions(+) create mode 100644 Sources/ShotdeckCore/Spool/SpoolStore.swift create mode 100644 Tests/ShotdeckCoreTests/SpoolStoreTests.swift diff --git a/Sources/ShotdeckCore/Spool/SpoolStore.swift b/Sources/ShotdeckCore/Spool/SpoolStore.swift new file mode 100644 index 0000000..798399e --- /dev/null +++ b/Sources/ShotdeckCore/Spool/SpoolStore.swift @@ -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 `/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 + } +} diff --git a/Tests/ShotdeckCoreTests/SpoolStoreTests.swift b/Tests/ShotdeckCoreTests/SpoolStoreTests.swift new file mode 100644 index 0000000..88f35bc --- /dev/null +++ b/Tests/ShotdeckCoreTests/SpoolStoreTests.swift @@ -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 +} From 3dfb703834791d34608ac421025cacd7fb0d6fa1 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:58:34 +0400 Subject: [PATCH 06/23] WP-5a: AnnotationInspector + ReturnLedger per SPEC-A1c with review corrections --- .../Returns/AnnotationInspector.swift | 110 +++++ .../ShotdeckCore/Returns/ReturnLedger.swift | 60 +++ .../AnnotationInspectorTests.swift | 412 ++++++++++++++++++ .../ShotdeckCoreTests/ReturnLedgerTests.swift | 200 +++++++++ 4 files changed, 782 insertions(+) create mode 100644 Sources/ShotdeckCore/Returns/AnnotationInspector.swift create mode 100644 Sources/ShotdeckCore/Returns/ReturnLedger.swift create mode 100644 Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift create mode 100644 Tests/ShotdeckCoreTests/ReturnLedgerTests.swift diff --git a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift new file mode 100644 index 0000000..7df2922 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift @@ -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 = [ + 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.. 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. + } +} diff --git a/Sources/ShotdeckCore/Returns/ReturnLedger.swift b/Sources/ShotdeckCore/Returns/ReturnLedger.swift new file mode 100644 index 0000000..a1c8fa9 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/ReturnLedger.swift @@ -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) + } +} diff --git a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift new file mode 100644 index 0000000..a7f84c0 --- /dev/null +++ b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift @@ -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.. (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) +} diff --git a/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift b/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift new file mode 100644 index 0000000..fc9b4dc --- /dev/null +++ b/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift @@ -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))) +} From 15021e9dda32a7e49326e780bae36761f7669956 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 15:08:05 +0400 Subject: [PATCH 07/23] WP-3b: ScreenCaptureKit capturer + region picker overlay per SPEC-A2a --- Sources/Shotdeck/RegionPickerController.swift | 181 ++++++++++++++++++ Sources/Shotdeck/RegionPickerWindow.swift | 110 +++++++++++ .../ShotdeckCore/Capture/ScreenCapturer.swift | 118 ++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 Sources/Shotdeck/RegionPickerController.swift create mode 100644 Sources/Shotdeck/RegionPickerWindow.swift create mode 100644 Sources/ShotdeckCore/Capture/ScreenCapturer.swift diff --git a/Sources/Shotdeck/RegionPickerController.swift b/Sources/Shotdeck/RegionPickerController.swift new file mode 100644 index 0000000..88d32bb --- /dev/null +++ b/Sources/Shotdeck/RegionPickerController.swift @@ -0,0 +1,181 @@ +import AppKit +import ShotdeckCore + +@MainActor +public final class RegionPickerController { + private var windows: [RegionPickerWindow] = [] + private var monitor: Any? + private var completion: (@MainActor (CaptureRegion?) -> Void)? + private var dragStart: NSPoint? + private var startScreen: NSScreen? + private var cursorPushed = false + + public init() {} + + /// Presents one overlay per attached screen. A pick already in progress is cancelled + /// (its completion called with nil) before the new one starts — never leaves a caller + /// waiting forever on a dropped re-entrant call. + public func pick(completion: @escaping @MainActor (CaptureRegion?) -> Void) { + if self.completion != nil { + finish(region: nil) + } + self.completion = completion + presentOverlays() + } + + private func presentOverlays() { + let screens = NSScreen.screens + guard !screens.isEmpty else { + finish(region: nil) + return + } + + NSCursor.crosshair.push() + cursorPushed = true + + windows = screens.map { RegionPickerWindow(screen: $0) } + for window in windows { + window.orderFrontRegardless() + } + // Nonactivating panel can become key without activating the app; needed so + // Escape reaches the local monitor immediately, before any mouse click. + let mouse = NSEvent.mouseLocation + let keyWindow = windows.first(where: { $0.coveringScreen.frame.contains(mouse) }) + ?? windows[0] + keyWindow.makeKey() + + monitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown] + ) { [weak self] event in + guard let self else { return event } + // NSEvent is not Sendable; lift the two Sendable fields the handler + // needs. Mouse geometry is read from NSEvent.mouseLocation on the + // main thread inside the isolated methods, per spec. + let type = event.type + let keyCode = event.keyCode + let consume = MainActor.assumeIsolated { + self.handle(type: type, keyCode: keyCode) + } + return consume ? nil : event + } + } + + /// Returns `true` when the event should be swallowed. + private func handle(type: NSEvent.EventType, keyCode: UInt16) -> Bool { + switch type { + case .keyDown: + if keyCode == 53 { // kVK_Escape + finish(region: nil) + return true + } + return false + case .leftMouseDown: + handleMouseDown() + return false + case .leftMouseDragged: + handleMouseDragged() + return false + case .leftMouseUp: + handleMouseUp() + return false + default: + return false + } + } + + private func handleMouseDown() { + let point = NSEvent.mouseLocation + dragStart = point + startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) }) + ?? NSScreen.screens.first + guard let startScreen else { return } + let local = Self.localRect( + from: CGRect(origin: point, size: .zero).intersection(startScreen.frame), + on: startScreen + ) + window(for: startScreen)?.updateSelection(localRect: local, sizeText: "0 x 0") + for window in windows where window.coveringScreen !== startScreen { + window.updateSelection(localRect: nil, sizeText: nil) + } + } + + private func handleMouseDragged() { + guard dragStart != nil, startScreen != nil else { return } + applyLiveSelection() + } + + private func handleMouseUp() { + guard let startScreen else { + dragStart = nil + return + } + guard let rect = currentClampedRect(), rect.width >= 8, rect.height >= 8 else { + // Mis-click: reset drag state, leave every window open and fully dimmed. + dragStart = nil + self.startScreen = nil + for window in windows { + window.updateSelection(localRect: nil, sizeText: nil) + } + return + } + let region = CaptureRegion.fromAppKit(rect: rect, on: startScreen) + finish(region: region) + } + + private func applyLiveSelection() { + guard let startScreen, let rect = currentClampedRect() else { return } + let local = Self.localRect(from: rect, on: startScreen) + let text = "\(Int(rect.width)) x \(Int(rect.height))" + window(for: startScreen)?.updateSelection(localRect: local, sizeText: text) + for window in windows where window.coveringScreen !== startScreen { + window.updateSelection(localRect: nil, sizeText: nil) + } + } + + /// Normalize the drag (so bottom-right → up-left is not misjudged) then clamp + /// to the screen the gesture started on — never selects across displays. + private func currentClampedRect() -> CGRect? { + guard let dragStart, let startScreen else { return nil } + let current = NSEvent.mouseLocation + let normalized = CGRect( + x: min(dragStart.x, current.x), + y: min(dragStart.y, current.y), + width: abs(current.x - dragStart.x), + height: abs(current.y - dragStart.y) + ) + return normalized.intersection(startScreen.frame) + } + + private func window(for screen: NSScreen) -> RegionPickerWindow? { + windows.first { $0.coveringScreen === screen } + } + + private static func localRect(from rect: CGRect, on screen: NSScreen) -> CGRect { + CGRect( + x: rect.minX - screen.frame.minX, + y: rect.minY - screen.frame.minY, + width: rect.width, + height: rect.height + ) + } + + private func finish(region: CaptureRegion?) { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if cursorPushed { + NSCursor.pop() + cursorPushed = false + } + for window in windows { + window.orderOut(nil) + } + windows.removeAll() + dragStart = nil + startScreen = nil + let done = completion + completion = nil + done?(region) + } +} diff --git a/Sources/Shotdeck/RegionPickerWindow.swift b/Sources/Shotdeck/RegionPickerWindow.swift new file mode 100644 index 0000000..a5c315d --- /dev/null +++ b/Sources/Shotdeck/RegionPickerWindow.swift @@ -0,0 +1,110 @@ +import AppKit + +/// Full-screen dim overlay for one attached display. Subclasses `NSPanel` with +/// `.nonactivatingPanel` so it can accept mouse and key events without activating +/// Shotdeck or stealing the frontmost app beyond those events. +@MainActor +final class RegionPickerWindow: NSPanel { + let coveringScreen: NSScreen + private let pickerView: RegionPickerView + + init(screen: NSScreen) { + self.coveringScreen = screen + self.pickerView = RegionPickerView(frame: CGRect(origin: .zero, size: screen.frame.size)) + super.init( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + setFrame(screen.frame, display: false) + level = .screenSaver + isOpaque = false + backgroundColor = .clear + ignoresMouseEvents = false + hasShadow = false + isMovable = false + hidesOnDeactivate = false + isFloatingPanel = true + becomesKeyOnlyIfNeeded = false + animationBehavior = .none + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + contentView = pickerView + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + /// `localRect` is in this window's coordinates (screen.frame origin subtracted). + /// Pass `nil` to restore the full dim with no punch and no size chip. + func updateSelection(localRect: CGRect?, sizeText: String?) { + pickerView.selectionLocalRect = localRect + pickerView.sizeChipText = sizeText + pickerView.needsDisplay = true + } +} + +/// Draws ~35% black over the screen with an even-odd punch for the live selection, +/// a thin light border, and the size-readout chip. +@MainActor +private final class RegionPickerView: NSView { + var selectionLocalRect: CGRect? + var sizeChipText: String? + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let dim = NSColor.black.withAlphaComponent(0.35) + let path = NSBezierPath(rect: bounds) + if let sel = selectionLocalRect, sel.width > 0, sel.height > 0 { + path.append(NSBezierPath(rect: sel)) + path.windingRule = .evenOdd + } + dim.setFill() + path.fill() + + guard let sel = selectionLocalRect, sel.width > 0, sel.height > 0 else { return } + + NSColor.white.withAlphaComponent(0.85).setStroke() + let border = NSBezierPath(rect: sel) + border.lineWidth = 1 + border.stroke() + + if let text = sizeChipText { + drawSizeChip(text, above: sel) + } + } + + private func drawSizeChip(_ text: String, above sel: CGRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: NSColor.white, + ] + let nsText = text as NSString + let textSize = nsText.size(withAttributes: attrs) + let padX: CGFloat = 8 + let padY: CGFloat = 4 + let chipSize = CGSize( + width: ceil(textSize.width) + padX * 2, + height: ceil(textSize.height) + padY * 2 + ) + + var origin = CGPoint(x: sel.minX, y: sel.maxY + 6) + if origin.y + chipSize.height > bounds.maxY { + origin.y = sel.minY - 6 - chipSize.height + } + origin.x = min(max(origin.x, bounds.minX + 4), bounds.maxX - chipSize.width - 4) + origin.y = min(max(origin.y, bounds.minY + 4), bounds.maxY - chipSize.height - 4) + + let chipRect = CGRect(origin: origin, size: chipSize) + NSColor.black.withAlphaComponent(0.75).setFill() + NSBezierPath(roundedRect: chipRect, xRadius: 4, yRadius: 4).fill() + nsText.draw( + at: CGPoint(x: chipRect.minX + padX, y: chipRect.minY + padY), + withAttributes: attrs + ) + } +} diff --git a/Sources/ShotdeckCore/Capture/ScreenCapturer.swift b/Sources/ShotdeckCore/Capture/ScreenCapturer.swift new file mode 100644 index 0000000..b6158fc --- /dev/null +++ b/Sources/ShotdeckCore/Capture/ScreenCapturer.swift @@ -0,0 +1,118 @@ +import ScreenCaptureKit +import CoreGraphics +import ImageIO +import UniformTypeIdentifiers +import AppKit // NSScreen only, to read backingScaleFactor as a fallback +import Foundation + +public struct CapturedImage: Sendable { + public let pngData: Data + public let pixelWidth: Int + public let pixelHeight: Int + public let scale: CGFloat +} + +public struct ScreenCapturer: Sendable { + public init() {} + + /// Does NOT prompt. Reports the current Screen Recording grant. + public static var isScreenRecordingGranted: Bool { CGPreflightScreenCaptureAccess() } + + /// Captures `region` at the display's true backing scale. Throws + /// `.screenRecordingNotGranted` when the grant is absent — never requests it. + public func capture(_ region: CaptureRegion) async throws -> CapturedImage { + guard Self.isScreenRecordingGranted else { + throw ShotdeckError.screenRecordingNotGranted + } + + let content: SCShareableContent + do { + content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + } catch { + throw ShotdeckError.captureFailed(underlying: error.localizedDescription) + } + + guard let display = content.displays.first(where: { $0.displayID == region.displayID }) else { + throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID) + } + + // Bounds/clamp: a resolution change often shrinks the display bounds by a few + // points without the region Ben picked being meaningfully wrong; 80% keeps a real + // recapture usable while a smaller overlap (e.g. a genuinely different display + // swapped in) still throws. + let rect: CGRect + if display.frame.contains(region.rect) { + rect = region.rect + } else { + let overlap = display.frame.intersection(region.rect) + let originalArea = region.rect.width * region.rect.height + let overlapArea = overlap.width * overlap.height + if originalArea > 0 && overlapArea / originalArea >= 0.8 { + rect = overlap + } else { + throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID) + } + } + + let localRect = CGRect( + x: rect.minX - display.frame.minX, + y: rect.minY - display.frame.minY, + width: rect.width, + height: rect.height + ) + + guard let mode = CGDisplayCopyDisplayMode(region.displayID) else { + throw ShotdeckError.captureFailed( + underlying: "no display mode for displayID \(region.displayID)" + ) + } + guard mode.width > 0 else { + throw ShotdeckError.captureFailed( + underlying: "display mode width is 0 for displayID \(region.displayID)" + ) + } + let scale = CGFloat(mode.pixelWidth) / CGFloat(mode.width) + + let filter = SCContentFilter(display: display, excludingWindows: []) + let configuration = SCStreamConfiguration() + configuration.sourceRect = localRect + configuration.width = Int((localRect.width * scale).rounded()) + configuration.height = Int((localRect.height * scale).rounded()) + configuration.scalesToFit = false + configuration.showsCursor = false + + let cgImage: CGImage + do { + cgImage = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: configuration + ) + } catch { + throw ShotdeckError.captureFailed(underlying: error.localizedDescription) + } + + let data = NSMutableData() + guard let dest = CGImageDestinationCreateWithData( + data, + UTType.png.identifier as CFString, + 1, + nil + ) else { + throw ShotdeckError.captureFailed(underlying: "could not create PNG image destination") + } + CGImageDestinationAddImage(dest, cgImage, nil) + guard CGImageDestinationFinalize(dest) else { + throw ShotdeckError.captureFailed(underlying: "PNG encoding failed to finalize") + } + + return CapturedImage( + pngData: data as Data, + pixelWidth: cgImage.width, + pixelHeight: cgImage.height, + scale: scale + ) + } +} From 49b1a9ea830fd88b947012cef5b7d50eb1c8557a Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 15:23:05 +0400 Subject: [PATCH 08/23] 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() + } +} From fdac2f2f4787eb8a07163eb96646354139993674 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 15:59:37 +0400 Subject: [PATCH 09/23] WP-4a: menu-bar shell, AppModel, session strip per SPEC-A2b + integration contracts --- Sources/Shotdeck/AppModel.swift | 265 ++++++++++++++++++++++++++++ Sources/Shotdeck/MenuBarView.swift | 180 +++++++++++++++++++ Sources/Shotdeck/SessionStrip.swift | 78 ++++++++ Sources/Shotdeck/main.swift | 77 +++++++- 4 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 Sources/Shotdeck/AppModel.swift create mode 100644 Sources/Shotdeck/MenuBarView.swift create mode 100644 Sources/Shotdeck/SessionStrip.swift diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift new file mode 100644 index 0000000..6c95b5a --- /dev/null +++ b/Sources/Shotdeck/AppModel.swift @@ -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) 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) 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 /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 + } +} diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift new file mode 100644 index 0000000..5b1f1be --- /dev/null +++ b/Sources/Shotdeck/MenuBarView.swift @@ -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 } + } +} diff --git a/Sources/Shotdeck/SessionStrip.swift b/Sources/Shotdeck/SessionStrip.swift new file mode 100644 index 0000000..4786340 --- /dev/null +++ b/Sources/Shotdeck/SessionStrip.swift @@ -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) + } + } +} diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 0cba481..60de9bc 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -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) + ) + } +} From 66657ac53291806be00a420588bcb0dd1b7de97b Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 16:07:41 +0400 Subject: [PATCH 10/23] WP-4c: returns list + settings per SPEC-A2b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ReturnsList (newest-first, max 8, click-to-Finder, hidden when empty) and SettingsView (hotkey row, folder Choose… via FolderSettings + AppModel seam mutators, async updateWatchFolder(url) only). --- Sources/Shotdeck/ReturnsList.swift | 53 ++++++++++++++++ Sources/Shotdeck/SettingsView.swift | 98 +++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 Sources/Shotdeck/ReturnsList.swift create mode 100644 Sources/Shotdeck/SettingsView.swift diff --git a/Sources/Shotdeck/ReturnsList.swift b/Sources/Shotdeck/ReturnsList.swift new file mode 100644 index 0000000..7df705a --- /dev/null +++ b/Sources/Shotdeck/ReturnsList.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +func newestReturnsForDisplay(_ returns: [ReturnedDocument], limit: Int = 8) -> [ReturnedDocument] { + Array(returns.sorted { $0.detectedAt > $1.detectedAt }.prefix(limit)) +} + +func returnMarkLabel(_ document: ReturnedDocument) -> String { + guard document.isCommented else { return "not marked" } + let count = document.annotatedPages.count + return "\(count) page\(count == 1 ? "" : "s") marked" +} + +struct ReturnsList: View { + let returns: [ReturnedDocument] + + var body: some View { + let visible = newestReturnsForDisplay(returns) + if visible.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Came back from your device") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(visible) { document in + Button { + NSWorkspace.shared.activateFileViewerSelecting([document.fileURL]) + } label: { + HStack(spacing: 8) { + Text(document.fileURL.lastPathComponent) + .lineLimit(1) + Spacer(minLength: 8) + Text(returnMarkLabel(document)) + .font(.caption) + .foregroundStyle(document.isCommented ? .primary : .secondary) + } + } + .buttonStyle(.plain) + .help(DubaiTime.stamp(document.detectedAt)) + } + } + } + } +} + +extension AppModel: ReturnsSectionProviding { + public func returnsSection() -> AnyView { + guard !allReturns.isEmpty else { return AnyView(EmptyView()) } + return AnyView(ReturnsList(returns: allReturns)) + } +} diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift new file mode 100644 index 0000000..624044e --- /dev/null +++ b/Sources/Shotdeck/SettingsView.swift @@ -0,0 +1,98 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +struct SettingsView: View { + @Environment(AppModel.self) private var model + + var body: some View { + Form { + Section("Hotkey") { + LabeledContent("Capture", value: "⌥⇧2 — fixed in this version") + } + Section("Folders") { + LabeledContent("Watch folder") { + HStack(spacing: 8) { + Text(model.watchFolderURL.path) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + Button("Choose…") { model.chooseWatchFolder() } + } + } + LabeledContent("Output folder") { + HStack(spacing: 8) { + Text(model.outboxURL.path) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + Button("Choose…") { model.chooseOutboxFolder() } + } + } + } + Section { + Button("Reveal spool folder") { model.openSpoolFolder() } + } + } + .padding() + .frame(width: 360) + .controlSize(.small) + } +} + +extension AppModel: SettingsWindowPresenting { + private static var settingsWindowController: NSWindowController? + + public func presentSettingsWindow() { + if let existing = Self.settingsWindowController { + existing.window?.makeKeyAndOrderFront(nil) + NSApp.activate() + return + } + let hosting = NSHostingController(rootView: SettingsView().environment(self)) + let window = NSWindow(contentViewController: hosting) + window.title = "Shotdeck Settings" + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + window.center() + let controller = NSWindowController(window: window) + Self.settingsWindowController = controller + controller.showWindow(nil) + NSApp.activate() + } + + func chooseOutboxFolder() { + guard let url = chooseDirectory(startingAt: outboxURL) else { return } + FolderSettings.setOutbox(url) + setFolderURLs(outbox: url, watch: watchFolderURL) + setStatus("Output folder set to \(url.lastPathComponent).") + } + + func chooseWatchFolder() { + guard let url = chooseDirectory(startingAt: watchFolderURL) else { return } + FolderSettings.setWatchFolder(url) + setFolderURLs(outbox: outboxURL, watch: url) + Task { + do { + try await watcher.updateWatchFolder(url) + setStatus("Watch folder set to \(url.lastPathComponent).") + } catch { + setStatus( + (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." + ) + } + } + } + + private func chooseDirectory(startingAt directory: URL) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.directoryURL = directory + guard panel.runModal() == .OK else { return nil } + return panel.url + } +} From 9201e74bfc80408a222910e4aff0223cb190e80f Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 16:12:58 +0400 Subject: [PATCH 11/23] WP-4b: Send pipeline + AirDrop sheet per SPEC-A2b --- Sources/Shotdeck/SendController.swift | 71 +++++++++++++++++++++++ Sources/Shotdeck/Sharing.swift | 83 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 Sources/Shotdeck/SendController.swift create mode 100644 Sources/Shotdeck/Sharing.swift diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift new file mode 100644 index 0000000..5934f05 --- /dev/null +++ b/Sources/Shotdeck/SendController.swift @@ -0,0 +1,71 @@ +import AppKit +import Darwin +import Foundation +import ShotdeckCore + +extension AppModel: SendCapable { + public func send(anchor: NSView?) async { + guard !session.isEmpty, !isSending else { return } + setSending(true) + defer { setSending(false) } + + let workingSession = session + let composer = self.composer + // Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect. + let outboxDir = outboxURL + let sourceDir = paths.sessionDirectory(workingSession.id) + let fileName = PDFComposer.fileName(for: workingSession) + let finalURL = outboxDir.appendingPathComponent(fileName) + // Same directory as the final target so the rename below is same-volume (atomic). + let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf") + let title = "Shotdeck – \(DubaiTime.stamp(workingSession.createdAt))" + + do { + // D-13: build off the main actor. Only Sendable values cross into the + // detached task — never `anchor` (NSView is not Sendable). + try await Task.detached(priority: .userInitiated) { + _ = try composer.compose( + session: workingSession, + imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, + title: title, + to: tempURL + ) + // POSIX rename onto `finalURL` replaces any same-name file in one + // directory operation; there is never a window where the PDF is gone. + if Darwin.rename(tempURL.path, finalURL.path) != 0 { + throw ShotdeckError.pdfCompositionFailed( + reason: "could not publish the PDF: \(String(cString: strerror(errno)))" + ) + } + try AtomicFile.fsyncDirectory(at: outboxDir) + }.value + + // File exists on disk now — archive only after that (D-13). A later AirDrop + // failure never deletes this file. + guard FileManager.default.fileExists(atPath: finalURL.path) else { + throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") + } + _ = try await spool.archiveCurrent(pdfFileName: fileName) + replaceSession(try await spool.currentSession()) + + let pageWord = workingSession.captures.count == 1 ? "page" : "pages" + setStatus("Sent — \(workingSession.captures.count) \(pageWord).") + + guard let anchor else { + setStatus("PDF saved to \(outboxDisplayName). Open the panel to AirDrop it.") + return + } + do { + try Sharing.airDrop(fileURL: finalURL, from: anchor) + } catch { + setStatus( + "AirDrop is not available right now — the PDF is on your \(outboxDisplayName)." + ) + } + } catch { + // Never unlink the published PDF, and never unlink `tempURL` either: + // a rename failure would leave the complete document at the temp name. + setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + } + } +} diff --git a/Sources/Shotdeck/Sharing.swift b/Sources/Shotdeck/Sharing.swift new file mode 100644 index 0000000..5b9741a --- /dev/null +++ b/Sources/Shotdeck/Sharing.swift @@ -0,0 +1,83 @@ +import AppKit +import ShotdeckCore + +@MainActor +enum Sharing { + /// Presents the AirDrop picker for `fileURL`, anchored to `view`. + /// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created, + /// `canPerform` is false, or `view` is not in a visible window (a detached view + /// never produces an on-screen sheet). + static func airDrop(fileURL: URL, from view: NSView) throws { + guard let service = NSSharingService(named: .sendViaAirDrop), + service.canPerform(withItems: [fileURL]) else { + throw ShotdeckError.airDropUnavailable + } + // Presenting from a detached NSView (no window) yields a sheet that never appears. + guard let window = view.window, window.isVisible else { + throw ShotdeckError.airDropUnavailable + } + + NSApp.activate() + window.makeKeyAndOrderFront(nil) + + service.subject = fileURL.lastPathComponent + let session = AirDropSession(service: service, window: window, view: view) + AirDropSession.keepAlive(session) + service.delegate = session + service.perform(withItems: [fileURL]) + } +} + +/// Retains the sharing service for the life of the picker and supplies the real +/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak. +@MainActor +private final class AirDropSession: NSObject, NSSharingServiceDelegate { + static var live: [AirDropSession] = [] + + let service: NSSharingService + let window: NSWindow + let view: NSView + + init(service: NSSharingService, window: NSWindow, view: NSView) { + self.service = service + self.window = window + self.view = view + } + + static func keepAlive(_ session: AirDropSession) { + live.append(session) + } + + private func drop() { + Self.live.removeAll { $0 === self } + } + + func sharingService( + _ sharingService: NSSharingService, + sourceWindowForShareItems items: [Any], + sharingContentScope: UnsafeMutablePointer + ) -> NSWindow? { + sharingContentScope.pointee = .item + return window + } + + func sharingService( + _ sharingService: NSSharingService, + sourceFrameOnScreenForShareItem item: Any + ) -> NSRect { + let inWindow = view.convert(view.bounds, to: nil) + return window.convertToScreen(inWindow) + } + + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + drop() + } + + func sharingService( + _ sharingService: NSSharingService, + didFailToShareItems items: [Any], + error: any Error + ) { + drop() + } +} From c54471ee2eaedd74a8a5305ec3ffaf408bb2c302 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 12:55:22 +0400 Subject: [PATCH 12/23] =?UTF-8?q?installer:=20make-dmg.sh=20=E2=80=94=20si?= =?UTF-8?q?gned=20app=20DMG=20with=20/Applications=20symlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/make-dmg.sh | 95 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100755 scripts/make-dmg.sh diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh new file mode 100755 index 0000000..301e1cb --- /dev/null +++ b/scripts/make-dmg.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +APP_BUNDLE="${ROOT}/.build/Shotdeck.app" +STAGING="${ROOT}/.build/dmg-staging" +DMG="${ROOT}/.build/Shotdeck.dmg" +MOUNT_POINT="${ROOT}/.build/dmg-mnt" + +SKIP_SIGN=0 +for arg in "$@"; do + case "${arg}" in + --skip-sign) + SKIP_SIGN=1 + ;; + *) + echo "Unknown argument: ${arg}" >&2 + echo "Usage: $0 [--skip-sign]" >&2 + exit 1 + ;; + esac +done + +echo "==> Building Shotdeck.app" +if [[ "${SKIP_SIGN}" -eq 1 ]]; then + ./scripts/build-app.sh --skip-sign +else + ./scripts/build-app.sh +fi + +if [[ ! -d "${APP_BUNDLE}" ]]; then + echo "App bundle not found at ${APP_BUNDLE}" >&2 + exit 1 +fi + +echo "==> Staging DMG contents" +rm -rf "${STAGING}" +mkdir -p "${STAGING}" +ditto "${APP_BUNDLE}" "${STAGING}/Shotdeck.app" +ln -s /Applications "${STAGING}/Applications" + +echo "==> Creating ${DMG}" +mkdir -p "$(dirname "${DMG}")" +hdiutil create -volname "Shotdeck" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}" + +MOUNTED=0 +detach_dmg() { + if [[ "${MOUNTED}" -eq 1 ]]; then + hdiutil detach "${MOUNT_POINT}" || hdiutil detach "${MOUNT_POINT}" -force || true + MOUNTED=0 + fi +} +trap detach_dmg EXIT + +if [[ -d "${MOUNT_POINT}" ]] && /sbin/mount | grep -F -q "${MOUNT_POINT}"; then + hdiutil detach "${MOUNT_POINT}" || hdiutil detach "${MOUNT_POINT}" -force +fi +rm -rf "${MOUNT_POINT}" +mkdir -p "${MOUNT_POINT}" + +echo "==> Verifying ${DMG}" +hdiutil attach "${DMG}" -nobrowse -readonly -mountpoint "${MOUNT_POINT}" +MOUNTED=1 + +echo "==> Mount contents" +ls -la "${MOUNT_POINT}" + +if [[ ! -d "${MOUNT_POINT}/Shotdeck.app" ]]; then + echo "Verification failed: Shotdeck.app missing from mounted DMG" >&2 + exit 1 +fi +if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then + echo "Verification failed: Applications symlink missing from mounted DMG" >&2 + exit 1 +fi +if [[ "$(readlink "${MOUNT_POINT}/Applications")" != "/Applications" ]]; then + echo "Verification failed: Applications does not point at /Applications" >&2 + exit 1 +fi + +echo "==> codesign --verify --deep" +codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Shotdeck.app" + +echo "==> Detaching ${MOUNT_POINT}" +hdiutil detach "${MOUNT_POINT}" +MOUNTED=0 +trap - EXIT + +SHA256="$(shasum -a 256 "${DMG}" | awk '{print $1}')" + +echo +echo "DMG path: ${DMG}" +echo "SHA256: ${SHA256}" From 3b269102a0e5a8d30ddb2612633aaaa212aab11e Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 13:01:45 +0400 Subject: [PATCH 13/23] test harness: offscreen panel snapshots via SHOTDECK_SNAPSHOT_DIR --- Sources/Shotdeck/PanelSnapshot.swift | 295 +++++++++++++++++++++++++++ Sources/Shotdeck/main.swift | 3 + 2 files changed, 298 insertions(+) create mode 100644 Sources/Shotdeck/PanelSnapshot.swift diff --git a/Sources/Shotdeck/PanelSnapshot.swift b/Sources/Shotdeck/PanelSnapshot.swift new file mode 100644 index 0000000..e6580e3 --- /dev/null +++ b/Sources/Shotdeck/PanelSnapshot.swift @@ -0,0 +1,295 @@ +import AppKit +import CoreGraphics +import Darwin +import Foundation +import ImageIO +import PDFKit +import SwiftUI +import ShotdeckCore + +/// Headless offscreen renderer for `MenuBarView` / `SettingsView`. +/// Driven by `SHOTDECK_SNAPSHOT_DIR`; never touches the real Application Support spool. +enum PanelSnapshot { + private static let panelWidth: CGFloat = 340 + + /// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the + /// env var is unset; otherwise writes the six panel PNGs, prints each path, and `exit`s. + @MainActor + static func runIfRequested() { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"], + !raw.isEmpty + else { return } + + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + + Task { @MainActor in + do { + try await captureAll(to: URL(fileURLWithPath: raw, isDirectory: true)) + exit(0) + } catch { + fputs("PanelSnapshot failed: \(error)\n", stderr) + exit(1) + } + } + + app.run() + exit(0) + } + + @MainActor + private static func captureAll(to directory: URL) async throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let (model, root) = try makeIsolatedModel() + defer { try? FileManager.default.removeItem(at: root) } + + let region = sampleRegion() + + // 01 — Screen Recording missing (no public mutator; snapshot-only seam). + model.snapshotSetScreenRecordingGranted(false) + model.replaceRegion(nil) + try renderMenuBar(model: model, to: directory, name: "01-no-permission") + + // 02 — granted, no region picked. + model.snapshotSetScreenRecordingGranted(true) + model.replaceRegion(nil) + try renderMenuBar(model: model, to: directory, name: "02-no-region") + + // 03 — region set, empty session. + model.replaceRegion(region) + try renderMenuBar(model: model, to: directory, name: "03-empty-session") + + // 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode. + let swatches: [(CGFloat, CGFloat, CGFloat)] = [ + (0.85, 0.22, 0.18), + (0.18, 0.62, 0.32), + (0.16, 0.38, 0.82), + ] + for (red, green, blue) in swatches { + let png = try makePNGData(width: 192, height: 108, red: red, green: green, blue: blue) + _ = try await model.spool.append( + pngData: png, + pixelWidth: 192, + pixelHeight: 108, + scale: 2.0, + capturedAt: Date() + ) + } + model.replaceSession(try await model.spool.currentSession()) + try renderMenuBar(model: model, to: directory, name: "04-captures-present") + + // 05 — two inspected PDFs in the temp ledger, one marked / one not. + try await seedReturns(model: model) + try renderMenuBar(model: model, to: directory, name: "05-returns-present") + + // 06 — SettingsView against the same isolated model. + try render( + SettingsView().environment(model), + to: directory.appendingPathComponent("panel-06-settings.png") + ) + } + + @MainActor + private static func renderMenuBar(model: AppModel, to directory: URL, name: String) throws { + try render( + MenuBarView().environment(model), + to: directory.appendingPathComponent("panel-\(name).png") + ) + } + + @MainActor + private static func render(_ view: some View, to url: URL) throws { + let wrapped = view + .frame(width: panelWidth, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .background(Color(nsColor: .windowBackgroundColor)) + + let hosting = NSHostingView(rootView: wrapped) + hosting.wantsLayer = true + hosting.appearance = NSAppearance(named: .aqua) + + let window = NSWindow( + contentRect: NSRect(x: -10_000, y: -10_000, width: panelWidth, height: 64), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.backgroundColor = .windowBackgroundColor + window.isOpaque = true + window.alphaValue = 0 + window.contentView = hosting + window.orderBack(nil) + + hosting.layoutSubtreeIfNeeded() + var size = hosting.fittingSize + if size.height < 1 { + size.height = hosting.intrinsicContentSize.height + } + if size.height < 1 { size.height = 240 } + size.width = panelWidth + size.height = ceil(size.height) + + hosting.setFrameSize(size) + window.setContentSize(size) + hosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + + let bounds = hosting.bounds + guard let rep = hosting.bitmapImageRepForCachingDisplay(in: bounds) else { + throw SnapshotError.renderFailed(url.lastPathComponent) + } + hosting.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + throw SnapshotError.encodeFailed(url.lastPathComponent) + } + try png.write(to: url) + print(url.path) + fflush(stdout) + + window.contentView = nil + window.close() + } + + @MainActor + private static func makeIsolatedModel() throws -> (model: AppModel, root: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-panel-snapshot-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder) + return (model, root) + } + + @MainActor + private static func seedReturns(model: AppModel) async throws { + let watch = model.paths.watchFolder + let unmarkedURL = watch.appendingPathComponent("Shotdeck-20260901-120000.pdf") + let markedURL = watch.appendingPathComponent("Shotdeck-20260901-120100.pdf") + try writeShotdeckPDF(to: unmarkedURL, marked: false) + try writeShotdeckPDF(to: markedURL, marked: true) + + let unmarked = try AnnotationInspector.inspect(fileURL: unmarkedURL) + let marked = try AnnotationInspector.inspect(fileURL: markedURL) + try await model.ledger.record(unmarked) + try await model.ledger.record(marked) + model.setReturns( + all: try await model.ledger.all(), + commented: try await model.ledger.commented() + ) + } + + private static func sampleRegion() -> CaptureRegion { + CaptureRegion( + displayID: CGMainDisplayID(), + rect: CGRect(x: 120, y: 80, width: 800, height: 600), + capturedScale: 2.0 + ) + } + + private static 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 SnapshotError.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 SnapshotError.pngGenerationFailed + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + buffer, + "public.png" as CFString, + 1, + nil + ) else { + throw SnapshotError.pngGenerationFailed + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw SnapshotError.pngGenerationFailed + } + return buffer as Data + } + + private static func writeShotdeckPDF(to url: URL, marked: Bool) throws { + let document = PDFDocument() + let page = PDFPage() + page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox) + document.insert(page, at: 0) + document.documentAttributes = [ + PDFDocumentAttribute.creatorAttribute: "Shotdeck", + PDFDocumentAttribute.subjectAttribute: UUID().uuidString, + ] + if marked { + let annotation = PDFAnnotation( + bounds: CGRect(x: 72, y: 400, width: 220, height: 36), + forType: .highlight, + withProperties: nil + ) + page.addAnnotation(annotation) + } + guard document.write(to: url) else { + throw SnapshotError.pdfWriteFailed(url.lastPathComponent) + } + } +} + +extension AppModel { + /// `screenRecordingGranted` is `public private(set)` with no seam mutator. + /// Snapshot-only: the KeyPath setter exists at runtime; compile-time access is file-private. + func snapshotSetScreenRecordingGranted(_ granted: Bool) { + // private(set) types this as KeyPath; the setter still exists on the @Observable storage. + let writable: ReferenceWritableKeyPath = unsafeBitCast( + \AppModel.screenRecordingGranted, to: ReferenceWritableKeyPath.self + ) + self[keyPath: writable] = granted + } +} + +private enum SnapshotError: Error, CustomStringConvertible { + case renderFailed(String) + case encodeFailed(String) + case pngGenerationFailed + case pdfWriteFailed(String) + + var description: String { + switch self { + case .renderFailed(let name): return "bitmapImageRepForCachingDisplay failed for \(name)" + case .encodeFailed(let name): return "PNG encode failed for \(name)" + case .pngGenerationFailed: return "CoreGraphics PNG generation failed" + case .pdfWriteFailed(let name): return "could not write \(name)" + } + } +} diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 60de9bc..21ce466 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -4,6 +4,9 @@ import ShotdeckCore // SwiftPM treats a file named main.swift as top-level code, which forbids `@main`. // App.main() is the equivalent entry point. +if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { + MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } +} ShotdeckApp.main() struct ShotdeckApp: App { From e1de0ad519c45b8a500a1797f1ab6355a4c1fd54 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 13:11:25 +0400 Subject: [PATCH 14/23] =?UTF-8?q?fix:=20settings=20form=20alignment=20?= =?UTF-8?q?=E2=80=94=20grouped=20form,=20aligned=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Shotdeck/SettingsView.swift | 88 +++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index 624044e..ca3ba35 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -5,39 +5,79 @@ import ShotdeckCore struct SettingsView: View { @Environment(AppModel.self) private var model + private let labelWidth: CGFloat = 104 + var body: some View { - Form { - Section("Hotkey") { - LabeledContent("Capture", value: "⌥⇧2 — fixed in this version") + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + GridRow { + Text("Hotkey") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .gridCellColumns(2) } - Section("Folders") { - LabeledContent("Watch folder") { - HStack(spacing: 8) { - Text(model.watchFolderURL.path) - .lineLimit(1) - .truncationMode(.middle) - .foregroundStyle(.secondary) - Button("Choose…") { model.chooseWatchFolder() } - } - } - LabeledContent("Output folder") { - HStack(spacing: 8) { - Text(model.outboxURL.path) - .lineLimit(1) - .truncationMode(.middle) - .foregroundStyle(.secondary) - Button("Choose…") { model.chooseOutboxFolder() } - } + + GridRow(alignment: .firstTextBaseline) { + fieldLabel("Capture") + Text("⌥⇧2 — fixed in this version") + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 22, alignment: .leading) + } + + GridRow { + Text("Folders") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .gridCellColumns(2) + .padding(.top, 6) + } + + GridRow(alignment: .center) { + fieldLabel("Watch folder") + folderValue(path: model.watchFolderURL.path) { + model.chooseWatchFolder() } } - Section { + + GridRow(alignment: .center) { + fieldLabel("Output folder") + folderValue(path: model.outboxURL.path) { + model.chooseOutboxFolder() + } + } + + GridRow { Button("Reveal spool folder") { model.openSpoolFolder() } + .gridCellColumns(2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) } } - .padding() - .frame(width: 360) + .padding(16) + .frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading) .controlSize(.small) } + + private func fieldLabel(_ title: String) -> some View { + Text(title) + .lineLimit(1) + .frame(width: labelWidth, alignment: .trailing) + .gridColumnAlignment(.trailing) + .frame(minHeight: 22, alignment: .trailing) + } + + private func folderValue(path: String, choose: @escaping () -> Void) -> some View { + HStack(spacing: 8) { + Text(path) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Choose…") { choose() } + } + .frame(minHeight: 22) + } } extension AppModel: SettingsWindowPresenting { From f2088bbed84e3f5953168c3f67b89be959e0e459 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 21:46:54 +0400 Subject: [PATCH 15/23] fix: picker first-mouse acceptance + event-based drag coords; in-process picker selftest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: RegionPickerView lacked acceptsFirstMouse — as an LSUIElement accessory app Shotdeck is never active when the hotkey fires, so the user's first click on the overlay was refused and the drag never started. Also plumbs the monitored event's location through the controller (hardware-cursor reads made the chain untestable). Adds PickerSelfTest (SHOTDECK_PICKER_SELFTEST): posts synthetic mouse events through the app's own queue, asserts the exact CaptureRegion, saves a mid-drag overlay bitmap. Coordinator ran it: PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0); overlay bitmap shows dim+punch+chip. Co-Authored-By: Claude Fable 5 --- Sources/Shotdeck/PickerSelfTest.swift | 172 ++++++++++++++++++ Sources/Shotdeck/RegionPickerController.swift | 60 ++++-- Sources/Shotdeck/RegionPickerWindow.swift | 3 + Sources/Shotdeck/main.swift | 3 + 4 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 Sources/Shotdeck/PickerSelfTest.swift diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift new file mode 100644 index 0000000..f389d43 --- /dev/null +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -0,0 +1,172 @@ +import AppKit +import Darwin +import Foundation +import ShotdeckCore + +/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`. +/// Posts synthetic mouse events through `NSApp.postEvent` only — never CGEventPost/taps. +@MainActor +enum PickerSelfTest { + private static let pointA = NSPoint(x: 200, y: 300) + private static let pointB = NSPoint(x: 600, y: 600) + private static let dragSteps = 6 + + /// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the + /// env var is unset; otherwise waits for launch, drives the production picker, and `exit`s. + static func runIfRequested() { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"], + !raw.isEmpty + else { return } + + let output = URL(fileURLWithPath: raw, isDirectory: true) + // Hop onto a plain main-queue turn after NSApp starts. Nested run loops + // from a Swift Task do not drain NSApp's event queue. + DispatchQueue.main.async { + MainActor.assumeIsolated { + execute(outputDirectory: output) + } + } + } + + private static func execute(outputDirectory: URL) { + do { + try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + } catch { + fail(expected: expectedLabel(), got: "could not create output dir: \(error)") + } + + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(pointA) }) + ?? NSScreen.screens.first + else { + fail(expected: expectedLabel(), got: "no NSScreen") + } + + let appKitRect = CGRect( + x: min(pointA.x, pointB.x), + y: min(pointA.y, pointB.y), + width: abs(pointB.x - pointA.x), + height: abs(pointB.y - pointA.y) + ).intersection(screen.frame) + let expected = CaptureRegion.fromAppKit(rect: appKitRect, on: screen) + + let picker = RegionPickerController() + var result: CaptureRegion?? + picker.pick { region in + result = .some(region) + } + + guard let overlay = overlayWindow(containing: pointA) else { + fail(expected: format(expected.rect), got: "no overlay window after pick()") + } + overlay.makeKey() + + var eventNumber = 1 + func post(_ type: NSEvent.EventType, at screenPoint: NSPoint, clickCount: Int) { + let locationInWindow = overlay.convertPoint(fromScreen: screenPoint) + guard let event = NSEvent.mouseEvent( + with: type, + location: locationInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: overlay.windowNumber, + context: nil, + eventNumber: eventNumber, + clickCount: clickCount, + pressure: 1 + ) else { + fail(expected: format(expected.rect), got: "NSEvent.mouseEvent(\(type.rawValue)) returned nil") + } + eventNumber += 1 + NSApp.postEvent(event, atStart: false) + // Local monitors run during NSApp.sendEvent (production dispatch), + // not during nextEvent dequeue. Drive that same path here. + NSApp.sendEvent(event) + } + + post(.leftMouseDown, at: pointA, clickCount: 1) + for step in 1...(dragSteps / 2) { + post(.leftMouseDragged, at: interpolate(step), clickCount: 0) + } + + writeOverlayBitmap(overlay, to: outputDirectory.appendingPathComponent("overlay-middrag.png")) + + for step in ((dragSteps / 2) + 1)...dragSteps { + post(.leftMouseDragged, at: interpolate(step), clickCount: 0) + } + post(.leftMouseUp, at: pointB, clickCount: 1) + + guard let wrapped = result else { + fail(expected: format(expected.rect), got: "completion never fired") + } + guard let got = wrapped else { + fail(expected: format(expected.rect), got: "nil") + } + + if got.rect != expected.rect { + fail(expected: format(expected.rect), got: format(got.rect)) + } + + print("PICKER-SELFTEST PASS rect=\(format(got.rect))") + fflush(stdout) + exit(0) + } + + private static func interpolate(_ step: Int) -> NSPoint { + let t = CGFloat(step) / CGFloat(dragSteps) + return NSPoint( + x: pointA.x + (pointB.x - pointA.x) * t, + y: pointA.y + (pointB.y - pointA.y) * t + ) + } + + private static func overlayWindow(containing point: NSPoint) -> RegionPickerWindow? { + let overlays = NSApp.windows.compactMap { $0 as? RegionPickerWindow } + return overlays.first(where: { $0.coveringScreen.frame.contains(point) }) ?? overlays.first + } + + private static func writeOverlayBitmap(_ overlay: RegionPickerWindow, to url: URL) { + guard let view = overlay.contentView else { + fail(expected: expectedLabel(), got: "overlay has no contentView") + } + overlay.layoutIfNeeded() + view.layoutSubtreeIfNeeded() + view.display() + let bounds = view.bounds + guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { + fail(expected: expectedLabel(), got: "bitmapImageRepForCachingDisplay failed") + } + view.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + fail(expected: expectedLabel(), got: "PNG encode failed") + } + do { + try png.write(to: url) + } catch { + fail(expected: expectedLabel(), got: "could not write \(url.path): \(error)") + } + print(url.path) + fflush(stdout) + } + + private static func expectedLabel() -> String { + format(CGRect( + x: min(pointA.x, pointB.x), + y: min(pointA.y, pointB.y), + width: abs(pointB.x - pointA.x), + height: abs(pointB.y - pointA.y) + )) + } + + private static func format(_ rect: CGRect) -> String { + "(\(rect.origin.x), \(rect.origin.y), \(rect.width), \(rect.height))" + } + + private static func fail(expected: String, got: String) -> Never { + print("PICKER-SELFTEST FAIL expected=\(expected) got=\(got)") + fflush(stdout) + exit(1) + } +} diff --git a/Sources/Shotdeck/RegionPickerController.swift b/Sources/Shotdeck/RegionPickerController.swift index 88d32bb..114fdcf 100644 --- a/Sources/Shotdeck/RegionPickerController.swift +++ b/Sources/Shotdeck/RegionPickerController.swift @@ -48,20 +48,27 @@ public final class RegionPickerController { matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown] ) { [weak self] event in guard let self else { return event } - // NSEvent is not Sendable; lift the two Sendable fields the handler - // needs. Mouse geometry is read from NSEvent.mouseLocation on the - // main thread inside the isolated methods, per spec. + // NSEvent is not Sendable; lift Sendable fields the handler needs. + // Prefer the event's window-local point converted to global AppKit + // coordinates; NSEvent.mouseLocation is only a fallback. + // keyCode is only valid on key events — reading it on a mouse event raises. let type = event.type - let keyCode = event.keyCode + let keyCode: UInt16 = (type == .keyDown) ? event.keyCode : 0 + let locationInWindow = event.locationInWindow + let windowNumber = event.windowNumber let consume = MainActor.assumeIsolated { - self.handle(type: type, keyCode: keyCode) + let location = self.globalAppKitLocation( + locationInWindow: locationInWindow, + windowNumber: windowNumber + ) + return self.handle(type: type, keyCode: keyCode, location: location) } return consume ? nil : event } } /// Returns `true` when the event should be swallowed. - private func handle(type: NSEvent.EventType, keyCode: UInt16) -> Bool { + private func handle(type: NSEvent.EventType, keyCode: UInt16, location: NSPoint?) -> Bool { switch type { case .keyDown: if keyCode == 53 { // kVK_Escape @@ -70,21 +77,21 @@ public final class RegionPickerController { } return false case .leftMouseDown: - handleMouseDown() + handleMouseDown(location: location) return false case .leftMouseDragged: - handleMouseDragged() + handleMouseDragged(location: location) return false case .leftMouseUp: - handleMouseUp() + handleMouseUp(location: location) return false default: return false } } - private func handleMouseDown() { - let point = NSEvent.mouseLocation + private func handleMouseDown(location: NSPoint?) { + let point = location ?? NSEvent.mouseLocation dragStart = point startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) }) ?? NSScreen.screens.first @@ -99,17 +106,17 @@ public final class RegionPickerController { } } - private func handleMouseDragged() { + private func handleMouseDragged(location: NSPoint?) { guard dragStart != nil, startScreen != nil else { return } - applyLiveSelection() + applyLiveSelection(current: location) } - private func handleMouseUp() { + private func handleMouseUp(location: NSPoint?) { guard let startScreen else { dragStart = nil return } - guard let rect = currentClampedRect(), rect.width >= 8, rect.height >= 8 else { + guard let rect = currentClampedRect(current: location), rect.width >= 8, rect.height >= 8 else { // Mis-click: reset drag state, leave every window open and fully dimmed. dragStart = nil self.startScreen = nil @@ -122,8 +129,8 @@ public final class RegionPickerController { finish(region: region) } - private func applyLiveSelection() { - guard let startScreen, let rect = currentClampedRect() else { return } + private func applyLiveSelection(current: NSPoint?) { + guard let startScreen, let rect = currentClampedRect(current: current) else { return } let local = Self.localRect(from: rect, on: startScreen) let text = "\(Int(rect.width)) x \(Int(rect.height))" window(for: startScreen)?.updateSelection(localRect: local, sizeText: text) @@ -134,9 +141,9 @@ public final class RegionPickerController { /// Normalize the drag (so bottom-right → up-left is not misjudged) then clamp /// to the screen the gesture started on — never selects across displays. - private func currentClampedRect() -> CGRect? { + private func currentClampedRect(current: NSPoint?) -> CGRect? { guard let dragStart, let startScreen else { return nil } - let current = NSEvent.mouseLocation + let current = current ?? NSEvent.mouseLocation let normalized = CGRect( x: min(dragStart.x, current.x), y: min(dragStart.y, current.y), @@ -146,6 +153,21 @@ public final class RegionPickerController { return normalized.intersection(startScreen.frame) } + /// Convert a monitored event's `locationInWindow` into global AppKit coordinates + /// (bottom-left origin, matching `NSEvent.mouseLocation` / `NSScreen.frame`). + private func globalAppKitLocation(locationInWindow: NSPoint, windowNumber: Int) -> NSPoint? { + if let window = windows.first(where: { $0.windowNumber == windowNumber }) { + return window.convertPoint(toScreen: locationInWindow) + } + if windowNumber != 0, let window = NSApp.window(withWindowNumber: windowNumber) { + return window.convertPoint(toScreen: locationInWindow) + } + if windowNumber == 0 { + return locationInWindow + } + return nil + } + private func window(for screen: NSScreen) -> RegionPickerWindow? { windows.first { $0.coveringScreen === screen } } diff --git a/Sources/Shotdeck/RegionPickerWindow.swift b/Sources/Shotdeck/RegionPickerWindow.swift index a5c315d..5ffaa3c 100644 --- a/Sources/Shotdeck/RegionPickerWindow.swift +++ b/Sources/Shotdeck/RegionPickerWindow.swift @@ -29,6 +29,7 @@ final class RegionPickerWindow: NSPanel { becomesKeyOnlyIfNeeded = false animationBehavior = .none collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + acceptsMouseMovedEvents = true contentView = pickerView } @@ -53,6 +54,8 @@ private final class RegionPickerView: NSView { override var isOpaque: Bool { false } override var acceptsFirstResponder: Bool { true } + // Accessory-app trap: first click on an inactive overlay is first-mouse and is dropped unless accepted. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 21ce466..81caac8 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -7,6 +7,9 @@ import ShotdeckCore if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } } +if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil { + MainActor.assumeIsolated { PickerSelfTest.runIfRequested() } +} ShotdeckApp.main() struct ShotdeckApp: App { From 517a4e4fdc17ba5028dc78e6dbeaeda10cd506fb Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 22:20:40 +0400 Subject: [PATCH 16/23] fix: load persisted capture region at launch; REGION-PERSIST selftest phase Ben-reported: picker opened on every activation. AppModel.init set region = nil and never read UserDefaults back; saving worked, every launch forgot it. init now loads via loadPersistedRegion() (decode + isStillValid). Selftest phase 2 writes a known region, reloads through the same path, asserts the rect, restores the user's stored value. Coordinator ran it: PICKER-SELFTEST PASS + REGION-PERSIST PASS, 90/90 tests green. Co-Authored-By: Claude Fable 5 --- Sources/Shotdeck/AppModel.swift | 10 ++++++- Sources/Shotdeck/PickerSelfTest.swift | 43 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 6c95b5a..050e971 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -72,7 +72,7 @@ public final class AppModel { captures: [], pdfFileName: nil ) - self.region = nil + self.region = Self.loadPersistedRegion() self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted // Seeded from FolderSettings.resolve() via resolvedAppSupportPaths — never .standard(). let folders = FolderSettings.resolve() @@ -237,6 +237,14 @@ public final class AppModel { NSWorkspace.shared.open(url) } + static func loadPersistedRegion() -> CaptureRegion? { + guard let data = UserDefaults.standard.data(forKey: CaptureRegion.defaultsKey), + let decoded = try? JSONDecoder().decode(CaptureRegion.self, from: data), + decoded.isStillValid + else { return nil } + return decoded + } + private func persistRegion(_ picked: CaptureRegion) { replaceRegion(picked) if let encoded = try? JSONEncoder().encode(picked) { diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index f389d43..c1eaede 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -111,9 +111,52 @@ enum PickerSelfTest { print("PICKER-SELFTEST PASS rect=\(format(got.rect))") fflush(stdout) + + runRegionPersistPhase() exit(0) } + /// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through + /// `AppModel.loadPersistedRegion()` (the same path init uses), then restores whatever + /// value was stored before so a real picked region is untouched. + private static func runRegionPersistPhase() { + let defaults = UserDefaults.standard + let previous = defaults.data(forKey: CaptureRegion.defaultsKey) + + let known = CaptureRegion( + displayID: CGMainDisplayID(), + rect: CGRect(x: 10, y: 10, width: 100, height: 100), + capturedScale: 2.0 + ) + var failure: String? + if let encoded = try? JSONEncoder().encode(known) { + defaults.set(encoded, forKey: CaptureRegion.defaultsKey) + if let loaded = AppModel.loadPersistedRegion() { + if loaded.rect != known.rect { + failure = "expected=\(format(known.rect)) got=\(format(loaded.rect))" + } + } else { + failure = "loadPersistedRegion returned nil" + } + } else { + failure = "could not encode CaptureRegion" + } + + if let previous { + defaults.set(previous, forKey: CaptureRegion.defaultsKey) + } else { + defaults.removeObject(forKey: CaptureRegion.defaultsKey) + } + + if let failure { + print("REGION-PERSIST FAIL \(failure)") + fflush(stdout) + exit(1) + } + print("REGION-PERSIST PASS") + fflush(stdout) + } + private static func interpolate(_ step: Int) -> NSPoint { let t = CGFloat(step) / CGFloat(dragSteps) return NSPoint( From d05bd735b57a20356bb47e1e808a0ad83c695d69 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 22:25:15 +0400 Subject: [PATCH 17/23] rename: user-facing product name Shotdeck -> Redline; legacy PDFs still recognized --- Info.plist | 6 +- README.md | 4 +- Sources/Shotdeck/MenuBarView.swift | 2 +- Sources/Shotdeck/SendController.swift | 2 +- Sources/Shotdeck/SettingsView.swift | 2 +- Sources/Shotdeck/main.swift | 4 +- .../ShotdeckCore/Model/ShotdeckError.swift | 2 +- Sources/ShotdeckCore/PDF/PDFComposer.swift | 4 +- .../Returns/AnnotationInspector.swift | 9 ++- .../ShotdeckCore/Returns/ReturnWatcher.swift | 2 +- .../AnnotationInspectorTests.swift | 66 +++++++++++++++++++ .../ShotdeckCoreTests/PDFComposerTests.swift | 2 +- .../ReturnWatcherTests.swift | 59 +++++++++++++++++ scripts/build-app.sh | 4 +- scripts/make-dmg.sh | 16 ++--- 15 files changed, 158 insertions(+), 26 deletions(-) diff --git a/Info.plist b/Info.plist index eb05ed5..62f3b8d 100644 --- a/Info.plist +++ b/Info.plist @@ -2,10 +2,14 @@ + CFBundleIdentifier ai.flowmaster.shotdeck CFBundleName - Shotdeck + Redline CFBundleExecutable Shotdeck CFBundlePackageType diff --git a/README.md b/README.md index 9158292..584de7b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Shotdeck +# Redline A macOS menu-bar app that captures a remembered screen region, builds a one-screenshot-per-page PDF, AirDrops it to an iPad for markup, then watches for the annotated file to come back. @@ -18,5 +18,5 @@ The grant is bound to the bundle identifier `ai.flowmaster.shotdeck` plus the co ```bash swift build && swift test ./scripts/build-app.sh # signed .app for daily use -open .build/Shotdeck.app +open .build/Redline.app ``` diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 5b1f1be..d0a16d9 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -128,7 +128,7 @@ struct MenuBarView: View { Button { NSApp.terminate(nil) } label: { - actionLabel("Quit Shotdeck") + actionLabel("Quit Redline") } } .buttonStyle(.plain) diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index 5934f05..59b7a87 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -18,7 +18,7 @@ extension AppModel: SendCapable { let finalURL = outboxDir.appendingPathComponent(fileName) // Same directory as the final target so the rename below is same-volume (atomic). let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf") - let title = "Shotdeck – \(DubaiTime.stamp(workingSession.createdAt))" + let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))" do { // D-13: build off the main actor. Only Sendable values cross into the diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index ca3ba35..c671be1 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -91,7 +91,7 @@ extension AppModel: SettingsWindowPresenting { } let hosting = NSHostingController(rootView: SettingsView().environment(self)) let window = NSWindow(contentViewController: hosting) - window.title = "Shotdeck Settings" + window.title = "Redline Settings" window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false window.center() diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 81caac8..3b61e42 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -27,7 +27,7 @@ struct ShotdeckApp: App { Text(count).font(.system(size: 11, weight: .semibold)) } } - .accessibilityLabel("Shotdeck") + .accessibilityLabel("Redline") } .menuBarExtraStyle(.window) } @@ -60,7 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // 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.") + model.setStatus("Redline could not access its storage folder. Captures will not persist.") return model } } diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift index 84880c8..a188a64 100644 --- a/Sources/ShotdeckCore/Model/ShotdeckError.swift +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -16,7 +16,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable { case .screenRecordingNotGranted: return "Screen Recording is turned off. Grant it in System Settings to capture." case .noRegionRemembered: - return "No capture region is set. Choose 'Re-select area' from the Shotdeck menu." + return "No capture region is set. Choose 'Re-select area' from the Redline menu." case .displayNoLongerConnected: return "The display used for capture is no longer connected." case .captureFailed(let underlying): diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift index 0e5e79c..0a9729c 100644 --- a/Sources/ShotdeckCore/PDF/PDFComposer.swift +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -71,7 +71,7 @@ public struct PDFComposer: Sendable { } public static func fileName(for session: CaptureSession) -> String { - "Shotdeck-\(DubaiTime.fileStamp(session.createdAt)).pdf" + "Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf" } private static func writePDF( @@ -86,7 +86,7 @@ public struct PDFComposer: Sendable { } let auxiliaryInfo: [String: Any] = [ - kCGPDFContextCreator as String: "Shotdeck", + kCGPDFContextCreator as String: "Redline", kCGPDFContextTitle as String: title, kCGPDFContextSubject as String: sessionID.uuidString.lowercased(), ] diff --git a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift index 7df2922..5b376e8 100644 --- a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift +++ b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift @@ -93,16 +93,19 @@ public enum AnnotationInspector { ) } - /// True when this PDF was produced by Shotdeck. Creator attribute is authoritative; + /// True when this PDF was produced by this app. Creator attribute is authoritative; /// the filename fallback applies ONLY when the creator attribute is absent. + /// Accepts both the current product name ("Redline") and the legacy name ("Shotdeck") + /// so PDFs already on the iPad or in Downloads are still detected. 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 + // Present creator is authoritative, full stop. + return creator == "Redline" || creator == "Shotdeck" } // 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 + return name.wholeMatch(of: /^(Redline|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. diff --git a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift index e19552f..829d3b8 100644 --- a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift +++ b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift @@ -60,7 +60,7 @@ public actor ReturnWatcher { } /// Scans the watch folder once, immediately, without waiting for an event. Every - /// recognized, stable, openable Shotdeck PDF present is (re-)inspected and (re-)recorded + /// recognized, stable, openable Redline/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] { diff --git a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift index a7f84c0..8bf6b34 100644 --- a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift +++ b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift @@ -371,6 +371,72 @@ func i19b_presentNonShotdeckCreatorBeatsMatchingFilename() throws { #expect(AnnotationInspector.isShotdeckDocument(reopened) == false) } +@Test("I-19c Present Redline creator is recognized") +func i19c_presentRedlineCreatorIsRecognized() throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134219.pdf") + try makePDF(at: pdfURL, pageCount: 1, creator: "Redline") + let reopened = try #require(PDFDocument(url: pdfURL)) + #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) +} + +@Test("I-19d Present legacy Shotdeck creator is still recognized") +func i19d_presentLegacyShotdeckCreatorIsStillRecognized() throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134220.pdf") + try makePDF(at: pdfURL, pageCount: 1, creator: "Shotdeck") + let reopened = try #require(PDFDocument(url: pdfURL)) + #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) +} + +@Test("I-19e Absent creator falls back to Redline filename") +func i19e_absentCreatorFallsBackToRedlineFilename() throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134221.pdf") + try makePDF(at: pdfURL, pageCount: 1, creator: nil) + let reopened = try #require(PDFDocument(url: pdfURL)) + #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) +} + +@Test("I-19f Absent creator falls back to legacy Shotdeck filename") +func i19f_absentCreatorFallsBackToLegacyShotdeckFilename() throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134222.pdf") + try makePDF(at: pdfURL, pageCount: 1, creator: nil) + let reopened = try #require(PDFDocument(url: pdfURL)) + #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) +} + +@Test("I-19g Absent creator falls back to Redline duplicate-name suffix") +func i19g_absentCreatorFallsBackToRedlineDuplicateNameSuffix() throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134223 2.pdf") + try makePDF(at: pdfURL, pageCount: 1, creator: nil) + let reopened = try #require(PDFDocument(url: pdfURL)) + #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) +} + +@Test("I-19h Present non-product creator beats a matching Redline filename") +func i19h_presentNonProductCreatorBeatsMatchingRedlineFilename() throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134224.pdf") + try makePDF(at: pdfURL, pageCount: 1, creator: "Preview") + let reopened = try #require(PDFDocument(url: pdfURL)) + #expect(AnnotationInspector.isShotdeckDocument(reopened) == false) +} + private func expectSinglePageMark( _ subtype: PDFAnnotationSubtype, bounds: CGRect, diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift index 2842daf..ef7d3d7 100644 --- a/Tests/ShotdeckCoreTests/PDFComposerTests.swift +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -499,7 +499,7 @@ func documentAttributesRoundTrip() throws { ) let document = try openDocument(output) let attributes = try #require(document.documentAttributes) - #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Shotdeck") + #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Redline") #expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == "Ben review — 2026-08-30") #expect(attributes[PDFDocumentAttribute.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001") #expect(document.pageCount == 1) diff --git a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift index 5b828b3..95bce13 100644 --- a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift +++ b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift @@ -109,6 +109,65 @@ func w25_duplicateNameSuffixIsRecognizedByScanNow() async throws { #expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path) } +@Test("W-25b Redline duplicate-name suffix is the normal AirDrop return (scanNow)") +func w25b_redlineDuplicateNameSuffixIsRecognizedByScanNow() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let ledger = try ReturnLedger(paths: paths) + let watcher = ReturnWatcher(paths: paths, ledger: ledger) + let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134205 2.pdf") + + try makePDF( + at: pdfURL, + pageCount: 1, + creator: nil, + subject: "44444444-4444-4444-4444-444444444444", + 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 == "Redline-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-25c Redline creator is recognized by scanNow") +func w25c_redlineCreatorIsRecognizedByScanNow() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let ledger = try ReturnLedger(paths: paths) + let watcher = ReturnWatcher(paths: paths, ledger: ledger) + let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134230.pdf") + + try makePDF( + at: pdfURL, + pageCount: 1, + creator: "Redline", + 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 == "Redline-20260830-134230.pdf") + #expect(doc.isCommented == true) +} + @Test("W-26 Creator provenance negative at the scan level") func w26_presentNonShotdeckCreatorIsIgnoredByScanNow() async throws { let (paths, cleanup) = try makeCasePaths() diff --git a/scripts/build-app.sh b/scripts/build-app.sh index b96899c..1a226f9 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -11,7 +11,7 @@ cd "$ROOT" IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" BUNDLE_ID="ai.flowmaster.shotdeck" -APP_BUNDLE="${ROOT}/.build/Shotdeck.app" +APP_BUNDLE="${ROOT}/.build/Redline.app" SKIP_SIGN=0 for arg in "$@"; do @@ -27,7 +27,7 @@ for arg in "$@"; do esac done -echo "==> Building Shotdeck (release)" +echo "==> Building Redline (release)" swift build -c release --product Shotdeck BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck" diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh index 301e1cb..adc4196 100755 --- a/scripts/make-dmg.sh +++ b/scripts/make-dmg.sh @@ -4,9 +4,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -APP_BUNDLE="${ROOT}/.build/Shotdeck.app" +APP_BUNDLE="${ROOT}/.build/Redline.app" STAGING="${ROOT}/.build/dmg-staging" -DMG="${ROOT}/.build/Shotdeck.dmg" +DMG="${ROOT}/.build/Redline.dmg" MOUNT_POINT="${ROOT}/.build/dmg-mnt" SKIP_SIGN=0 @@ -23,7 +23,7 @@ for arg in "$@"; do esac done -echo "==> Building Shotdeck.app" +echo "==> Building Redline.app" if [[ "${SKIP_SIGN}" -eq 1 ]]; then ./scripts/build-app.sh --skip-sign else @@ -38,12 +38,12 @@ fi echo "==> Staging DMG contents" rm -rf "${STAGING}" mkdir -p "${STAGING}" -ditto "${APP_BUNDLE}" "${STAGING}/Shotdeck.app" +ditto "${APP_BUNDLE}" "${STAGING}/Redline.app" ln -s /Applications "${STAGING}/Applications" echo "==> Creating ${DMG}" mkdir -p "$(dirname "${DMG}")" -hdiutil create -volname "Shotdeck" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}" +hdiutil create -volname "Redline" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}" MOUNTED=0 detach_dmg() { @@ -67,8 +67,8 @@ MOUNTED=1 echo "==> Mount contents" ls -la "${MOUNT_POINT}" -if [[ ! -d "${MOUNT_POINT}/Shotdeck.app" ]]; then - echo "Verification failed: Shotdeck.app missing from mounted DMG" >&2 +if [[ ! -d "${MOUNT_POINT}/Redline.app" ]]; then + echo "Verification failed: Redline.app missing from mounted DMG" >&2 exit 1 fi if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then @@ -81,7 +81,7 @@ if [[ "$(readlink "${MOUNT_POINT}/Applications")" != "/Applications" ]]; then fi echo "==> codesign --verify --deep" -codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Shotdeck.app" +codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Redline.app" echo "==> Detaching ${MOUNT_POINT}" hdiutil detach "${MOUNT_POINT}" From c52e68a9d5fd4b1b6066a195ab30969707aecf39 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 22:33:10 +0400 Subject: [PATCH 18/23] icon: programmatic Redline app icon (icns + generator script) --- Info.plist | 2 + Resources/AppIcon.icns | Bin 0 -> 446409 bytes scripts/build-app.sh | 1 + scripts/make-icon.swift | 324 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+) create mode 100644 Resources/AppIcon.icns create mode 100644 scripts/make-icon.swift diff --git a/Info.plist b/Info.plist index 62f3b8d..8717b5c 100644 --- a/Info.plist +++ b/Info.plist @@ -18,6 +18,8 @@ 0.1.0 CFBundleVersion 1 + CFBundleIconFile + AppIcon LSUIElement LSMinimumSystemVersion diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..f3396f0d9c05cfd13501a1b52d9674f61800117d GIT binary patch literal 446409 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 z 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 literal 0 HcmV?d00001 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 1a226f9..bac4075 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -43,6 +43,7 @@ mkdir -p "${APP_BUNDLE}/Contents/Resources" cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck" chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck" cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist" +cp "${ROOT}/Resources/AppIcon.icns" "${APP_BUNDLE}/Contents/Resources/AppIcon.icns" if [[ "${SKIP_SIGN}" -eq 1 ]]; then echo diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..439c03e --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,324 @@ +#!/usr/bin/env swift +import Foundation +import CoreGraphics +import ImageIO + +// Programmatic Redline app icon. +// Draws a 1024×1024 master, writes AppIcon.iconset (16…1024 incl. @2x), +// and compiles Resources/AppIcon.icns via iconutil. +// +// Usage: +// swift scripts/make-icon.swift +// swift scripts/make-icon.swift --preview /path/to/icon-512.png + +private let masterSize = 1024 + +private enum Palette { + static let charcoalTop = CGColor(srgbRed: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 1) + static let charcoalBottom = CGColor(srgbRed: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 1) // #1C1C1E + static let white = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + static let redline = CGColor(srgbRed: 229.0 / 255.0, green: 72.0 / 255.0, blue: 63.0 / 255.0, alpha: 1) // #E5483F +} + +private struct IconsetEntry { + let filename: String + let pixels: Int +} + +private let iconsetEntries: [IconsetEntry] = [ + IconsetEntry(filename: "icon_16x16.png", pixels: 16), + IconsetEntry(filename: "icon_16x16@2x.png", pixels: 32), + IconsetEntry(filename: "icon_32x32.png", pixels: 32), + IconsetEntry(filename: "icon_32x32@2x.png", pixels: 64), + IconsetEntry(filename: "icon_128x128.png", pixels: 128), + IconsetEntry(filename: "icon_128x128@2x.png", pixels: 256), + IconsetEntry(filename: "icon_256x256.png", pixels: 256), + IconsetEntry(filename: "icon_256x256@2x.png", pixels: 512), + IconsetEntry(filename: "icon_512x512.png", pixels: 512), + IconsetEntry(filename: "icon_512x512@2x.png", pixels: 1024), +] + +// MARK: - Geometry + +/// Apple-style continuous-corner rounded square (squircle-like). +/// Superellipse |x/a|^n + |y/b|^n = 1 with n≈5, inset 1px so antialiased +/// edge pixels are not clipped by the bitmap. +private func continuousRoundedSquare(size: CGFloat, exponent n: CGFloat = 5.0, segments: Int = 256) -> CGPath { + let inset: CGFloat = 1 + let a = (size - inset * 2) / 2 + let cx = size / 2 + let cy = size / 2 + let twoOverN = 2 / n + let path = CGMutablePath() + for i in 0...segments { + let theta = CGFloat(i) / CGFloat(segments) * 2 * .pi + let ct = cos(theta) + let st = sin(theta) + let x = cx + (ct < 0 ? -1 : 1) * pow(abs(ct), twoOverN) * a + let y = cy + (st < 0 ? -1 : 1) * pow(abs(st), twoOverN) * a + if i == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + path.closeSubpath() + return path +} + +// MARK: - Drawing + +private func drawMasterIcon(size: Int) -> CGImage { + let s = CGFloat(size) + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue + guard let ctx = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + fputs("error: failed to create \(size)×\(size) bitmap context\n", stderr) + exit(1) + } + + ctx.setShouldAntialias(true) + ctx.setAllowsAntialiasing(true) + ctx.interpolationQuality = .high + + // Flip to top-left origin so "top-to-bottom gradient" is literal. + ctx.translateBy(x: 0, y: s) + ctx.scaleBy(x: 1, y: -1) + + ctx.clear(CGRect(x: 0, y: 0, width: s, height: s)) + + let squircle = continuousRoundedSquare(size: s) + + ctx.saveGState() + ctx.addPath(squircle) + ctx.clip() + + let gradient = CGGradient( + colorsSpace: colorSpace, + colors: [Palette.charcoalTop, Palette.charcoalBottom] as CFArray, + locations: [0, 1] + )! + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: s / 2, y: 0), + end: CGPoint(x: s / 2, y: s), + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] + ) + ctx.restoreGState() + + // Viewfinder corner brackets: thick L-strokes, rounded caps/joins, inset ~18%. + let inset = s * 0.18 + let bracketWidth = s * 0.095 + let arm = s * 0.155 + let centerline = inset + bracketWidth / 2 + + ctx.saveGState() + ctx.addPath(squircle) + ctx.clip() + ctx.setStrokeColor(Palette.white) + ctx.setLineWidth(bracketWidth) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + + func strokeBracket(cornerX: CGFloat, cornerY: CGFloat, dirX: CGFloat, dirY: CGFloat) { + let path = CGMutablePath() + path.move(to: CGPoint(x: cornerX + dirX * arm, y: cornerY)) + path.addLine(to: CGPoint(x: cornerX, y: cornerY)) + path.addLine(to: CGPoint(x: cornerX, y: cornerY + dirY * arm)) + ctx.addPath(path) + ctx.strokePath() + } + + // Top-left, top-right, bottom-left, bottom-right. + strokeBracket(cornerX: centerline, cornerY: centerline, dirX: 1, dirY: 1) + strokeBracket(cornerX: s - centerline, cornerY: centerline, dirX: -1, dirY: 1) + strokeBracket(cornerX: centerline, cornerY: s - centerline, dirX: 1, dirY: -1) + strokeBracket(cornerX: s - centerline, cornerY: s - centerline, dirX: -1, dirY: -1) + + // Bold redline slash over the frame, lower-left bracket area → upper-right. + let slashWidth = s * 0.078 + ctx.setStrokeColor(Palette.redline) + ctx.setLineWidth(slashWidth) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + let slashInset = centerline + arm * 0.12 + let slash = CGMutablePath() + slash.move(to: CGPoint(x: slashInset, y: s - slashInset)) + slash.addLine(to: CGPoint(x: s - slashInset, y: slashInset)) + ctx.addPath(slash) + ctx.strokePath() + ctx.restoreGState() + + guard let image = ctx.makeImage() else { + fputs("error: failed to materialize master CGImage\n", stderr) + exit(1) + } + return image +} + +private func scaledImage(_ image: CGImage, pixels: Int) -> CGImage { + if image.width == pixels && image.height == pixels { + return image + } + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue + guard let ctx = CGContext( + data: nil, + width: pixels, + height: pixels, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + fputs("error: failed to create \(pixels)×\(pixels) scale context\n", stderr) + exit(1) + } + ctx.interpolationQuality = .high + ctx.setShouldAntialias(true) + ctx.draw(image, in: CGRect(x: 0, y: 0, width: pixels, height: pixels)) + guard let out = ctx.makeImage() else { + fputs("error: failed to scale image to \(pixels)px\n", stderr) + exit(1) + } + return out +} + +private func writePNG(_ image: CGImage, to url: URL) { + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.removeItem(at: url) + } + guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { + fputs("error: cannot create PNG destination at \(url.path)\n", stderr) + exit(1) + } + CGImageDestinationAddImage(dest, image, nil) + if !CGImageDestinationFinalize(dest) { + fputs("error: failed to write PNG \(url.path)\n", stderr) + exit(1) + } +} + +// MARK: - Paths / CLI + +private func repoRoot() -> URL { + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let arg0 = URL(fileURLWithPath: CommandLine.arguments[0]) + let scriptURL: URL + if arg0.path.hasPrefix("/") { + scriptURL = arg0.standardizedFileURL + } else { + scriptURL = cwd.appendingPathComponent(arg0.path).standardizedFileURL + } + let fromScript = scriptURL.deletingLastPathComponent().deletingLastPathComponent() + if FileManager.default.fileExists(atPath: fromScript.appendingPathComponent("Package.swift").path) { + return fromScript + } + if FileManager.default.fileExists(atPath: cwd.appendingPathComponent("Package.swift").path) { + return cwd + } + fputs("error: could not find repo root (Package.swift)\n", stderr) + exit(1) +} + +private func parsePreviewPath() -> String? { + let args = CommandLine.arguments + var i = 1 + var preview: String? + while i < args.count { + let arg = args[i] + if arg == "--preview" { + i += 1 + guard i < args.count else { + fputs("error: --preview requires a path\n", stderr) + exit(1) + } + preview = args[i] + } else if arg.hasPrefix("--preview=") { + preview = String(arg.dropFirst("--preview=".count)) + } else if arg == "--help" || arg == "-h" { + fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr) + exit(0) + } else { + fputs("error: unknown argument \(arg)\n", stderr) + fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr) + exit(1) + } + i += 1 + } + return preview +} + +private func runIconutil(iconset: URL, icns: URL) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") + proc.arguments = ["-c", "icns", iconset.path, "-o", icns.path] + proc.standardOutput = FileHandle.standardOutput + proc.standardError = FileHandle.standardError + do { + try proc.run() + proc.waitUntilExit() + } catch { + fputs("error: failed to launch iconutil: \(error)\n", stderr) + exit(1) + } + if proc.terminationStatus != 0 { + fputs("error: iconutil exited \(proc.terminationStatus)\n", stderr) + exit(1) + } +} + +// MARK: - Main + +let root = repoRoot() +let resources = root.appendingPathComponent("Resources", isDirectory: true) +let icnsURL = resources.appendingPathComponent("AppIcon.icns") +let previewPath = parsePreviewPath() +let fm = FileManager.default + +print("==> Drawing \(masterSize)×\(masterSize) master") +let master = drawMasterIcon(size: masterSize) + +let iconset = fm.temporaryDirectory.appendingPathComponent("Redline-AppIcon-\(UUID().uuidString).iconset", isDirectory: true) + +do { + try fm.createDirectory(at: iconset, withIntermediateDirectories: true) + try fm.createDirectory(at: resources, withIntermediateDirectories: true) + + print("==> Writing AppIcon.iconset") + for entry in iconsetEntries { + let img = scaledImage(master, pixels: entry.pixels) + writePNG(img, to: iconset.appendingPathComponent(entry.filename)) + } + + if fm.fileExists(atPath: icnsURL.path) { + try fm.removeItem(at: icnsURL) + } + + print("==> Compiling \(icnsURL.path)") + runIconutil(iconset: iconset, icns: icnsURL) + + try? fm.removeItem(at: iconset) +} catch { + try? fm.removeItem(at: iconset) + fputs("error: \(error)\n", stderr) + exit(1) +} + +if let previewPath { + let previewURL = URL(fileURLWithPath: previewPath) + print("==> Writing 512px preview \(previewURL.path)") + writePNG(scaledImage(master, pixels: 512), to: previewURL) +} + +print("App icon: \(icnsURL.path)") From fa3e7b0a4aac4ac587fea51c4b8ab22631b25134 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 22:33:13 +0400 Subject: [PATCH 19/23] feature: user-selectable capture hotkey with recorder in Settings --- Sources/Shotdeck/AppModel.swift | 40 +++++-- Sources/Shotdeck/HotkeyPreference.swift | 151 ++++++++++++++++++++++++ Sources/Shotdeck/MenuBarView.swift | 4 +- Sources/Shotdeck/SettingsView.swift | 78 +++++++++++- 4 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 Sources/Shotdeck/HotkeyPreference.swift diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 050e971..9419436 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -1,5 +1,4 @@ import AppKit -import Carbon.HIToolbox import Foundation import Observation import SwiftUI @@ -37,6 +36,9 @@ public final class AppModel { public private(set) var outboxURL: URL /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. public private(set) var watchFolderURL: URL + /// Currently bound capture combo (the last one Carbon accepted, or the preferred load). + private(set) var captureHotkey: HotkeyPreference + var hotkeyDisplayString: String { captureHotkey.displayString } let paths: AppSupportPaths let spool: SpoolStore @@ -80,6 +82,7 @@ public final class AppModel { self.watchFolderURL = folders.watch self.outboxDisplayName = folders.outbox.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent + self.captureHotkey = HotkeyPreference.load() } // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. @@ -146,16 +149,39 @@ public final class AppModel { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.") } - let registered = hotkeys.register( + let pref = HotkeyPreference.load() + captureHotkey = pref + if !bindCaptureHotkey(pref) { + setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") + } + } + + /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new + /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. + func reRegisterHotkey() { + let previous = captureHotkey + let next = HotkeyPreference.load() + hotkeys.unregister(id: "capture") + if bindCaptureHotkey(next) { + captureHotkey = next + return + } + setStatus("That combination is taken — pick another.") + previous.save() + if bindCaptureHotkey(previous) { + captureHotkey = previous + } + } + + @discardableResult + private func bindCaptureHotkey(_ pref: HotkeyPreference) -> Bool { + hotkeys.register( id: "capture", - keyCode: UInt32(kVK_ANSI_2), - modifiers: UInt32(optionKey | shiftKey) + keyCode: pref.keyCode, + modifiers: pref.modifiers ) { [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 { diff --git a/Sources/Shotdeck/HotkeyPreference.swift b/Sources/Shotdeck/HotkeyPreference.swift new file mode 100644 index 0000000..e0c53ea --- /dev/null +++ b/Sources/Shotdeck/HotkeyPreference.swift @@ -0,0 +1,151 @@ +import AppKit +import Carbon.HIToolbox +import Foundation + +/// User-chosen capture hotkey. `modifiers` are Carbon bits (`cmdKey`, `optionKey`, +/// `shiftKey`, `controlKey`), matching `HotkeyCenter.register`. +struct HotkeyPreference: Codable, Equatable, Sendable { + var keyCode: UInt32 + var modifiers: UInt32 + + static let defaultsKey = "ai.flowmaster.shotdeck.hotkey" + + /// ⌥⇧2 — kVK_ANSI_2 (19) with optionKey|shiftKey. + static let `default` = HotkeyPreference( + keyCode: 19, + modifiers: UInt32(optionKey) | UInt32(shiftKey) + ) + + static func load(defaults: UserDefaults = .standard) -> HotkeyPreference { + guard let data = defaults.data(forKey: defaultsKey), + let decoded = try? JSONDecoder().decode(HotkeyPreference.self, from: data), + decoded.modifiers != 0 + else { return .default } + return decoded + } + + func save(defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.defaultsKey) + } + + /// ⌘⌥⇧⌃ in that order, then a name for common keycodes. + var displayString: String { + var s = "" + if modifiers & UInt32(cmdKey) != 0 { s += "⌘" } + if modifiers & UInt32(optionKey) != 0 { s += "⌥" } + if modifiers & UInt32(shiftKey) != 0 { s += "⇧" } + if modifiers & UInt32(controlKey) != 0 { s += "⌃" } + s += Self.keyName(for: keyCode) + return s + } + + /// `nil` when the event is modifier-only or has no ⌘⌥⇧⌃ flags. + static func fromKeyEvent(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> HotkeyPreference? { + switch Int(keyCode) { + case kVK_Shift, kVK_RightShift, + kVK_Command, kVK_RightCommand, + kVK_Option, kVK_RightOption, + kVK_Control, kVK_RightControl, + kVK_CapsLock, kVK_Function: + return nil + default: + break + } + var carbon: UInt32 = 0 + if modifierFlags.contains(.command) { carbon |= UInt32(cmdKey) } + if modifierFlags.contains(.option) { carbon |= UInt32(optionKey) } + if modifierFlags.contains(.shift) { carbon |= UInt32(shiftKey) } + if modifierFlags.contains(.control) { carbon |= UInt32(controlKey) } + guard carbon != 0 else { return nil } + return HotkeyPreference(keyCode: UInt32(keyCode), modifiers: carbon) + } + + private static func keyName(for keyCode: UInt32) -> String { + switch Int(keyCode) { + case kVK_ANSI_A: return "A" + case kVK_ANSI_B: return "B" + case kVK_ANSI_C: return "C" + case kVK_ANSI_D: return "D" + case kVK_ANSI_E: return "E" + case kVK_ANSI_F: return "F" + case kVK_ANSI_G: return "G" + case kVK_ANSI_H: return "H" + case kVK_ANSI_I: return "I" + case kVK_ANSI_J: return "J" + case kVK_ANSI_K: return "K" + case kVK_ANSI_L: return "L" + case kVK_ANSI_M: return "M" + case kVK_ANSI_N: return "N" + case kVK_ANSI_O: return "O" + case kVK_ANSI_P: return "P" + case kVK_ANSI_Q: return "Q" + case kVK_ANSI_R: return "R" + case kVK_ANSI_S: return "S" + case kVK_ANSI_T: return "T" + case kVK_ANSI_U: return "U" + case kVK_ANSI_V: return "V" + case kVK_ANSI_W: return "W" + case kVK_ANSI_X: return "X" + case kVK_ANSI_Y: return "Y" + case kVK_ANSI_Z: return "Z" + case kVK_ANSI_0: return "0" + case kVK_ANSI_1: return "1" + case kVK_ANSI_2: return "2" + case kVK_ANSI_3: return "3" + case kVK_ANSI_4: return "4" + case kVK_ANSI_5: return "5" + case kVK_ANSI_6: return "6" + case kVK_ANSI_7: return "7" + case kVK_ANSI_8: return "8" + case kVK_ANSI_9: return "9" + case kVK_ANSI_Equal: return "=" + case kVK_ANSI_Minus: return "-" + case kVK_ANSI_RightBracket: return "]" + case kVK_ANSI_LeftBracket: return "[" + case kVK_ANSI_Quote: return "'" + case kVK_ANSI_Semicolon: return ";" + case kVK_ANSI_Backslash: return "\\" + case kVK_ANSI_Comma: return "," + case kVK_ANSI_Slash: return "/" + case kVK_ANSI_Period: return "." + case kVK_ANSI_Grave: return "`" + case kVK_Space: return "Space" + case kVK_Return: return "Return" + case kVK_Tab: return "Tab" + case kVK_Delete: return "Delete" + case kVK_ForwardDelete: return "Fwd Delete" + case kVK_Escape: return "Esc" + case kVK_Home: return "Home" + case kVK_End: return "End" + case kVK_PageUp: return "Page Up" + case kVK_PageDown: return "Page Down" + case kVK_Help: return "Help" + case kVK_LeftArrow: return "←" + case kVK_RightArrow: return "→" + case kVK_DownArrow: return "↓" + case kVK_UpArrow: return "↑" + case kVK_F1: return "F1" + case kVK_F2: return "F2" + case kVK_F3: return "F3" + case kVK_F4: return "F4" + case kVK_F5: return "F5" + case kVK_F6: return "F6" + case kVK_F7: return "F7" + case kVK_F8: return "F8" + case kVK_F9: return "F9" + case kVK_F10: return "F10" + case kVK_F11: return "F11" + case kVK_F12: return "F12" + case kVK_F13: return "F13" + case kVK_F14: return "F14" + case kVK_F15: return "F15" + case kVK_F16: return "F16" + case kVK_F17: return "F17" + case kVK_F18: return "F18" + case kVK_F19: return "F19" + case kVK_F20: return "F20" + default: return "Key \(keyCode)" + } + } +} diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index d0a16d9..2218cbc 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -49,7 +49,7 @@ struct MenuBarView: View { private var defaultStatusText: String { guard let region = model.region else { - return "No region yet — press ⌥⇧2 to pick one." + return "No region yet — press \(model.hotkeyDisplayString) to pick one." } let w = Int(region.rect.width) let h = Int(region.rect.height) @@ -89,7 +89,7 @@ struct MenuBarView: View { Button { Task { await model.captureNow() } } label: { - actionLabel("Capture now", trailing: "⌥⇧2") + actionLabel("Capture now", trailing: model.hotkeyDisplayString) } .disabled(model.isCapturing) diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index c671be1..4f33f99 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -4,6 +4,8 @@ import ShotdeckCore struct SettingsView: View { @Environment(AppModel.self) private var model + @State private var isRecordingHotkey = false + @State private var recorder = HotkeyRecorderBox() private let labelWidth: CGFloat = 104 @@ -16,13 +18,19 @@ struct SettingsView: View { .gridCellColumns(2) } - GridRow(alignment: .firstTextBaseline) { + GridRow(alignment: .center) { fieldLabel("Capture") - Text("⌥⇧2 — fixed in this version") - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: 22, alignment: .leading) + HStack(spacing: 8) { + Button { + armHotkeyRecorder() + } label: { + Text(isRecordingHotkey ? "Press keys…" : model.hotkeyDisplayString) + .foregroundStyle(isRecordingHotkey ? .secondary : .primary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(minHeight: 22) } GridRow { @@ -57,6 +65,35 @@ struct SettingsView: View { .padding(16) .frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading) .controlSize(.small) + .onDisappear { disarmHotkeyRecorder() } + } + + private func armHotkeyRecorder() { + guard !isRecordingHotkey else { return } + isRecordingHotkey = true + recorder.onKey = { keyCode, flags in + handleRecorderKey(keyCode: keyCode, flags: flags) + } + recorder.arm() + } + + private func handleRecorderKey(keyCode: UInt16, flags: NSEvent.ModifierFlags) { + if keyCode == 53 { // kVK_Escape + disarmHotkeyRecorder() + return + } + guard let pref = HotkeyPreference.fromKeyEvent(keyCode: keyCode, modifierFlags: flags) else { + return + } + pref.save() + model.reRegisterHotkey() + disarmHotkeyRecorder() + } + + private func disarmHotkeyRecorder() { + recorder.disarm() + recorder.onKey = nil + isRecordingHotkey = false } private func fieldLabel(_ title: String) -> some View { @@ -80,6 +117,35 @@ struct SettingsView: View { } } +/// Local keyDown monitor for the Settings capture-hotkey recorder. Callbacks hop onto the +/// main actor the same way `RegionPickerController` does — local monitors fire on the +/// main run loop during `NSApp.sendEvent`. +@MainActor +private final class HotkeyRecorderBox { + var onKey: ((UInt16, NSEvent.ModifierFlags) -> Void)? + private var monitor: Any? + + func arm() { + disarm() + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + let keyCode = event.keyCode + let rawFlags = event.modifierFlags.rawValue + MainActor.assumeIsolated { + self.onKey?(keyCode, NSEvent.ModifierFlags(rawValue: rawFlags)) + } + return nil + } + } + + func disarm() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + } +} + extension AppModel: SettingsWindowPresenting { private static var settingsWindowController: NSWindowController? From 728456848913c3e47cf988cee57a3d5b27af693e Mon Sep 17 00:00:00 2001 From: kua-agent Date: Wed, 2 Sep 2026 09:02:45 +0400 Subject: [PATCH 20/23] =?UTF-8?q?release=20tooling:=20publish-update.sh=20?= =?UTF-8?q?=E2=80=94=20bump,=20build,=20sign,=20zip,=20appcast,=20upload,?= =?UTF-8?q?=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/publish-update.sh | 287 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100755 scripts/publish-update.sh diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh new file mode 100755 index 0000000..ca6550d --- /dev/null +++ b/scripts/publish-update.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-command Redline release: bump Info.plist, commit, signed build, zip, +# DMG, appcast, upload to mmd01, verify the public URLs. +# Hidden flag: --test — upload under .../redline/test/ and skip the git commit. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +PLIST="${ROOT}/Info.plist" +PLISTBUDDY="/usr/libexec/PlistBuddy" +REMOTE_HOST="mmd01" +REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" +PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline" +SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" + +usage() { + echo "Usage: $0 [\"notes\"]" >&2 + exit 1 +} + +TEST_MODE=0 +VERSION="" +NOTES="" +NOTES_SET=0 +for arg in "$@"; do + case "${arg}" in + --test) + TEST_MODE=1 + ;; + --help|-h) + usage + ;; + --*) + echo "Unknown argument: ${arg}" >&2 + usage + ;; + *) + if [[ -z "${VERSION}" ]]; then + VERSION="${arg}" + elif [[ "${NOTES_SET}" -eq 0 ]]; then + NOTES="${arg}" + NOTES_SET=1 + else + echo "Unexpected extra argument: ${arg}" >&2 + usage + fi + ;; + esac +done + +if [[ -z "${VERSION}" ]]; then + usage +fi + +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then + echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2 + exit 1 +fi + +if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then + echo "Version contains illegal path characters: ${VERSION}" >&2 + exit 1 +fi + +if [[ "${TEST_MODE}" -eq 1 ]]; then + REMOTE_DIR="${REMOTE_BASE}/test" + PUBLIC_DIR="${PUBLIC_BASE}/test" +else + REMOTE_DIR="${REMOTE_BASE}" + PUBLIC_DIR="${PUBLIC_BASE}" +fi + +ZIP_NAME="Redline-${VERSION}.zip" +DMG_NAME="Redline-${VERSION}.dmg" +ZIP_PATH="${ROOT}/.build/${ZIP_NAME}" +DMG_PATH="${ROOT}/.build/Redline.dmg" +APPCAST_PATH="${ROOT}/.build/appcast.json" +ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}" +APPCAST_URL="${PUBLIC_DIR}/appcast.json" + +if [[ "${TEST_MODE}" -eq 1 ]]; then + echo "==> Publish Redline ${VERSION} (test)" +else + echo "==> Publish Redline ${VERSION}" +fi +echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/" +echo " public: ${PUBLIC_DIR}/" + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Not inside a git work tree." >&2 + exit 1 +fi + +# Tracked files must match HEAD. Untracked files are ignored so this script +# can be dry-run (--test) before it is itself committed. +if [[ -n "$(git status --porcelain -uno)" ]]; then + echo "git tree is not clean; commit or stash before publishing." >&2 + git status --porcelain -uno >&2 + exit 1 +fi + +if [[ ! -x "${PLISTBUDDY}" ]]; then + echo "PlistBuddy not found at ${PLISTBUDDY}" >&2 + exit 1 +fi +if [[ ! -f "${PLIST}" ]]; then + echo "Info.plist not found at ${PLIST}" >&2 + exit 1 +fi + +restore_plist() { + git checkout -- "${PLIST}" >/dev/null 2>&1 || true +} + +if [[ "${TEST_MODE}" -eq 1 ]]; then + trap restore_plist EXIT +fi + +CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")" +if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then + echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2 + exit 1 +fi +NEW_BUILD=$((CURRENT_BUILD + 1)) + +echo "==> Bumping Info.plist" +echo " CFBundleShortVersionString -> ${VERSION}" +echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}" +"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}" +"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}" + +if [[ "${TEST_MODE}" -eq 0 ]]; then + echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)" + git add "${PLIST}" + git commit -m "release: v${VERSION}" +else + echo "==> --test: skipping git commit of version bump" +fi + +# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign. +# Re-run signed steps with the account's real home when the identity is missing. +signing_home() { + if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then + echo "${HOME}" + return + fi + local rh + rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -n "${rh}" && -d "${rh}" ]]; then + echo "${rh}" + else + echo "${HOME}" + fi +} + +run_signed() { + local sign_home + sign_home="$(signing_home)" + if [[ "${sign_home}" != "${HOME}" ]]; then + echo "==> Using HOME=${sign_home} so codesign can see the login keychain" + fi + HOME="${sign_home}" "$@" +} + +echo "==> Building signed Redline.app" +run_signed ./scripts/build-app.sh + +if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then + echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2 + exit 1 +fi + +echo "==> Zipping Redline.app -> ${ZIP_PATH}" +mkdir -p "${ROOT}/.build" +( + cd "${ROOT}/.build" + rm -f "${ZIP_NAME}" + ditto -c -k --keepParent Redline.app "${ZIP_NAME}" +) + +if [[ ! -s "${ZIP_PATH}" ]]; then + echo "Zip was not created at ${ZIP_PATH}" >&2 + exit 1 +fi + +echo "==> Building manual installer DMG" +run_signed ./scripts/make-dmg.sh + +if [[ ! -s "${DMG_PATH}" ]]; then + echo "DMG was not created at ${DMG_PATH}" >&2 + exit 1 +fi + +SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" +ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" +PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +echo "==> Zip SHA256: ${SHA256}" +echo " Zip bytes: ${ZIP_BYTES}" + +echo "==> Writing ${APPCAST_PATH}" +python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY' +import json +import sys + +version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:] +payload = { + "version": version, + "zipURL": zip_url, + "sha256": sha256, + "notes": notes, + "pubDate": pub_date, +} +with open(out_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") +PY + +echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/" +ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'" +rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}" +rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg" +rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}" +rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json" +ssh -o BatchMode=yes "${REMOTE_HOST}" \ + "chmod 644 \ + '${REMOTE_DIR}/${ZIP_NAME}' \ + '${REMOTE_DIR}/Redline.dmg' \ + '${REMOTE_DIR}/${DMG_NAME}' \ + '${REMOTE_DIR}/appcast.json'" + +echo "==> Verifying public appcast ${APPCAST_URL}" +APPCAST_BODY="" +ok=0 +attempt=1 +while [[ "${attempt}" -le 15 ]]; do + if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then + echo "${APPCAST_BODY}" + if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then + echo "OK: appcast contains ${VERSION}" + ok=1 + break + fi + echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2 + else + echo "appcast fetch failed (attempt ${attempt})" >&2 + fi + attempt=$((attempt + 1)) + sleep 2 +done +if [[ "${ok}" -ne 1 ]]; then + echo "Public appcast verification failed for ${APPCAST_URL}" >&2 + exit 1 +fi + +echo "==> Verifying public zip HEAD ${ZIP_URL}" +ok=0 +attempt=1 +HEAD_OUT="" +while [[ "${attempt}" -le 15 ]]; do + HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)" + echo "${HEAD_OUT}" + HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')" + CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)" + if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then + echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}" + ok=1 + break + fi + echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2 + attempt=$((attempt + 1)) + sleep 2 +done +if [[ "${ok}" -ne 1 ]]; then + echo "Public zip verification failed for ${ZIP_URL}" >&2 + exit 1 +fi + +echo +echo "Published v${VERSION}" +echo " appcast: ${APPCAST_URL}" +echo " zip: ${ZIP_URL}" +echo " sha256: ${SHA256}" +echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" +echo " dmg: ${PUBLIC_DIR}/Redline.dmg" From c01653e9aa6782edbc15eb4eb140968903da05e9 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Wed, 2 Sep 2026 09:08:42 +0400 Subject: [PATCH 21/23] feature: built-in auto-update (appcast + sha256 + staged install), version 0.2.0 --- Info.plist | 2 +- Sources/Shotdeck/AppModel.swift | 25 +++ Sources/Shotdeck/MenuBarView.swift | 9 + Sources/Shotdeck/PickerSelfTest.swift | 187 ++++++++++++++++- Sources/Shotdeck/UpdateChecker.swift | 281 ++++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 Sources/Shotdeck/UpdateChecker.swift diff --git a/Info.plist b/Info.plist index 8717b5c..e64972c 100644 --- a/Info.plist +++ b/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + 0.2.0 CFBundleVersion 1 CFBundleIconFile diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 9419436..410d55a 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -39,6 +39,8 @@ public final class AppModel { /// Currently bound capture combo (the last one Carbon accepted, or the preferred load). private(set) var captureHotkey: HotkeyPreference var hotkeyDisplayString: String { captureHotkey.displayString } + /// Staged update offered in the menu. Set only after checksum + payload validation. + public private(set) var updateAvailable: (version: String, notes: String)? let paths: AppSupportPaths let spool: SpoolStore @@ -48,6 +50,7 @@ public final class AppModel { let picker: RegionPickerController let ledger: ReturnLedger let watcher: ReturnWatcher + let updateChecker: UpdateChecker public init( paths: AppSupportPaths, @@ -83,6 +86,14 @@ public final class AppModel { self.outboxDisplayName = folders.outbox.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent self.captureHotkey = HotkeyPreference.load() + self.updateChecker = UpdateChecker() + self.updateChecker.onChecked = { [weak self] in + guard let self else { return } + self.updateAvailable = self.updateChecker.availableUpdate + if let message = self.updateChecker.statusMessage { + self.setStatus(message) + } + } } // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. @@ -154,6 +165,20 @@ public final class AppModel { if !bindCaptureHotkey(pref) { setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") } + + let skipSchedule = + ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil + || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil + || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil + if !skipSchedule { + updateChecker.startSchedule() + } + } + + /// Installs the staged update over `/Applications/Redline.app` and relaunches. + /// Does nothing unless the user clicked the menu row. + public func installUpdate() { + updateChecker.installStaged() } /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 2218cbc..1dbca7c 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -74,6 +74,15 @@ struct MenuBarView: View { private var actionsList: some View { VStack(alignment: .leading, spacing: 2) { + if let update = model.updateAvailable { + Button { + model.installUpdate() + } label: { + actionLabel("Update to \(update.version)") + .foregroundStyle(Color.accentColor) + } + } + Button { let anchor = NSApp.keyWindow?.contentView if let sender = model as? SendCapable { diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index c1eaede..2f14ffe 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -113,7 +113,10 @@ enum PickerSelfTest { fflush(stdout) runRegionPersistPhase() - exit(0) + if !startUpdateSelfTestIfRequested() { + exit(0) + } + // UPDATE-SELFTEST hops to a later main-actor turn and exits itself. } /// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through @@ -157,6 +160,179 @@ enum PickerSelfTest { fflush(stdout) } + /// Phase 3: builds a fake 99.0.0 bundle, serves a local appcast, stages via + /// `checkNow`, then `installStaged` into the env dir — never `/Applications`. + /// Returns true when the async phase was scheduled (it calls `exit` itself). + @discardableResult + private static func startUpdateSelfTestIfRequested() -> Bool { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"], + !raw.isEmpty + else { return false } + + let output = URL(fileURLWithPath: raw, isDirectory: true) + Task { @MainActor in + do { + try await runUpdateSelfTest(outputDirectory: output) + print("UPDATE-SELFTEST PASS version=99.0.0") + fflush(stdout) + exit(0) + } catch let error as UpdateSelfTestError { + updateFail(error.description) + } catch { + updateFail(String(describing: error)) + } + } + return true + } + + private static func runUpdateSelfTest(outputDirectory: URL) async throws { + let fm = FileManager.default + try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + guard let sourceApp = ownAppBundleURL() else { + throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))") + } + + let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true) + if fm.fileExists(atPath: payload.path) { + try fm.removeItem(at: payload) + } + try fm.createDirectory(at: payload, withIntermediateDirectories: true) + let fakeApp = payload.appendingPathComponent("Redline.app") + try fm.copyItem(at: sourceApp, to: fakeApp) + + let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist") + let plistData = try Data(contentsOf: plistURL) + guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else { + throw UpdateSelfTestError.detail("could not parse copied Info.plist") + } + plist["CFBundleShortVersionString"] = "99.0.0" + let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try rewritten.write(to: plistURL) + + let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip") + if fm.fileExists(atPath: zipURL.path) { + try fm.removeItem(at: zipURL) + } + try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) + + let zipData = try Data(contentsOf: zipURL) + let hex = UpdateChecker.sha256Hex(zipData) + + let appcastURL = outputDirectory.appendingPathComponent("appcast.json") + let appcast: [String: String] = [ + "version": "99.0.0", + "zipURL": zipURL.absoluteString, + "sha256": hex, + "notes": "UPDATE-SELFTEST", + ] + let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) + try appcastData.write(to: appcastURL) + + let defaults = UserDefaults.standard + let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) + defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) + defer { + if let previous { + defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey) + } else { + defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) + } + } + + let (model, isolatedRoot) = try makeIsolatedUpdateModel() + defer { try? fm.removeItem(at: isolatedRoot) } + + await model.updateChecker.checkNow() + + guard model.updateAvailable?.version == "99.0.0" else { + throw UpdateSelfTestError.detail( + "updateAvailable=\(model.updateAvailable?.version ?? "nil")" + ) + } + guard let staged = model.updateChecker.stagedAppURL else { + throw UpdateSelfTestError.detail("staged payload missing") + } + guard staged.lastPathComponent == "Redline.app" else { + throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)") + } + let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck") + guard fm.fileExists(atPath: stagedExe.path) else { + throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing") + } + + let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true) + if fm.fileExists(atPath: targetRoot.path) { + try fm.removeItem(at: targetRoot) + } + let target = targetRoot.appendingPathComponent("Redline.app") + model.updateChecker.installStaged(to: target) + + let installedPlist = target.appendingPathComponent("Contents/Info.plist") + guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any], + let installedVersion = installed["CFBundleShortVersionString"] as? String + else { + throw UpdateSelfTestError.detail("installed Info.plist unreadable") + } + guard installedVersion == "99.0.0" else { + throw UpdateSelfTestError.detail("installed version \(installedVersion)") + } + } + + private static func ownAppBundleURL() -> URL? { + let bundle = Bundle.main.bundleURL + if bundle.pathExtension == "app" { return bundle } + let up3 = bundle + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + if up3.pathExtension == "app" { return up3 } + return nil + } + + private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + return (model, root) + } + + private static func runDitto(arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = arguments + let err = Pipe() + process.standardError = err + process.standardOutput = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw UpdateSelfTestError.detail("ditto failed: \(message)") + } + } + + private static func updateFail(_ detail: String) -> Never { + print("UPDATE-SELFTEST FAIL \(detail)") + fflush(stdout) + exit(1) + } + private static func interpolate(_ step: Int) -> NSPoint { let t = CGFloat(step) / CGFloat(dragSteps) return NSPoint( @@ -213,3 +389,12 @@ enum PickerSelfTest { exit(1) } } + +private enum UpdateSelfTestError: Error, CustomStringConvertible { + case detail(String) + var description: String { + switch self { + case .detail(let s): return s + } + } +} diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift new file mode 100644 index 0000000..207f51a --- /dev/null +++ b/Sources/Shotdeck/UpdateChecker.swift @@ -0,0 +1,281 @@ +import AppKit +import CryptoKit +import Foundation + +/// Built-in updater. Checks an appcast, stages a verified payload, and installs +/// only when the user clicks the menu row — never automatically. +@MainActor +final class UpdateChecker { + static let appcastURLDefaultsKey = "ai.flowmaster.shotdeck.appcastURL" + static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")! + static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app") + + private(set) var availableUpdate: (version: String, notes: String)? + private(set) var stagedAppURL: URL? + private(set) var statusMessage: String? + + var onChecked: (() -> Void)? + + private let urlSession: URLSession + private var repeatingTimer: Timer? + private var firstCheckTask: Task? + private var isChecking = false + private var stagingDirectory: URL? + + init() { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 15 + config.httpCookieAcceptPolicy = .never + config.httpShouldSetCookies = false + config.httpCookieStorage = nil + config.urlCache = nil + urlSession = URLSession(configuration: config) + } + + /// First check 10 seconds after start, then every 6 hours. Stages only — never installs. + func startSchedule() { + firstCheckTask?.cancel() + firstCheckTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + await self?.checkNow() + } + repeatingTimer?.invalidate() + let timer = Timer(timeInterval: 6 * 60 * 60, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.checkNow() + } + } + RunLoop.main.add(timer, forMode: .common) + repeatingTimer = timer + } + + func checkNow() async { + guard !isChecking else { return } + isChecking = true + defer { isChecking = false } + + let appcast: Appcast + do { + appcast = try await fetchAppcast() + } catch { + statusMessage = "Could not check for updates." + onChecked?() + return + } + + guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { + clearOffer() + statusMessage = nil + onChecked?() + return + } + + do { + try await downloadAndStage(appcast) + availableUpdate = (version: appcast.version, notes: appcast.notes ?? "") + statusMessage = nil + } catch UpdateCheckError.checksumMismatch { + discardStaging() + availableUpdate = nil + statusMessage = "Update file failed the checksum — not installed." + } catch { + discardStaging() + availableUpdate = nil + statusMessage = "The update could not be prepared." + } + onChecked?() + } + + /// Copies the staged app onto `target` with ditto (in place; never deletes the old app). + /// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test + /// can assert the installed Info.plist without killing the process. + func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) { + guard let staged = stagedAppURL else { + statusMessage = "No update is staged." + onChecked?() + return + } + + do { + try FileManager.default.createDirectory( + at: target.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path]) + } catch { + statusMessage = "The update could not be installed." + onChecked?() + return + } + + let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil + if isSelfTest { return } + + do { + try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path]) + } catch { + statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." + onChecked?() + return + } + NSApp.terminate(nil) + } + + static func resolvedAppcastURL() -> URL { + if let env = ProcessInfo.processInfo.environment["REDLINE_APPCAST_URL"], + !env.isEmpty, + let url = URL(string: env) + { + return url + } + if let stored = UserDefaults.standard.string(forKey: appcastURLDefaultsKey), + !stored.isEmpty, + let url = URL(string: stored) + { + return url + } + return defaultAppcastURL + } + + static func currentVersion() -> String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + } + + static func isNewer(_ candidate: String, than current: String) -> Bool { + let a = semverParts(candidate) + let b = semverParts(current) + for i in 0..<3 { + if a[i] != b[i] { return a[i] > b[i] } + } + return false + } + + static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Private + + private struct Appcast: Decodable { + var version: String + var zipURL: URL + var sha256: String + var notes: String? + } + + private enum UpdateCheckError: Error { + case checksumMismatch + case invalidPayload + case httpStatus(Int) + case processFailed(String) + } + + private func fetchAppcast() async throws -> Appcast { + let data = try await fetchData(from: Self.resolvedAppcastURL()) + return try JSONDecoder().decode(Appcast.self, from: data) + } + + private func fetchData(from url: URL) async throws -> Data { + if url.isFileURL { + return try Data(contentsOf: url) + } + let (data, response) = try await urlSession.data(from: url) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw UpdateCheckError.httpStatus(http.statusCode) + } + return data + } + + private func downloadAndStage(_ appcast: Appcast) async throws { + let zipData = try await fetchData(from: appcast.zipURL) + let expected = appcast.sha256.trimmingCharacters(in: .whitespacesAndNewlines) + let actual = Self.sha256Hex(zipData) + guard actual.caseInsensitiveCompare(expected) == .orderedSame else { + throw UpdateCheckError.checksumMismatch + } + + discardStaging() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-update-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + stagingDirectory = root + + let zipURL = root.appendingPathComponent("update.zip") + try zipData.write(to: zipURL) + + let extracted = root.appendingPathComponent("extracted", isDirectory: true) + try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true) + try Self.runProcess( + executable: "/usr/bin/ditto", + arguments: ["-x", "-k", zipURL.path, extracted.path] + ) + + guard let appURL = Self.findRedlineApp(in: extracted) else { + throw UpdateCheckError.invalidPayload + } + let executable = appURL.appendingPathComponent("Contents/MacOS/Shotdeck") + guard FileManager.default.fileExists(atPath: executable.path) else { + throw UpdateCheckError.invalidPayload + } + stagedAppURL = appURL + } + + private func clearOffer() { + availableUpdate = nil + discardStaging() + } + + private func discardStaging() { + if let stagingDirectory { + try? FileManager.default.removeItem(at: stagingDirectory) + } + stagingDirectory = nil + stagedAppURL = nil + } + + private static func findRedlineApp(in directory: URL) -> URL? { + let fm = FileManager.default + let direct = directory.appendingPathComponent("Redline.app") + if fm.fileExists(atPath: direct.path) { return direct } + + guard let enumerator = fm.enumerator( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return nil } + + while let item = enumerator.nextObject() as? URL { + if item.lastPathComponent == "Redline.app" { + return item + } + if item.pathExtension == "app" { + enumerator.skipDescendants() + } + } + return nil + } + + private static func semverParts(_ string: String) -> [Int] { + let core = string.split(separator: "-").first.map(String.init) ?? string + var parts = core.split(separator: ".").prefix(3).map { Int($0) ?? 0 } + while parts.count < 3 { parts.append(0) } + return parts + } + + private static func runProcess(executable: String, arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let err = Pipe() + process.standardError = err + process.standardOutput = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw UpdateCheckError.processFailed("\(executable) failed: \(message)") + } + } +} From 5e61cd735c7627f1bfc051e6ef6e219ce3f017d2 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Wed, 2 Sep 2026 09:12:08 +0400 Subject: [PATCH 22/23] fix: archive only after AirDrop completes; PDF named by send time; Reveal last PDF --- Sources/Shotdeck/AppModel.swift | 38 +++++ Sources/Shotdeck/MenuBarView.swift | 7 + Sources/Shotdeck/PickerSelfTest.swift | 142 +++++++++++++++++- Sources/Shotdeck/SendController.swift | 133 ++++++++++------ Sources/Shotdeck/Sharing.swift | 43 +++++- Sources/ShotdeckCore/PDF/PDFComposer.swift | 4 +- .../ShotdeckCoreTests/PDFComposerTests.swift | 11 ++ 7 files changed, 327 insertions(+), 51 deletions(-) diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 9419436..3f40ba1 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -36,6 +36,8 @@ public final class AppModel { public private(set) var outboxURL: URL /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. public private(set) var watchFolderURL: URL + /// Absolute URL of the PDF composed this run, if any. Used by "Reveal last PDF". + public private(set) var lastComposedPDFURL: URL? /// Currently bound capture combo (the last one Carbon accepted, or the preferred load). private(set) var captureHotkey: HotkeyPreference var hotkeyDisplayString: String { captureHotkey.displayString } @@ -105,6 +107,42 @@ public final class AppModel { watchFolderURL = watch setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) } + func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } + + /// True when a last-composed PDF path is known this run, or the newest + /// `Redline-*.pdf` in the outbox exists on disk. + var canRevealLastPDF: Bool { revealablePDFURL() != nil } + + public func revealLastPDF() { + guard let url = revealablePDFURL() else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + func revealablePDFURL() -> URL? { + if let last = lastComposedPDFURL, FileManager.default.fileExists(atPath: last.path) { + return last + } + return newestOutboxRedlinePDF() + } + + func newestOutboxRedlinePDF() -> URL? { + let fm = FileManager.default + let items = (try? fm.contentsOfDirectory( + at: outboxURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + let matches = items.filter { + $0.lastPathComponent.hasPrefix("Redline-") && $0.pathExtension.lowercased() == "pdf" + } + return matches.max { a, b in + let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + return da < db + } + } public var iconState: MenuIconState { if !screenRecordingGranted { return .recordingMissing } diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 2218cbc..83c054e 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -86,6 +86,13 @@ struct MenuBarView: View { } .disabled(model.session.isEmpty || model.isSending) + Button { + model.revealLastPDF() + } label: { + actionLabel("Reveal last PDF") + } + .disabled(!model.canRevealLastPDF) + Button { Task { await model.captureNow() } } label: { diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index c1eaede..27e49c6 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -1,6 +1,8 @@ import AppKit +import CoreGraphics import Darwin import Foundation +import ImageIO import ShotdeckCore /// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`. @@ -113,7 +115,9 @@ enum PickerSelfTest { fflush(stdout) runRegionPersistPhase() - exit(0) + // Hop off this MainActor job so the SEND-TRUTH Task can run; do not + // exit(0) here — runSendTruthPhase exits when it finishes. + runSendTruthPhase() } /// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through @@ -157,6 +161,142 @@ enum PickerSelfTest { fflush(stdout) } + /// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet. + /// Fail path must leave the session open in the temp spool; success path archives + /// and mints a fresh empty session. Scheduled as a new MainActor job because this + /// function is called from inside `execute()` — a nested run-loop wait would never + /// let the Task start. + private static func runSendTruthPhase() { + Task { @MainActor in + do { + try await executeSendTruth() + print("SEND-TRUTH PASS") + fflush(stdout) + exit(0) + } catch { + print("SEND-TRUTH FAIL \(error)") + fflush(stdout) + exit(1) + } + } + } + + private static func executeSendTruth() async throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder) + + let png = try makeTinyPNGData() + _ = try await model.spool.append( + pngData: png, + pixelWidth: 64, + pixelHeight: 48, + scale: 1, + capturedAt: Date() + ) + model.replaceSession(try await model.spool.currentSession()) + let openID = model.session.id + guard !model.session.isEmpty else { + sendTruthFail("seeded session was empty") + } + + let pending = try await model.composePDFForSend() + guard fm.fileExists(atPath: pending.fileURL.path) else { + sendTruthFail("PDF was not written") + } + + model.handleDidFailToShareItems(fileName: pending.fileName) + let still = try await model.spool.currentSession() + guard still.id == openID, !still.isEmpty, still.state == .open else { + sendTruthFail("fail path archived or replaced the session") + } + let spoolDir = paths.sessionDirectory(openID) + guard fm.fileExists(atPath: spoolDir.path) else { + sendTruthFail("fail path: session missing from temp spool") + } + guard let status = model.statusLine, status.contains("nothing was sent") else { + sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")") + } + + await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount) + let fresh = try await model.spool.currentSession() + guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else { + sendTruthFail("success path did not mint a fresh empty session") + } + let archived = try await model.spool.archivedSessions() + guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else { + sendTruthFail("success path did not archive the session") + } + let archiveDir = paths.archiveDirectory(openID) + guard fm.fileExists(atPath: archiveDir.path) else { + sendTruthFail("success path: archive dir missing") + } + guard !fm.fileExists(atPath: spoolDir.path) else { + sendTruthFail("success path: session still in spool") + } + } + + private static func makeTinyPNGData() throws -> Data { + let width = 64 + let height = 48 + 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 { + sendTruthFail("could not create PNG context") + } + context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + sendTruthFail("could not make CGImage") + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + buffer, + "public.png" as CFString, + 1, + nil + ) else { + sendTruthFail("could not create PNG destination") + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + sendTruthFail("could not finalize PNG") + } + return buffer as Data + } + + private static func sendTruthFail(_ reason: String) -> Never { + print("SEND-TRUTH FAIL \(reason)") + fflush(stdout) + exit(1) + } + private static func interpolate(_ step: Int) -> NSPoint { let t = CGFloat(step) / CGFloat(dragSteps) return NSPoint( diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index 59b7a87..d4d818a 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -3,12 +3,59 @@ import Darwin import Foundation import ShotdeckCore +/// Result of composing a send PDF. Kept so the self-test can drive the share +/// outcome without presenting a real AirDrop sheet. +struct ComposedSend: Sendable { + let fileName: String + let fileURL: URL + let pageCount: Int +} + extension AppModel: SendCapable { public func send(anchor: NSView?) async { guard !session.isEmpty, !isSending else { return } setSending(true) - defer { setSending(false) } + let pending: ComposedSend + do { + pending = try await composePDFForSend() + } catch { + // Never unlink the published PDF, and never unlink the temp file either: + // a rename failure would leave the complete document at the temp name. + setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + setSending(false) + return + } + + guard let anchor else { + handleDidFailToShareItems(fileName: pending.fileName) + setSending(false) + return + } + + do { + try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in + guard let self else { return } + if success { + await self.handleDidShareItems( + fileName: pending.fileName, + pageCount: pending.pageCount + ) + } else { + self.handleDidFailToShareItems(fileName: pending.fileName) + } + self.setSending(false) + } + } catch { + // canPerform false, no service, or no visible window: same as cancel. + handleDidFailToShareItems(fileName: pending.fileName) + setSending(false) + } + } + + /// Writes the PDF to the outbox and records its path. Does not archive the session + /// and does not present AirDrop — that happens only after the share completes. + func composePDFForSend() async throws -> ComposedSend { let workingSession = session let composer = self.composer // Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect. @@ -20,52 +67,54 @@ extension AppModel: SendCapable { let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf") let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))" - do { - // D-13: build off the main actor. Only Sendable values cross into the - // detached task — never `anchor` (NSView is not Sendable). - try await Task.detached(priority: .userInitiated) { - _ = try composer.compose( - session: workingSession, - imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, - title: title, - to: tempURL + // D-13: build off the main actor. Only Sendable values cross into the + // detached task — never `anchor` (NSView is not Sendable). + try await Task.detached(priority: .userInitiated) { + _ = try composer.compose( + session: workingSession, + imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, + title: title, + to: tempURL + ) + // POSIX rename onto `finalURL` replaces any same-name file in one + // directory operation; there is never a window where the PDF is gone. + if Darwin.rename(tempURL.path, finalURL.path) != 0 { + throw ShotdeckError.pdfCompositionFailed( + reason: "could not publish the PDF: \(String(cString: strerror(errno)))" ) - // POSIX rename onto `finalURL` replaces any same-name file in one - // directory operation; there is never a window where the PDF is gone. - if Darwin.rename(tempURL.path, finalURL.path) != 0 { - throw ShotdeckError.pdfCompositionFailed( - reason: "could not publish the PDF: \(String(cString: strerror(errno)))" - ) - } - try AtomicFile.fsyncDirectory(at: outboxDir) - }.value - - // File exists on disk now — archive only after that (D-13). A later AirDrop - // failure never deletes this file. - guard FileManager.default.fileExists(atPath: finalURL.path) else { - throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") } + try AtomicFile.fsyncDirectory(at: outboxDir) + }.value + + guard FileManager.default.fileExists(atPath: finalURL.path) else { + throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") + } + rememberLastComposedPDF(finalURL) + return ComposedSend( + fileName: fileName, + fileURL: finalURL, + pageCount: workingSession.captures.count + ) + } + + /// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam. + func handleDidShareItems(fileName: String, pageCount: Int) async { + guard !session.isEmpty else { return } + do { _ = try await spool.archiveCurrent(pdfFileName: fileName) replaceSession(try await spool.currentSession()) - - let pageWord = workingSession.captures.count == 1 ? "page" : "pages" - setStatus("Sent — \(workingSession.captures.count) \(pageWord).") - - guard let anchor else { - setStatus("PDF saved to \(outboxDisplayName). Open the panel to AirDrop it.") - return - } - do { - try Sharing.airDrop(fileURL: finalURL, from: anchor) - } catch { - setStatus( - "AirDrop is not available right now — the PDF is on your \(outboxDisplayName)." - ) - } + let pageWord = pageCount == 1 ? "page" : "pages" + setStatus("Sent — \(pageCount) \(pageWord).") } catch { - // Never unlink the published PDF, and never unlink `tempURL` either: - // a rename failure would leave the complete document at the temp name. - setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.") } } + + /// `NSSharingServiceDelegate.sharingService(_:didFailToShareItems:error:)` seam, + /// also used when `canPerform` is false or the user cancels. Does not archive. + func handleDidFailToShareItems(fileName: String) { + setStatus( + "AirDrop didn't complete — nothing was sent. Your captures are still here; the PDF is on your \(outboxDisplayName) as \(fileName)." + ) + } } diff --git a/Sources/Shotdeck/Sharing.swift b/Sources/Shotdeck/Sharing.swift index 5b9741a..7d26be0 100644 --- a/Sources/Shotdeck/Sharing.swift +++ b/Sources/Shotdeck/Sharing.swift @@ -7,7 +7,14 @@ enum Sharing { /// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created, /// `canPerform` is false, or `view` is not in a visible window (a detached view /// never produces an on-screen sheet). - static func airDrop(fileURL: URL, from view: NSView) throws { + /// + /// `onFinished` is invoked on the main actor when the sheet completes: `true` for + /// `didShareItems`, `false` for `didFailToShareItems` (including user cancel). + static func airDrop( + fileURL: URL, + from view: NSView, + onFinished: @escaping @MainActor @Sendable (Bool) async -> Void + ) throws { guard let service = NSSharingService(named: .sendViaAirDrop), service.canPerform(withItems: [fileURL]) else { throw ShotdeckError.airDropUnavailable @@ -21,7 +28,12 @@ enum Sharing { window.makeKeyAndOrderFront(nil) service.subject = fileURL.lastPathComponent - let session = AirDropSession(service: service, window: window, view: view) + let session = AirDropSession( + service: service, + window: window, + view: view, + onFinished: onFinished + ) AirDropSession.keepAlive(session) service.delegate = session service.perform(withItems: [fileURL]) @@ -29,7 +41,9 @@ enum Sharing { } /// Retains the sharing service for the life of the picker and supplies the real -/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak. +/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak, so +/// `live` is the strong reference that keeps this object alive until the sheet +/// reports success or failure (including cancel). @MainActor private final class AirDropSession: NSObject, NSSharingServiceDelegate { static var live: [AirDropSession] = [] @@ -37,11 +51,19 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { let service: NSSharingService let window: NSWindow let view: NSView + let onFinished: @MainActor @Sendable (Bool) async -> Void + private var reported = false - init(service: NSSharingService, window: NSWindow, view: NSView) { + init( + service: NSSharingService, + window: NSWindow, + view: NSView, + onFinished: @escaping @MainActor @Sendable (Bool) async -> Void + ) { self.service = service self.window = window self.view = view + self.onFinished = onFinished } static func keepAlive(_ session: AirDropSession) { @@ -52,6 +74,15 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { Self.live.removeAll { $0 === self } } + private func report(_ success: Bool) { + guard !reported else { return } + reported = true + Task { @MainActor in + await self.onFinished(success) + self.drop() + } + } + func sharingService( _ sharingService: NSSharingService, sourceWindowForShareItems items: [Any], @@ -70,7 +101,7 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { } func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { - drop() + report(true) } func sharingService( @@ -78,6 +109,6 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { didFailToShareItems items: [Any], error: any Error ) { - drop() + report(false) } } diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift index 0a9729c..5fbfbc8 100644 --- a/Sources/ShotdeckCore/PDF/PDFComposer.swift +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -70,8 +70,8 @@ public struct PDFComposer: Sendable { } } - public static func fileName(for session: CaptureSession) -> String { - "Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf" + public static func fileName(for _: CaptureSession) -> String { + "Redline-\(DubaiTime.fileStamp(Date())).pdf" } private static func writePDF( diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift index ef7d3d7..0b3987c 100644 --- a/Tests/ShotdeckCoreTests/PDFComposerTests.swift +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -203,6 +203,17 @@ private func pixelWindow( return (pixelX0, pixelY0, pixelX1, pixelY1) } +@Test("fileName uses compose time, not session.createdAt, and matches Redline-yyyyMMdd-HHmmss.pdf") +func fileNameUsesComposeTimeNotSessionCreatedAt() { + let old = Date(timeIntervalSince1970: 1_600_000_000) // 2020-09-13 + let session = makeSession(captures: [], createdAt: old) + let name = PDFComposer.fileName(for: session) + #expect(name.wholeMatch(of: /^Redline-\d{8}-\d{6}\.pdf$/) != nil) + #expect(!name.contains(DubaiTime.fileStamp(old))) + let today = String(DubaiTime.fileStamp(Date()).prefix(8)) + #expect(name.contains(today)) +} + @Test("Three-page basic compose") func threePageBasicCompose() throws { let directory = try makeScratchDirectory() From 8338216f235a07969ec005edf4d911a944745b35 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Wed, 2 Sep 2026 09:18:21 +0400 Subject: [PATCH 23/23] release: v0.2.0 --- Info.plist | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/Info.plist b/Info.plist index e64972c..2755b1e 100644 --- a/Info.plist +++ b/Info.plist @@ -2,28 +2,24 @@ - + CFBundleExecutable + Shotdeck + CFBundleIconFile + AppIcon CFBundleIdentifier ai.flowmaster.shotdeck CFBundleName Redline - CFBundleExecutable - Shotdeck CFBundlePackageType APPL CFBundleShortVersionString 0.2.0 CFBundleVersion - 1 - CFBundleIconFile - AppIcon - LSUIElement - + 2 LSMinimumSystemVersion 14.0 + LSUIElement + NSHumanReadableCopyright Copyright © 2026 Flowmaster FZC LLC. All rights reserved.