Compare commits

...
Author SHA1 Message Date
kua-agent 7284568489 release tooling: publish-update.sh — bump, build, sign, zip, appcast, upload, verify 2026-09-02 09:02:45 +04:00
kua-agent 8d66e49e07 Merge pull request 'feature: user-selectable capture hotkey (recorder in Settings)' (#18) from feat/hotkey-config-20260901 into feat/shotdeck-20260830 2026-09-01 18:34:14 +00:00
kua-agent e253a966ab Merge pull request 'icon: programmatic Redline app icon' (#17) from feat/app-icon-20260901 into feat/shotdeck-20260830 2026-09-01 18:34:02 +00:00
kua-agent fa3e7b0a4a feature: user-selectable capture hotkey with recorder in Settings 2026-09-01 22:33:13 +04:00
kua-agent c52e68a9d5 icon: programmatic Redline app icon (icns + generator script) 2026-09-01 22:33:10 +04:00
kua-agent 3c55be174a Merge pull request 'rename: product name → Redline (identity preserved, legacy PDFs recognized)' (#16) from feat/rename-redline-20260901 into feat/shotdeck-20260830 2026-09-01 18:25:47 +00:00
kua-agent d05bd735b5 rename: user-facing product name Shotdeck -> Redline; legacy PDFs still recognized 2026-09-01 22:25:15 +04:00
kua-agent 74606e5046 Merge pull request 'fix: load persisted capture region at launch' (#15) from fix/region-persist-20260901 into feat/shotdeck-20260830 2026-09-01 18:20:54 +00:00
kua-agentandClaude Fable 5 517a4e4fdc 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 <noreply@anthropic.com>
2026-09-01 22:20:40 +04:00
kua-agent 6c0b6e3068 Merge pull request 'fix: picker first-mouse acceptance + event-based drag coords + in-process selftest' (#14) from fix/picker-first-mouse-20260901 into feat/shotdeck-20260830 2026-09-01 17:47:09 +00:00
kua-agentandClaude Fable 5 f2088bbed8 fix: picker first-mouse acceptance + event-based drag coords; in-process picker selftest
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 <noreply@anthropic.com>
2026-09-01 21:46:54 +04:00
kua-agent 31013802d7 Merge pull request 'fix: settings form alignment — grouped form, aligned labels' (#13) from fix/settings-form-alignment-20260901 into feat/shotdeck-20260830 2026-09-01 09:13:09 +00:00
kua-agent e1de0ad519 fix: settings form alignment — grouped form, aligned labels 2026-09-01 13:11:25 +04:00
kua-agent bb6c1cd0b4 Merge pull request 'test harness: offscreen panel snapshots (SHOTDECK_SNAPSHOT_DIR)' (#12) from feat/panel-snapshot-20260901 into feat/shotdeck-20260830 2026-09-01 09:06:36 +00:00
kua-agent 9278f703e0 Merge pull request 'installer: make-dmg.sh — signed DMG with /Applications symlink' (#11) from feat/dmg-installer-20260901 into feat/shotdeck-20260830 2026-09-01 09:06:28 +00:00
kua-agent 3b269102a0 test harness: offscreen panel snapshots via SHOTDECK_SNAPSHOT_DIR 2026-09-01 13:01:45 +04:00
kua-agent c54471ee2e installer: make-dmg.sh — signed app DMG with /Applications symlink 2026-09-01 12:55:22 +04:00
kua-agent 79fa9ee1da Merge pull request 'WP-4c: returns list + settings' (#10) from wp4c/returns-settings-20260831 into feat/shotdeck-20260830 2026-08-31 12:18:15 +00:00
kua-agent 672f8d495f Merge pull request 'WP-4b: Send pipeline + AirDrop sheet' (#9) from wp4b/send-airdrop-20260831 into feat/shotdeck-20260830 2026-08-31 12:18:09 +00:00
kua-agent 9201e74bfc WP-4b: Send pipeline + AirDrop sheet per SPEC-A2b 2026-08-31 16:12:58 +04:00
kua-agent 66657ac532 WP-4c: returns list + settings per SPEC-A2b
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).
2026-08-31 16:07:41 +04:00
kua-agent 9989333c24 Merge pull request 'WP-4a: menu-bar shell, AppModel, session strip' (#8) from wp4a/shell-20260831 into feat/shotdeck-20260830 2026-08-31 12:04:05 +00:00
kua-agent fdac2f2f47 WP-4a: menu-bar shell, AppModel, session strip per SPEC-A2b + integration contracts 2026-08-31 15:59:37 +04:00
kua-agent 8dc97d02f2 Merge pull request 'WP-5b: FSEvents ReturnWatcher' (#7) from wp5b/return-watcher-20260831 into feat/shotdeck-20260830 2026-08-31 11:33:58 +00:00
kua-agent 23b53e8cd2 Merge pull request 'WP-3b: ScreenCaptureKit capturer + region picker overlay' (#6) from wp3b/capture-picker-20260831 into feat/shotdeck-20260830 2026-08-31 11:29:33 +00:00
kua-agent ce63d0295f Merge pull request 'WP-1: durable SpoolStore with crash reconciliation and removed/' (#4) from wp1/spool-store-20260831 into feat/shotdeck-20260830 2026-08-31 11:29:26 +00:00
kua-agent 49b1a9ea83 WP-5b: FSEvents ReturnWatcher per SPEC-A1c with review corrections 2026-08-31 15:23:05 +04:00
kua-agent 3e0db398ca Merge pull request 'WP-5a: AnnotationInspector + ReturnLedger' (#5) from wp5a/inspector-ledger-20260831 into feat/shotdeck-20260830 2026-08-31 11:16:52 +00:00
kua-agent 15021e9dda WP-3b: ScreenCaptureKit capturer + region picker overlay per SPEC-A2a 2026-08-31 15:08:05 +04:00
kua-agent 7379e4fbd7 Merge pull request 'WP-3a: CaptureRegion geometry + Carbon HotkeyCenter' (#1) from wp3a/region-hotkey-20260831 into feat/shotdeck-20260830 2026-08-31 11:01:09 +00:00
kua-agent 96e31d17e0 Merge pull request 'WP-2: PDF composer + PageLayout (two-pass, atomic write, zero annotations)' (#3) from wp2/pdf-composer-20260831 into feat/shotdeck-20260830 2026-08-31 11:01:03 +00:00
kua-agent 3dfb703834 WP-5a: AnnotationInspector + ReturnLedger per SPEC-A1c with review corrections 2026-08-31 14:58:34 +04:00
kua-agent 4c4d3d6633 WP-1: durable SpoolStore with crash reconciliation and removed/ per SPEC-A1a 2026-08-31 14:58:03 +04:00
kua-agent cee4c93dd5 WP-0b: AtomicFile, Log, FolderSettings foundation (merge) 2026-08-31 10:48:48 +00:00
kua-agent 1b5816aa38 WP-2: PDF composer + PageLayout per SPEC-A1b (two-pass, atomic write, zero annotations) 2026-08-31 14:42:00 +04:00
kua-agent 31721b431c WP-0b: AtomicFile, Log, FolderSettings foundation per SPEC-A1a; drop stale hotkey string 2026-08-31 14:40:21 +04:00
38 changed files with 6613 additions and 10 deletions
+7 -1
View File
@@ -2,10 +2,14 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Identity invariants: CFBundleIdentifier stays ai.flowmaster.shotdeck and
CFBundleExecutable stays Shotdeck. Changing either one invalidates the
user's existing Screen Recording grant. CFBundleName is the user-facing
product name only. -->
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>ai.flowmaster.shotdeck</string> <string>ai.flowmaster.shotdeck</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Shotdeck</string> <string>Redline</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>Shotdeck</string> <string>Shotdeck</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
@@ -14,6 +18,8 @@
<string>0.1.0</string> <string>0.1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
+2 -2
View File
@@ -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. 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 ```bash
swift build && swift test swift build && swift test
./scripts/build-app.sh # signed .app for daily use ./scripts/build-app.sh # signed .app for daily use
open .build/Shotdeck.app open .build/Redline.app
``` ```
Binary file not shown.
+299
View File
@@ -0,0 +1,299 @@
import AppKit
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
/// 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
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 = Self.loadPersistedRegion()
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
self.captureHotkey = HotkeyPreference.load()
}
// 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 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: pref.keyCode,
modifiers: pref.modifiers
) { [weak self] in
Task { await self?.captureNow() }
}
}
public func captureNow() async {
guard !isCapturing else { return }
setCapturing(true)
defer { setCapturing(false) }
var target = region
if target == nil {
target = await withCheckedContinuation { (cont: CheckedContinuation<CaptureRegion?, Never>) in
picker.pick { picked in cont.resume(returning: picked) }
}
guard let picked = target else {
setStatus("No region selected.")
return
}
persistRegion(picked)
}
guard let region = target else { return }
do {
let image = try await capturer.capture(region)
let capture = try await spool.append(
pngData: image.pngData,
pixelWidth: image.pixelWidth,
pixelHeight: image.pixelHeight,
scale: image.scale,
capturedAt: Date()
)
replaceSession(try await spool.currentSession())
setStatus("Captured page \(capture.sequence).")
} catch {
screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
setStatus((error as? ShotdeckError)?.errorDescription ?? "The screenshot could not be taken.")
}
}
public func rePickRegion() async {
let picked = await withCheckedContinuation { (cont: CheckedContinuation<CaptureRegion?, Never>) in
picker.pick { cont.resume(returning: $0) }
}
guard let picked else { return }
persistRegion(picked)
setStatus("Region set: \(Int(picked.rect.width)) × \(Int(picked.rect.height)).")
}
public func removeCapture(id: UUID) async {
do {
// D-11: SpoolStore.remove MOVES the PNG to <session>/removed/; it is never unlinked.
replaceSession(try await spool.remove(captureID: id))
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not remove that capture.")
}
}
public func copyCommentedLinks() async {
do {
let text = try await ledger.clipboardText()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
let count = commentedReturns.count
setStatus("Copied \(count) link\(count == 1 ? "" : "s").")
} catch {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Nothing to copy.")
}
}
public func openSpoolFolder() {
NSWorkspace.shared.open(paths.spool)
}
public func openScreenRecordingSettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else {
setStatus("Could not open System Settings.")
return
}
NSWorkspace.shared.open(url)
}
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) {
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
}
}
+151
View File
@@ -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)"
}
}
}
+180
View File
@@ -0,0 +1,180 @@
import AppKit
import SwiftUI
import ShotdeckCore
struct MenuBarView: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(alignment: .leading, spacing: 8) {
statusRow
SessionStrip()
Divider()
actionsList
if !model.allReturns.isEmpty {
Divider()
returnsBlock
}
}
.padding(10)
.frame(width: 320, alignment: .leading)
.controlSize(.small)
}
@ViewBuilder
private var statusRow: some View {
if !model.screenRecordingGranted {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 4) {
Text(
ShotdeckError.screenRecordingNotGranted.errorDescription
?? "Screen Recording is turned off."
)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
Button("Open Screen Recording settings") {
model.openScreenRecordingSettings()
}
}
}
} else {
Text(model.statusLine ?? defaultStatusText)
.font(.caption)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var defaultStatusText: String {
guard let region = model.region else {
return "No region yet — press \(model.hotkeyDisplayString) 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: model.hotkeyDisplayString)
}
.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 Redline")
}
}
.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 }
}
}
+295
View File
@@ -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<AppModel, Bool> = unsafeBitCast(
\AppModel.screenRecordingGranted, to: ReferenceWritableKeyPath<AppModel, Bool>.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)"
}
}
}
+215
View File
@@ -0,0 +1,215 @@
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)
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(
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)
}
}
@@ -0,0 +1,203 @@
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 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: UInt16 = (type == .keyDown) ? event.keyCode : 0
let locationInWindow = event.locationInWindow
let windowNumber = event.windowNumber
let consume = MainActor.assumeIsolated {
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, location: NSPoint?) -> Bool {
switch type {
case .keyDown:
if keyCode == 53 { // kVK_Escape
finish(region: nil)
return true
}
return false
case .leftMouseDown:
handleMouseDown(location: location)
return false
case .leftMouseDragged:
handleMouseDragged(location: location)
return false
case .leftMouseUp:
handleMouseUp(location: location)
return false
default:
return false
}
}
private func handleMouseDown(location: NSPoint?) {
let point = location ?? 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(location: NSPoint?) {
guard dragStart != nil, startScreen != nil else { return }
applyLiveSelection(current: location)
}
private func handleMouseUp(location: NSPoint?) {
guard let startScreen else {
dragStart = nil
return
}
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
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(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)
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(current: NSPoint?) -> CGRect? {
guard let dragStart, let startScreen else { return nil }
let current = 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)
}
/// 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 }
}
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)
}
}
+113
View File
@@ -0,0 +1,113 @@
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]
acceptsMouseMovedEvents = true
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 }
// 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)
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
)
}
}
+53
View File
@@ -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))
}
}
+71
View File
@@ -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 = "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
)
// 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.")
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import AppKit
import SwiftUI
import ShotdeckCore
struct SessionStrip: View {
@Environment(AppModel.self) private var model
var body: some View {
if model.session.captures.isEmpty {
Text("Nothing captured yet.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 6) {
ForEach(model.session.captures) { capture in
SessionThumb(capture: capture)
}
}
}
.frame(height: 64)
}
}
}
private struct SessionThumb: View {
@Environment(AppModel.self) private var model
let capture: Capture
@State private var hovering = false
var body: some View {
ZStack(alignment: .topLeading) {
thumbnail
Text("\(capture.sequence)")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(.black.opacity(0.65))
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
.padding(3)
if hovering {
VStack {
Spacer()
Button("Remove") {
Task { await model.removeCapture(id: capture.id) }
}
.font(.system(size: 10, weight: .semibold))
.buttonStyle(.plain)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 3)
.background(.black.opacity(0.7))
}
}
}
.frame(height: 64)
.clipped()
.onHover { hovering = $0 }
.help(DubaiTime.stamp(capture.capturedAt))
}
@ViewBuilder
private var thumbnail: some View {
let url = model.paths.sessionDirectory(model.session.id).appendingPathComponent(capture.fileName)
if let image = NSImage(contentsOf: url) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 64)
} else {
Rectangle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 64, height: 64)
}
}
}
+204
View File
@@ -0,0 +1,204 @@
import AppKit
import SwiftUI
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
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
Text("Hotkey")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
}
GridRow(alignment: .center) {
fieldLabel("Capture")
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 {
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()
}
}
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(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 {
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)
}
}
/// 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?
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 = "Redline 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
}
}
+83
View File
@@ -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<NSSharingService.SharingContentScope>
) -> 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()
}
}
+79 -4
View File
@@ -1,6 +1,81 @@
import AppKit import AppKit
import SwiftUI
import ShotdeckCore
// WP-4 replaces this body with the real menu-bar UI. // SwiftPM treats a file named main.swift as top-level code, which forbids `@main`.
let application = NSApplication.shared // App.main() is the equivalent entry point.
application.setActivationPolicy(.accessory) if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
application.run() MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
}
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
}
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("Redline")
}
.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("Redline could not access its storage folder. Captures will not persist.")
return model
}
}
private static func makeModel(paths: AppSupportPaths) throws -> AppModel {
let ledger = try ReturnLedger(paths: paths)
return AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
capturer: ScreenCapturer(),
hotkeys: HotkeyCenter(),
picker: RegionPickerController(),
ledger: ledger,
watcher: ReturnWatcher(paths: paths, ledger: ledger)
)
}
}
@@ -0,0 +1,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
)
}
}
@@ -16,7 +16,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
case .screenRecordingNotGranted: case .screenRecordingNotGranted:
return "Screen Recording is turned off. Grant it in System Settings to capture." return "Screen Recording is turned off. Grant it in System Settings to capture."
case .noRegionRemembered: 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 Redline menu."
case .displayNoLongerConnected: case .displayNoLongerConnected:
return "The display used for capture is no longer connected." return "The display used for capture is no longer connected."
case .captureFailed(let underlying): case .captureFailed(let underlying):
+209
View File
@@ -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 {
"Redline-\(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: "Redline",
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<CGRect>.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)
}
}
+149
View File
@@ -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))
}
}
@@ -0,0 +1,113 @@
import Foundation
import PDFKit
public struct ReturnedDocument: Codable, Sendable, Identifiable, Equatable {
public var id: URL { fileURL }
public let fileURL: URL
/// Session UUID recovered from the PDF's subject attribute, when present and valid.
public let sessionID: UUID?
public let pageCount: Int
/// 1-based page numbers that carry at least one human mark, ascending, no duplicates.
public let annotatedPages: [Int]
public let detectedAt: Date
public var isCommented: Bool { !annotatedPages.isEmpty }
public init(
fileURL: URL,
sessionID: UUID?,
pageCount: Int,
annotatedPages: [Int],
detectedAt: Date
) {
self.fileURL = fileURL
self.sessionID = sessionID
self.pageCount = pageCount
self.annotatedPages = annotatedPages
self.detectedAt = detectedAt
}
}
public enum AnnotationInspector {
private static let humanMarkTypes: Set<String> = [
PDFAnnotationSubtype.ink.rawValue,
PDFAnnotationSubtype.highlight.rawValue,
PDFAnnotationSubtype.underline.rawValue,
PDFAnnotationSubtype.strikeOut.rawValue,
// PDFKit has no PDFAnnotationSubtype.squiggly member (unsupported renderer),
// but Apple Markup still writes Adobe /Squiggly objects that we must count.
PDFAnnotationSubtype(rawValue: "/Squiggly").rawValue,
PDFAnnotationSubtype.freeText.rawValue,
PDFAnnotationSubtype.square.rawValue,
PDFAnnotationSubtype.circle.rawValue,
PDFAnnotationSubtype.line.rawValue,
PDFAnnotationSubtype.stamp.rawValue,
PDFAnnotationSubtype.text.rawValue,
]
// .link ignored: a PDF hyperlink is structural, not a human mark.
// .popup ignored: it is always the companion of another annotation; counting it
// would double-count a single human mark as two.
// .widget ignored: a form field. Shotdeck's own PASS/FAIL boxes are page content
// (drawn by WP-2), never PDFAnnotation objects a widget seen here can only be
// introduced by a third-party tool flattening/reopening the file, and is not a
// human mark either way.
/// PDFKit's `PDFAnnotation.type` may omit the leading slash that
/// `PDFAnnotationSubtype.rawValue` includes; compare against the slash form.
private static func pdfTypeName(_ type: String) -> String {
type.hasPrefix("/") ? type : "/" + type
}
private static func isHumanMark(_ annotation: PDFAnnotation) -> Bool {
guard let raw = annotation.type else { return false }
let type = pdfTypeName(raw)
guard humanMarkTypes.contains(type) else { return false }
if type == PDFAnnotationSubtype.ink.rawValue {
let b = annotation.bounds
return b.width > 0 && b.height > 0 // zero-area ink = an undone stroke, not a mark
}
return true
}
/// Opens the PDF at fileURL and reports which pages carry a genuine human mark.
/// Throws ShotdeckError.manifestCorrupt(path: fileURL.path) if PDFDocument cannot open it.
/// File modification date is never consulted; only persisted PDFAnnotation objects count.
public static func inspect(fileURL: URL) throws -> ReturnedDocument {
guard let document = PDFDocument(url: fileURL) else {
throw ShotdeckError.manifestCorrupt(path: fileURL.path)
}
var annotatedPages: [Int] = []
for index in 0..<document.pageCount {
guard let page = document.page(at: index) else { continue }
if page.annotations.contains(where: isHumanMark) {
annotatedPages.append(index + 1) // 1-based
}
}
var sessionID: UUID?
if let subject = document.documentAttributes?[PDFDocumentAttribute.subjectAttribute] as? String {
sessionID = UUID(uuidString: subject) // nil (not thrown) if it doesn't parse
}
return ReturnedDocument(
fileURL: fileURL, sessionID: sessionID, pageCount: document.pageCount,
annotatedPages: annotatedPages, detectedAt: Date()
)
}
/// True when this PDF was produced by 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 {
// 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: /^(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.
}
}
@@ -0,0 +1,60 @@
import Foundation
public actor ReturnLedger {
private let fileURL: URL
private var entries: [URL: ReturnedDocument]
/// Loads `returns.json` under paths.root if it exists; starts empty otherwise.
/// A file that cannot be decoded is renamed (never deleted) and the ledger starts empty.
public init(paths: AppSupportPaths) throws {
self.fileURL = paths.root.appendingPathComponent("returns.json")
if FileManager.default.fileExists(atPath: fileURL.path) {
let data = try Data(contentsOf: fileURL)
do {
let decoded = try JSONDecoder().decode([ReturnedDocument].self, from: data)
entries = Dictionary(decoded.map { ($0.fileURL, $0) }, uniquingKeysWith: { _, new in new })
} catch {
let stamp = DubaiTime.fileStamp(Date())
let corruptURL = fileURL.deletingLastPathComponent()
.appendingPathComponent("returns.json.corrupt-\(stamp)")
try FileManager.default.moveItem(at: fileURL, to: corruptURL)
Log.returns.error(
"returns.json could not be decoded; moved to \(corruptURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
entries = [:]
}
} else {
entries = [:]
}
}
/// Upserts by fileURL recording the same URL again replaces the prior entry
/// (the most recently recorded call wins, regardless of its detectedAt value).
public func record(_ document: ReturnedDocument) throws {
entries[document.fileURL] = document
try persist()
}
/// Every recorded return, newest detectedAt first.
public func all() throws -> [ReturnedDocument] {
entries.values.sorted { $0.detectedAt > $1.detectedAt }
}
/// Commented returns (isCommented == true), newest detectedAt first.
public func commented() throws -> [ReturnedDocument] {
try all().filter(\.isCommented)
}
/// Absolute POSIX paths of commented returns, newest first, one per line, no
/// trailing newline. Throws ShotdeckError.noCommentedReturns when commented() is empty.
public func clipboardText() throws -> String {
let paths = try commented().map { $0.fileURL.path }
guard !paths.isEmpty else { throw ShotdeckError.noCommentedReturns }
return paths.joined(separator: "\n")
}
private func persist() throws {
let data = try JSONEncoder().encode(Array(entries.values))
try AtomicFile.write(data, to: fileURL)
}
}
@@ -0,0 +1,154 @@
import Foundation
import PDFKit
import CoreServices // FSEventStream* APIs; system framework, no Package.swift change needed
public actor ReturnWatcher {
private let ledger: ReturnLedger
private var watchFolder: URL
private var onChange: (@Sendable ([ReturnedDocument]) -> Void)?
private var stream: FSEventStreamRef?
private var bridge: FSEventBridge?
private var pendingScanTask: Task<Void, Never>?
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
/// Watch folder is `paths.watchFolder`, which production constructs from
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
/// `updateWatchFolder` is invoked by the UI layer only.
public init(paths: AppSupportPaths, ledger: ReturnLedger) {
self.ledger = ledger
self.watchFolder = paths.watchFolder // never a literal "~/Downloads" here
}
/// Starts watching paths.watchFolder for returned PDFs. Performs one immediate
/// scanNow() before returning, then calls onChange after every subsequent debounced
/// batch (even if that batch's result is empty the caller decides what to do).
public func start(onChange: @escaping @Sendable ([ReturnedDocument]) -> Void) async throws {
self.onChange = onChange
try startStream(on: watchFolder)
let found = try await scanNow()
onChange(found)
}
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
/// when never started or already stopped. Cancels any pending debounced scan.
public func stop() {
// Idempotent: nil stream / already-stopped is a no-op; never started is the same.
pendingScanTask?.cancel()
pendingScanTask = nil
if let stream {
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
stream = nil
bridge = nil
}
/// Called by whoever owns the Settings "Choose..." folder action (WP-4c) after the user
/// picks a new watch folder. If the watcher was running, stops the old FSEventStream,
/// switches to the new folder, restarts, and performs one immediate scanNow (reporting
/// through the same onChange callback given to start()). If the watcher was never
/// started, only updates the stored folder for the next start() call.
public func updateWatchFolder(_ url: URL) async throws {
let wasRunning = stream != nil
stop()
watchFolder = url
guard wasRunning else { return }
try startStream(on: url)
let found = try await scanNow()
onChange?(found)
}
/// Scans the watch folder once, immediately, without waiting for an event. Every
/// recognized, stable, openable 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] {
let fm = FileManager.default
let candidates = (try? fm.contentsOfDirectory(
at: watchFolder, includingPropertiesForKeys: nil
)) ?? []
var results: [ReturnedDocument] = []
for url in candidates.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let name = url.lastPathComponent
// ".pdf.inprogress" already fails hasSuffix(".pdf") -> naturally skipped.
guard name.hasSuffix(".pdf"), !name.hasPrefix(".") else { continue }
guard await isStableAndReadable(url) else { continue } // leave for next event
guard let document = PDFDocument(url: url),
AnnotationInspector.isShotdeckDocument(document) else { continue }
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
try await ledger.record(inspected)
results.append(inspected)
}
return results
}
/// Size-stable: two equal byte counts 250 ms apart AND PDFDocument opens;
/// otherwise leave the file for the next event.
private func isStableAndReadable(_ url: URL) async -> Bool {
let fm = FileManager.default
guard let size1 = try? fm.attributesOfItem(atPath: url.path)[.size] as? Int else { return false }
try? await Task.sleep(for: .milliseconds(250))
guard let size2 = try? fm.attributesOfItem(atPath: url.path)[.size] as? Int else { return false }
guard size1 == size2, size1 > 0 else { return false }
return PDFDocument(url: url) != nil
}
private func startStream(on folder: URL) throws {
let bridge = FSEventBridge { [weak self] in
guard let self else { return }
Task { await self.scheduleDebouncedScan() }
}
self.bridge = bridge
var context = FSEventStreamContext()
context.version = 0
context.info = Unmanaged.passUnretained(bridge).toOpaque()
context.retain = nil
context.release = nil
context.copyDescription = nil
guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, shotdeckFSEventsCallback, &context,
[folder.path] as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.0,
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer)
) else {
throw ShotdeckError.captureFailed(underlying: "could not create FSEventStream for \(folder.path)")
}
// Dispatch queue, not a run loop: this actor has no run loop of its own, and
// FSEventStreamSetDispatchQueue is the modern replacement for
// FSEventStreamScheduleWithRunLoop. One dedicated serial queue per watcher.
FSEventStreamSetDispatchQueue(stream, eventQueue)
guard FSEventStreamStart(stream) else {
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
throw ShotdeckError.captureFailed(underlying: "FSEventStreamStart failed for \(folder.path)")
}
self.stream = stream
}
private func scheduleDebouncedScan() async {
pendingScanTask?.cancel()
pendingScanTask = Task {
try? await Task.sleep(for: .milliseconds(400)) // coalesce AirDrop's write+rename burst
guard !Task.isCancelled else { return }
guard let found = try? await self.scanNow() else { return }
// One in-flight debounced callback may land after stop(); it is a
// harmless read-only rescan (ledger upsert, no watch-folder mutation).
self.onChange?(found)
}
}
}
/// Non-actor bridge because FSEventStreamCallback is a @convention(c) function pointer and
/// cannot capture actor-isolated state directly; it hops back onto the actor via Task.
private final class FSEventBridge: @unchecked Sendable {
// @unchecked is safe: `notify` is a let, set once at init, never mutated after the
// type is immutable for its entire lifetime.
let notify: @Sendable () -> Void
init(notify: @escaping @Sendable () -> Void) { self.notify = notify }
}
private let shotdeckFSEventsCallback: FSEventStreamCallback = { _, info, _, _, _, _ in
guard let info else { return }
Unmanaged<FSEventBridge>.fromOpaque(info).takeUnretainedValue().notify()
}
+418
View File
@@ -0,0 +1,418 @@
import Foundation
import ImageIO
import CoreGraphics
import Darwin
public actor SpoolStore {
private let paths: AppSupportPaths
private var openSession: CaptureSession
public init(paths: AppSupportPaths) throws {
self.paths = paths
let fm = FileManager.default
try Self.supersedeSpoolArchiveOverlaps(paths: paths, fileManager: fm)
try Self.finishInterruptedArchives(paths: paths, fileManager: fm)
let remainingIDs = try Self.listUUIDDirectories(in: paths.spool, fileManager: fm)
if remainingIDs.isEmpty {
self.openSession = try Self.createFreshSession(paths: paths)
} else {
var candidates: [CaptureSession] = []
for id in remainingIDs {
let dir = paths.sessionDirectory(id)
candidates.append(
try Self.reconcileSessionDirectory(
at: dir, id: id, assumedStateIfRebuilt: .open, fileManager: fm))
}
self.openSession = Self.pickNewest(first: candidates[0], rest: Array(candidates.dropFirst()))
}
}
/// The session currently accepting captures. Cheap accessor all reconciliation already
/// happened once, inside init.
public func currentSession() throws -> CaptureSession {
openSession
}
/// Writes `pngData` to disk and fsyncs it BEFORE the manifest is touched, then updates and
/// durably writes the manifest. Order is non-negotiable: image durable -> manifest durable
/// -> return. Uses AtomicFile.write/writeJSON for both writes never Data.write(to:).
public func append(
pngData: Data, pixelWidth: Int, pixelHeight: Int,
scale: CGFloat, capturedAt: Date
) throws -> Capture {
let captureID = UUID()
let sequence = openSession.nextSequence
let fileName = "\(String(format: "%03d", sequence))-\(Self.hexSuffix(captureID)).png"
let sessionDir = paths.sessionDirectory(openSession.id)
let fileURL = sessionDir.appendingPathComponent(fileName)
try AtomicFile.write(pngData, to: fileURL)
let capture = Capture(
id: captureID, sequence: sequence, fileName: fileName,
pixelWidth: pixelWidth, pixelHeight: pixelHeight,
scale: scale, capturedAt: capturedAt)
let updated = openSession.appending(capture)
try AtomicFile.writeJSON(updated, to: sessionDir.appendingPathComponent("session.json"))
openSession = updated
return capture
}
/// D-11: moves the capture's PNG into `<sessionDir>/removed/` (created lazily) and drops
/// its manifest entry. NEVER unlinks/deletes a user PNG. Throws (no filesystem change) if
/// `captureID` is not present in the open session.
public func remove(captureID: UUID) throws -> CaptureSession {
guard let capture = openSession.captures.first(where: { $0.id == captureID }) else {
throw ShotdeckError.spoolWriteFailed(
path: paths.sessionDirectory(openSession.id).path,
underlying: "capture \(captureID) is not in the open session")
}
let sessionDir = paths.sessionDirectory(openSession.id)
let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true)
do {
try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true)
} catch {
throw ShotdeckError.spoolWriteFailed(path: removedDir.path, underlying: error.localizedDescription)
}
let sourceURL = sessionDir.appendingPathComponent(capture.fileName)
let destURL = removedDir.appendingPathComponent(capture.fileName)
guard rename(sourceURL.path, destURL.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: destURL.path,
underlying: "could not move the capture into removed/: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: removedDir)
let updated = openSession.removing(captureID: captureID)
try AtomicFile.writeJSON(updated, to: sessionDir.appendingPathComponent("session.json"))
openSession = updated
return updated
}
/// Closes the open session (must be non-empty), moves its directory under archive/, records
/// pdfFileName, and starts a fresh empty open session. Returns the archived one.
public func archiveCurrent(pdfFileName: String) throws -> CaptureSession {
guard !openSession.isEmpty else {
throw ShotdeckError.spoolWriteFailed(
path: paths.sessionDirectory(openSession.id).path,
underlying: "cannot archive an empty session")
}
let archived = openSession.markArchived(pdfFileName: pdfFileName)
let sessionDir = paths.sessionDirectory(openSession.id)
try AtomicFile.writeJSON(archived, to: sessionDir.appendingPathComponent("session.json"))
let archiveDir = paths.archiveDirectory(openSession.id)
guard rename(sessionDir.path, archiveDir.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: sessionDir.path,
underlying: "could not move the session into archive/: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: paths.archive)
let fresh = try Self.createFreshSession(paths: paths)
openSession = fresh
return archived
}
/// Abandons the open session only if it is empty (mints a new id/dir/manifest); throws,
/// with no filesystem change, if the open session has captures.
public func startNewSession() throws -> CaptureSession {
guard openSession.isEmpty else {
throw ShotdeckError.spoolWriteFailed(
path: paths.sessionDirectory(openSession.id).path,
underlying: "cannot start a new session: \(openSession.captures.count) capture(s) present in the open session")
}
let fresh = try Self.createFreshSession(paths: paths)
openSession = fresh
return fresh
}
/// Absolute URL of a capture's PNG, in whichever top-level directory its session lives
/// (spool/ if session.state == .open, archive/ if .archived).
public func imageURL(for capture: Capture, in session: CaptureSession) -> URL {
let dir = session.state == .open ? paths.sessionDirectory(session.id) : paths.archiveDirectory(session.id)
return dir.appendingPathComponent(capture.fileName)
}
/// Archived sessions, newest createdAt first. Lazily reconciles each archive/ directory
/// the same way init reconciles spool/ candidates (orphan recovery, missing-drop, corrupt
/// rebuild) an archived session's manifest can degrade too and must self-heal without
/// ever losing a PNG.
public func archivedSessions() throws -> [CaptureSession] {
let ids = try Self.listUUIDDirectories(in: paths.archive, fileManager: .default)
var sessions: [CaptureSession] = []
for id in ids {
sessions.append(
try Self.reconcileSessionDirectory(
at: paths.archiveDirectory(id), id: id, assumedStateIfRebuilt: .archived, fileManager: .default))
}
return sessions.sorted { ($0.createdAt, $0.id.uuidString) > ($1.createdAt, $1.id.uuidString) }
}
// MARK: - Reconciliation (static so they can run inside init)
private static func listUUIDDirectories(in parent: URL, fileManager: FileManager) throws -> [UUID] {
let entries: [URL]
do {
entries = try fileManager.contentsOfDirectory(
at: parent,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])
} catch {
throw ShotdeckError.spoolWriteFailed(path: parent.path, underlying: error.localizedDescription)
}
var ids: [UUID] = []
for url in entries {
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
guard isDirectory else { continue }
if let id = UUID(uuidString: url.lastPathComponent) {
ids.append(id)
}
}
return ids
}
private static func supersedeSpoolArchiveOverlaps(paths: AppSupportPaths, fileManager: FileManager) throws {
let spoolIDs = Set(try listUUIDDirectories(in: paths.spool, fileManager: fileManager))
let archiveIDs = Set(try listUUIDDirectories(in: paths.archive, fileManager: fileManager))
for id in spoolIDs.intersection(archiveIDs) {
let spoolDir = paths.sessionDirectory(id)
let supersededDir = paths.spool.appendingPathComponent(
"\(id.uuidString).superseded-\(DubaiTime.fileStamp(Date()))", isDirectory: true)
guard rename(spoolDir.path, supersededDir.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: spoolDir.path,
underlying: "could not supersede a duplicate spool copy: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: paths.spool)
Log.spool.warning("Found session \(id.uuidString, privacy: .public) in both spool/ and archive/; kept the archive copy and superseded the spool copy — nothing was deleted.")
}
}
private static func finishInterruptedArchives(paths: AppSupportPaths, fileManager: FileManager) throws {
for id in try listUUIDDirectories(in: paths.spool, fileManager: fileManager) {
let spoolDir = paths.sessionDirectory(id)
let manifestURL = spoolDir.appendingPathComponent("session.json")
guard let data = try? Data(contentsOf: manifestURL),
let decoded = try? decodeSession(from: data),
decoded.id == id, decoded.state == .archived
else { continue }
let archiveDir = paths.archiveDirectory(id)
guard rename(spoolDir.path, archiveDir.path) == 0 else {
throw ShotdeckError.spoolWriteFailed(
path: spoolDir.path,
underlying: "could not complete an interrupted archive move: \(String(cString: strerror(errno)))")
}
try AtomicFile.fsyncDirectory(at: paths.archive)
Log.spool.warning("Completed an archive move for \(id.uuidString, privacy: .public) that was interrupted before this launch.")
}
}
private static func reconcileSessionDirectory(
at dir: URL,
id: UUID,
assumedStateIfRebuilt: SessionState,
fileManager: FileManager
) throws -> CaptureSession {
let manifestURL = dir.appendingPathComponent("session.json")
let decoded: CaptureSession?
if let data = try? Data(contentsOf: manifestURL),
let session = try? decodeSession(from: data),
session.id == id {
decoded = session
} else {
decoded = nil
}
guard let session = decoded else {
return try rebuildManifest(
at: dir,
id: id,
assumedState: assumedStateIfRebuilt,
fileManager: fileManager)
}
var present: [Capture] = []
var missingCount = 0
for capture in session.captures {
let fileURL = dir.appendingPathComponent(capture.fileName)
if fileManager.fileExists(atPath: fileURL.path) {
present.append(capture)
} else {
missingCount += 1
}
}
let referenced = Set(session.captures.map(\.fileName))
let pngs = try listCapturePNGs(in: dir, fileManager: fileManager)
var recoveredOrphans: [Capture] = []
for pngURL in pngs where !referenced.contains(pngURL.lastPathComponent) {
if let recovered = recoverCapture(from: pngURL, fileManager: fileManager) {
recoveredOrphans.append(recovered)
} else {
Log.spool.error("Could not decode orphan PNG at \(pngURL.path, privacy: .public); leaving it on disk.")
}
}
let deletedTmp = deleteStrayTmpFiles(in: dir, fileManager: fileManager)
if missingCount == 0 && recoveredOrphans.isEmpty && !deletedTmp {
return session
}
let finalCaptures = (present + recoveredOrphans).sorted { $0.sequence < $1.sequence }
let updated = CaptureSession(
id: session.id,
createdAt: session.createdAt,
state: session.state,
captures: finalCaptures,
pdfFileName: session.pdfFileName)
try AtomicFile.writeJSON(updated, to: manifestURL)
Log.spool.warning("Reconciled session \(id.uuidString, privacy: .public): dropped \(missingCount) missing PNG(s), recovered \(recoveredOrphans.count) orphan(s).")
return updated
}
private static func rebuildManifest(
at dir: URL,
id: UUID,
assumedState: SessionState,
fileManager: FileManager
) throws -> CaptureSession {
let manifestURL = dir.appendingPathComponent("session.json")
if fileManager.fileExists(atPath: manifestURL.path) {
let corruptURL = dir.appendingPathComponent(
"session.json.corrupt-\(DubaiTime.fileStamp(Date()))")
do {
try fileManager.moveItem(at: manifestURL, to: corruptURL)
} catch {
throw ShotdeckError.spoolWriteFailed(
path: manifestURL.path,
underlying: "could not quarantine a corrupt manifest: \(error.localizedDescription)")
}
}
var recovered: [Capture] = []
for pngURL in try listCapturePNGs(in: dir, fileManager: fileManager) {
if let capture = recoverCapture(from: pngURL, fileManager: fileManager) {
recovered.append(capture)
} else {
Log.spool.error("Could not decode PNG at \(pngURL.path, privacy: .public) while rebuilding the manifest; leaving it on disk.")
}
}
_ = deleteStrayTmpFiles(in: dir, fileManager: fileManager)
recovered.sort { $0.sequence < $1.sequence }
let createdAt: Date
if let earliest = recovered.map(\.capturedAt).min() {
createdAt = earliest
} else {
createdAt = (try? dir.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date()
}
let rebuilt = CaptureSession(
id: id,
createdAt: createdAt,
state: assumedState,
captures: recovered,
pdfFileName: nil)
try AtomicFile.writeJSON(rebuilt, to: dir.appendingPathComponent("session.json"))
Log.spool.warning("Rebuilt manifest for \(id.uuidString, privacy: .public) from \(recovered.count) recovered PNG(s).")
if assumedState == .archived {
Log.spool.warning("Rebuilt an archived session \(id.uuidString, privacy: .public) from PNGs; pdfFileName could not be recovered.")
}
return rebuilt
}
private static func recoverCapture(from fileURL: URL, fileManager: FileManager) -> Capture? {
guard fileManager.fileExists(atPath: fileURL.path) else { return nil }
guard let source = CGImageSourceCreateWithURL(fileURL as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?,
let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue
else { return nil }
let name = fileURL.lastPathComponent
let sequence = Int(name.prefix(3)) ?? 1
let capturedAt = (try? fileURL.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date()
return Capture(
id: UUID(),
sequence: sequence,
fileName: name,
pixelWidth: width,
pixelHeight: height,
scale: 1.0,
capturedAt: capturedAt)
}
private static func pickNewest(first: CaptureSession, rest: [CaptureSession]) -> CaptureSession {
rest.reduce(first) { current, candidate in
let currentKey = (current.createdAt, current.id.uuidString)
let candidateKey = (candidate.createdAt, candidate.id.uuidString)
return candidateKey > currentKey ? candidate : current
}
}
private static func createFreshSession(paths: AppSupportPaths) throws -> CaptureSession {
let id = UUID()
let createdAt = Date()
let dir = paths.sessionDirectory(id)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
throw ShotdeckError.spoolWriteFailed(path: dir.path, underlying: error.localizedDescription)
}
let session = CaptureSession(id: id, createdAt: createdAt, state: .open, captures: [], pdfFileName: nil)
try AtomicFile.writeJSON(session, to: dir.appendingPathComponent("session.json"))
return session
}
private static func hexSuffix(_ id: UUID) -> String {
String(id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased()
}
private static func decodeSession(from data: Data) throws -> CaptureSession {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(CaptureSession.self, from: data)
}
private static func isCapturePNGName(_ name: String) -> Bool {
guard name.hasSuffix(".png") else { return false }
let stem = String(name.dropLast(4))
let parts = stem.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2,
parts[0].count == 3,
parts[0].allSatisfy(\.isNumber),
parts[1].count == 8,
parts[1].allSatisfy(\.isHexDigit)
else { return false }
return true
}
private static func listCapturePNGs(in dir: URL, fileManager: FileManager) throws -> [URL] {
let entries: [URL]
do {
entries = try fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])
} catch {
throw ShotdeckError.spoolWriteFailed(path: dir.path, underlying: error.localizedDescription)
}
return entries.filter { url in
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
guard !isDirectory else { return false }
return isCapturePNGName(url.lastPathComponent)
}
}
private static func deleteStrayTmpFiles(in dir: URL, fileManager: FileManager) -> Bool {
let entries = (try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])) ?? []
var deleted = false
for url in entries {
guard url.lastPathComponent.hasSuffix(".tmp") else { continue }
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
guard !isDirectory else { continue }
do {
try fileManager.removeItem(at: url)
deleted = true
} catch {
Log.spool.error("Could not remove stray temp file at \(url.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
return deleted
}
}
@@ -0,0 +1,104 @@
import Foundation
import Darwin
public enum AtomicFile {
/// Writes `data` to `url` durably: writes to `<url>.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<T: Encodable>(_ 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)))")
}
}
}
@@ -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)
}
}
+9
View File
@@ -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")
}
@@ -0,0 +1,478 @@
import AppKit
import Foundation
import PDFKit
import Testing
import ShotdeckCore
private let pageBounds = CGRect(x: 0, y: 0, width: 600, height: 800)
private func makeAnnotation(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect = CGRect(x: 100, y: 100, width: 80, height: 40),
contents: String? = nil
) -> PDFAnnotation {
let annotation = PDFAnnotation(
bounds: bounds,
forType: subtype,
withProperties: nil
)
annotation.contents = contents
return annotation
}
private func makePDF(
at url: URL,
pageCount: Int,
creator: String? = "Shotdeck",
subject: String? = nil,
annotations: [(page: Int, annotation: PDFAnnotation)] = []
) throws {
let document = PDFDocument()
for index in 0..<pageCount {
let page = PDFPage()
page.setBounds(pageBounds, for: .mediaBox)
document.insert(page, at: index)
}
var attributes = [PDFDocumentAttribute: Any]()
if let creator { attributes[.creatorAttribute] = creator }
if let subject { attributes[.subjectAttribute] = subject }
document.documentAttributes = attributes
for item in annotations {
guard let page = document.page(at: item.page) else {
throw NSError(domain: "WP5Test", code: 1)
}
page.addAnnotation(item.annotation)
}
guard document.write(to: url) else {
throw NSError(domain: "WP5Test", code: 2)
}
}
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5a-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: cleanup.appendingPathComponent("root", isDirectory: true),
outbox: cleanup.appendingPathComponent("outbox", isDirectory: true),
watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true)
)
return (paths, cleanup)
}
@Test("I-1 Untouched PDF is clean")
func i1_untouchedPDFIsClean() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134200.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
creator: "Shotdeck",
subject: "11111111-1111-1111-1111-111111111111"
)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Dubai"))
let firstMTime = try #require(calendar.date(from: DateComponents(
year: 2026, month: 8, day: 30, hour: 13, minute: 42, second: 0
)))
let secondMTime = try #require(calendar.date(from: DateComponents(
year: 2026, month: 8, day: 30, hour: 13, minute: 43, second: 0
)))
try FileManager.default.setAttributes([.modificationDate: firstMTime], ofItemAtPath: pdfURL.path)
try FileManager.default.setAttributes([.modificationDate: secondMTime], ofItemAtPath: pdfURL.path)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.fileURL == pdfURL)
#expect(inspected.sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111"))
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [])
#expect(inspected.isCommented == false)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(inspected)
let all = try await ledger.all()
#expect(all.count == 1)
#expect(all[0].fileURL == pdfURL)
#expect(try await ledger.commented() == [])
let clipboardError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = clipboardError else {
Issue.record("expected noCommentedReturns, got \(clipboardError)")
return
}
}
@Test("I-2 Ink is a mark")
func i2_inkIsAMark() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134201.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
annotations: [(page: 1, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 120, y: 240, width: 140, height: 60)
))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [2])
#expect(inspected.isCommented == true)
}
@Test("I-3 Highlight is a mark")
func i3_highlightIsAMark() throws {
try expectSinglePageMark(
.highlight,
bounds: CGRect(x: 80, y: 500, width: 300, height: 24),
fileName: "Shotdeck-20260830-134202.pdf"
)
}
@Test("I-4 Underline is a mark")
func i4_underlineIsAMark() throws {
try expectSinglePageMark(
.underline,
bounds: CGRect(x: 90, y: 460, width: 280, height: 18),
fileName: "Shotdeck-20260830-134203.pdf"
)
}
@Test("I-5 Strike-out is a mark")
func i5_strikeOutIsAMark() throws {
try expectSinglePageMark(
.strikeOut,
bounds: CGRect(x: 90, y: 430, width: 280, height: 18),
fileName: "Shotdeck-20260830-134204.pdf"
)
}
@Test("I-6 Squiggly is a mark")
func i6_squigglyIsAMark() throws {
try expectSinglePageMark(
PDFAnnotationSubtype(rawValue: "/Squiggly"),
bounds: CGRect(x: 90, y: 400, width: 280, height: 18),
fileName: "Shotdeck-20260830-134205.pdf"
)
}
@Test("I-7 Free-text is a mark")
func i7_freeTextIsAMark() throws {
try expectSinglePageMark(
.freeText,
bounds: CGRect(x: 140, y: 300, width: 220, height: 80),
contents: "Typed review note",
fileName: "Shotdeck-20260830-134206.pdf"
)
}
@Test("I-8 Square is a mark")
func i8_squareIsAMark() throws {
try expectSinglePageMark(
.square,
bounds: CGRect(x: 160, y: 250, width: 100, height: 100),
fileName: "Shotdeck-20260830-134207.pdf"
)
}
@Test("I-9 Circle is a mark")
func i9_circleIsAMark() throws {
try expectSinglePageMark(
.circle,
bounds: CGRect(x: 280, y: 250, width: 100, height: 100),
fileName: "Shotdeck-20260830-134208.pdf"
)
}
@Test("I-10 Line is a mark")
func i10_lineIsAMark() throws {
try expectSinglePageMark(
.line,
bounds: CGRect(x: 100, y: 180, width: 320, height: 8),
fileName: "Shotdeck-20260830-134209.pdf"
)
}
@Test("I-11 Stamp is a mark")
func i11_stampIsAMark() throws {
try expectSinglePageMark(
.stamp,
bounds: CGRect(x: 180, y: 500, width: 120, height: 60),
contents: "Approved",
fileName: "Shotdeck-20260830-134210.pdf"
)
}
@Test("I-12 Sticky text is a mark")
func i12_stickyTextIsAMark() throws {
try expectSinglePageMark(
.text,
bounds: CGRect(x: 420, y: 620, width: 28, height: 28),
contents: "Look here",
fileName: "Shotdeck-20260830-134211.pdf"
)
}
@Test("I-13 Link alone is not a mark")
func i13_linkAloneIsNotAMark() throws {
try expectSinglePageIgnored(
.link,
bounds: CGRect(x: 80, y: 700, width: 160, height: 20),
fileName: "Shotdeck-20260830-134212.pdf"
)
}
@Test("I-14 Popup alone is not a mark")
func i14_popupAloneIsNotAMark() throws {
try expectSinglePageIgnored(
.popup,
bounds: CGRect(x: 450, y: 600, width: 100, height: 60),
fileName: "Shotdeck-20260830-134213.pdf"
)
}
@Test("I-15 Widget alone is not a mark")
func i15_widgetAloneIsNotAMark() throws {
try expectSinglePageIgnored(
.widget,
bounds: CGRect(x: 100, y: 100, width: 140, height: 32),
fileName: "Shotdeck-20260830-134214.pdf"
)
}
@Test("I-16 Zero-area ink is not a mark")
func i16_zeroAreaInkIsNotAMark() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134215.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 240, y: 240, width: 0, height: 0)
))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.annotatedPages == [])
#expect(inspected.isCommented == false)
}
@Test("I-17 Ignored objects plus one real mark count once")
func i17_ignoredObjectsPlusOneRealMarkCountOnce() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134216.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
annotations: [
(page: 1, annotation: makeAnnotation(.link, bounds: CGRect(x: 20, y: 20, width: 40, height: 20))),
(page: 1, annotation: makeAnnotation(.popup, bounds: CGRect(x: 70, y: 20, width: 40, height: 20))),
(page: 1, annotation: makeAnnotation(.widget, bounds: CGRect(x: 120, y: 20, width: 40, height: 20))),
(page: 1, annotation: makeAnnotation(.ink, bounds: CGRect(x: 200, y: 200, width: 120, height: 50))),
]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [2])
#expect(inspected.isCommented == true)
}
@Test("I-18 Page numbers are 1-based and ascending")
func i18_pageNumbersAre1BasedAndAscending() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134217.pdf")
try makePDF(
at: pdfURL,
pageCount: 3,
annotations: [
(page: 0, annotation: makeAnnotation(.circle, bounds: CGRect(x: 260, y: 200, width: 70, height: 70))),
(page: 1, annotation: makeAnnotation(.link, bounds: CGRect(x: 80, y: 700, width: 160, height: 20))),
(page: 2, annotation: makeAnnotation(.ink, bounds: CGRect(x: 100, y: 100, width: 90, height: 40))),
]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 3)
#expect(inspected.annotatedPages == [1, 3])
#expect(inspected.isCommented == true)
}
@Test("I-19 Session UUID recovery and invalid-subject tolerance")
func i19_sessionUUIDRecoveryAndInvalidSubjectTolerance() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let validURL = paths.watchFolder.appendingPathComponent("valid-subject.pdf")
let invalidURL = paths.watchFolder.appendingPathComponent("invalid-subject.pdf")
let missingURL = paths.watchFolder.appendingPathComponent("missing-subject.pdf")
try makePDF(
at: validURL,
pageCount: 1,
creator: "Shotdeck",
subject: "22222222-2222-2222-2222-222222222222"
)
try makePDF(
at: invalidURL,
pageCount: 1,
creator: "Shotdeck",
subject: "not-a-uuid"
)
try makePDF(
at: missingURL,
pageCount: 1,
creator: "Shotdeck",
subject: nil
)
let valid = try AnnotationInspector.inspect(fileURL: validURL)
#expect(valid.sessionID == UUID(uuidString: "22222222-2222-2222-2222-222222222222"))
#expect(valid.pageCount == 1)
#expect(valid.annotatedPages == [])
#expect(valid.isCommented == false)
let invalid = try AnnotationInspector.inspect(fileURL: invalidURL)
#expect(invalid.sessionID == nil)
#expect(invalid.annotatedPages == [])
#expect(invalid.isCommented == false)
let missing = try AnnotationInspector.inspect(fileURL: missingURL)
#expect(missing.sessionID == nil)
#expect(missing.annotatedPages == [])
#expect(missing.isCommented == false)
}
@Test("I-19b Present non-Shotdeck creator beats a matching filename")
func i19b_presentNonShotdeckCreatorBeatsMatchingFilename() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134218.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: "Preview",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == false)
}
@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,
contents: String? = nil,
fileName: String
) throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent(fileName)
try makePDF(
at: pdfURL,
pageCount: 1,
annotations: [(page: 0, annotation: makeAnnotation(subtype, bounds: bounds, contents: contents))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.pageCount == 1)
#expect(inspected.annotatedPages == [1])
#expect(inspected.isCommented == true)
}
private func expectSinglePageIgnored(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect,
fileName: String
) throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent(fileName)
try makePDF(
at: pdfURL,
pageCount: 1,
annotations: [(page: 0, annotation: makeAnnotation(subtype, bounds: bounds))]
)
let inspected = try AnnotationInspector.inspect(fileURL: pdfURL)
#expect(inspected.annotatedPages == [])
#expect(inspected.isCommented == false)
}
@@ -0,0 +1,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
))
)
}
@@ -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
}
@@ -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..<yEnd {
for x in xStart..<xEnd {
if let (r, g, b) = pixel(x: x, y: y), r != 255 || g != 255 || b != 255 {
return true
}
}
}
return false
}
static func loadPNG(at url: URL) throws -> 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..<document.pageCount {
let page = try #require(document.page(at: index))
#expect(page.annotations.isEmpty)
}
}
@Test("Mixed orientations coexist, unnormalised")
func mixedOrientationsCoexist() 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: 2, pixelWidth: 900, pixelHeight: 1600)
let square = makeCapture(sequence: 3, pixelWidth: 1000, pixelHeight: 1000)
let urls: [UUID: URL] = [
wide.id: try writePNG(for: wide, red: 90, green: 10, blue: 10, in: directory),
tall.id: try writePNG(for: tall, red: 10, green: 90, blue: 10, in: directory),
square.id: try writePNG(for: square, red: 10, green: 10, blue: 90, in: directory),
]
let output = directory.appendingPathComponent("mixed.pdf")
_ = try PDFComposer().compose(
session: makeSession(captures: [wide, tall, square]),
imageURL: imageURLMap(urls),
title: "Mixed",
to: output
)
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: 595, height: 842))
}
@Test("No cover page, no before/after, exactly one page per capture")
func noCoverPageExactlyOnePagePerCapture() throws {
let directory = try makeScratchDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let c1 = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 500)
let c2 = makeCapture(sequence: 2, pixelWidth: 500, pixelHeight: 800)
let urls: [UUID: URL] = [
c1.id: try writePNG(for: c1, red: 12, green: 12, blue: 12, in: directory),
c2.id: try writePNG(for: c2, red: 22, green: 22, blue: 22, in: directory),
]
let output = directory.appendingPathComponent("two.pdf")
let pageCount = try PDFComposer().compose(
session: makeSession(captures: [c1, c2]),
imageURL: imageURLMap(urls),
title: "Two",
to: output
)
#expect(pageCount == 2)
#expect(try openDocument(output).pageCount == 2)
}
@Test("Small image is never upscaled")
func smallImageIsNeverUpscaled() throws {
let capture = makeCapture(sequence: 1, pixelWidth: 200, pixelHeight: 150)
let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1)
#expect(layout.imageRect.size == CGSize(width: 200, height: 150))
}
@Test("Empty session throws, produces nothing on disk")
func emptySessionThrowsProducesNothing() throws {
let directory = try makeScratchDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let output = directory.appendingPathComponent("empty.pdf")
var threwCompositionFailed = false
do {
_ = try PDFComposer().compose(
session: makeSession(captures: []),
imageURL: { _ in directory.appendingPathComponent("missing.png") },
title: "Empty",
to: output
)
} catch ShotdeckError.pdfCompositionFailed {
threwCompositionFailed = true
}
#expect(threwCompositionFailed)
#expect(!FileManager.default.fileExists(atPath: output.path))
let leftover = try FileManager.default.contentsOfDirectory(atPath: directory.path)
#expect(leftover.isEmpty)
}
@Test("A missing PNG is skipped, page count drops, document still opens")
func missingPNGIsSkipped() throws {
let directory = try makeScratchDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let first = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900)
let missing = makeCapture(sequence: 2, pixelWidth: 800, pixelHeight: 600)
let third = makeCapture(sequence: 3, pixelWidth: 900, pixelHeight: 1600)
let firstURL = try writePNG(for: first, red: 220, green: 10, blue: 10, in: directory)
let thirdURL = try writePNG(for: third, red: 10, green: 10, blue: 220, in: directory)
let missingURL = directory.appendingPathComponent("does-not-exist.png")
let urls: [UUID: URL] = [
first.id: firstURL,
missing.id: missingURL,
third.id: thirdURL,
]
let output = directory.appendingPathComponent("skip-missing.pdf")
let pageCount = try PDFComposer().compose(
session: makeSession(captures: [first, missing, third]),
imageURL: imageURLMap(urls),
title: "Skip missing",
to: output
)
#expect(pageCount == 2)
let document = try openDocument(output)
#expect(document.pageCount == 2)
let p0 = try #require(document.page(at: 0))
let p1 = try #require(document.page(at: 1))
#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))
// Distinct fill colors sampled from pdftoppm at each imageRect center.
let prefix = directory.appendingPathComponent("skip-missing")
try pdftoppm(pdf: output, prefix: prefix)
let page1PNG = directory.appendingPathComponent("skip-missing-1.png")
let page2PNG = directory.appendingPathComponent("skip-missing-2.png")
let bitmap1 = try RGBABitmap.loadPNG(at: page1PNG)
let bitmap2 = try RGBABitmap.loadPNG(at: page2PNG)
let k = 110.0 / 72.0
let layout1 = PageLayout(capture: first, pageIndex: 1, pageCount: 2)
let layout2 = PageLayout(capture: third, pageIndex: 2, pageCount: 2)
let c1 = try #require(bitmap1.pixel(
x: Int((layout1.imageRect.midX * k).rounded()),
y: Int(((layout1.pageRect.height - layout1.imageRect.midY) * k).rounded())
))
let c2 = try #require(bitmap2.pixel(
x: Int((layout2.imageRect.midX * k).rounded()),
y: Int(((layout2.pageRect.height - layout2.imageRect.midY) * k).rounded())
))
#expect(c1.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 == "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)
#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)
}
@@ -0,0 +1,200 @@
import Foundation
import Testing
import ShotdeckCore
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5a-ledger-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: cleanup.appendingPathComponent("root", isDirectory: true),
outbox: cleanup.appendingPathComponent("outbox", isDirectory: true),
watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true)
)
return (paths, cleanup)
}
private func document(
path: String,
annotatedPages: [Int],
detectedAt: Date,
sessionID: UUID? = nil,
pageCount: Int = 1
) -> ReturnedDocument {
ReturnedDocument(
fileURL: URL(fileURLWithPath: path),
sessionID: sessionID,
pageCount: pageCount,
annotatedPages: annotatedPages,
detectedAt: detectedAt
)
}
@Test("L-1 commented() returns only commented entries, newest first")
func l1_commentedReturnsOnlyCommentedNewestFirst() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let newer = document(
path: "/tmp/shotdeck-returns/newer.pdf",
annotatedPages: [1, 2],
detectedAt: Date(timeIntervalSince1970: 1_777_000_200)
)
let older = document(
path: "/tmp/shotdeck-returns/older.pdf",
annotatedPages: [3],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let clean = document(
path: "/tmp/shotdeck-returns/clean.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_300)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(older)
try await ledger.record(newer)
try await ledger.record(clean)
let commented = try await ledger.commented()
#expect(commented.map(\.fileURL) == [newer.fileURL, older.fileURL])
#expect(commented.map(\.annotatedPages) == [[1, 2], [3]])
}
@Test("L-2 clipboardText() exact format")
func l2_clipboardTextExactFormat() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let newer = document(
path: "/tmp/shotdeck-returns/newer.pdf",
annotatedPages: [1, 2],
detectedAt: Date(timeIntervalSince1970: 1_777_000_200)
)
let older = document(
path: "/tmp/shotdeck-returns/older.pdf",
annotatedPages: [3],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let clean = document(
path: "/tmp/shotdeck-returns/clean.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_300)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(older)
try await ledger.record(newer)
try await ledger.record(clean)
let text = try await ledger.clipboardText()
#expect(text == newer.fileURL.path + "\n" + older.fileURL.path)
#expect(text.hasSuffix("\n") == false)
}
@Test("L-3 Re-recording the same URL upserts")
func l3_rerecordingTheSameURLUpserts() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let url = URL(fileURLWithPath: "/tmp/shotdeck-returns/same.pdf")
let first = ReturnedDocument(
fileURL: url,
sessionID: nil,
pageCount: 1,
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_400)
)
let second = ReturnedDocument(
fileURL: url,
sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111"),
pageCount: 1,
annotatedPages: [1],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(first)
try await ledger.record(second)
let all = try await ledger.all()
#expect(all.count == 1)
#expect(all[0].fileURL == url)
#expect(all[0].annotatedPages == [1])
#expect(all[0].isCommented == true)
#expect(all[0].sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111"))
}
@Test("L-4 clipboardText() throws when nothing is commented")
func l4_clipboardTextThrowsWhenNothingIsCommented() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let emptyError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = emptyError else {
Issue.record("expected noCommentedReturns on an empty ledger, got \(emptyError)")
return
}
try await ledger.record(document(
path: "/tmp/shotdeck-returns/clean-only.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_500)
))
let cleanError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = cleanError else {
Issue.record("expected noCommentedReturns with only clean returns, got \(cleanError)")
return
}
}
@Test("L-5 Ledger survives reopening from disk")
func l5_ledgerSurvivesReopeningFromDisk() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let first = document(
path: "/tmp/shotdeck-returns/a.pdf",
annotatedPages: [1],
detectedAt: Date(timeIntervalSince1970: 1_777_000_700),
sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")
)
let second = document(
path: "/tmp/shotdeck-returns/b.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_600)
)
do {
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(first)
try await ledger.record(second)
}
let reopened = try ReturnLedger(paths: paths)
let all = try await reopened.all()
#expect(all == [first, second])
}
@Test("L-6 Corrupt returns.json is renamed and the ledger starts empty")
func l6_corruptReturnsJSONIsRenamedAndStartsEmpty() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let returnsURL = paths.root.appendingPathComponent("returns.json")
try Data("{not-json".utf8).write(to: returnsURL)
#expect(FileManager.default.fileExists(atPath: returnsURL.path))
let ledger = try ReturnLedger(paths: paths)
#expect(try await ledger.all() == [])
#expect(FileManager.default.fileExists(atPath: returnsURL.path) == false)
let names = try FileManager.default.contentsOfDirectory(atPath: paths.root.path)
let corrupt = names.filter { $0.hasPrefix("returns.json.corrupt-") }
#expect(corrupt.count == 1)
#expect(corrupt[0].contains(DubaiTime.fileStamp(Date()).prefix(8)))
}
@@ -0,0 +1,232 @@
import AppKit
import Foundation
import PDFKit
import Testing
import ShotdeckCore
private let pageBounds = CGRect(x: 0, y: 0, width: 600, height: 800)
private func makeAnnotation(
_ subtype: PDFAnnotationSubtype,
bounds: CGRect = CGRect(x: 100, y: 100, width: 80, height: 40),
contents: String? = nil
) -> PDFAnnotation {
let annotation = PDFAnnotation(
bounds: bounds,
forType: subtype,
withProperties: nil
)
annotation.contents = contents
return annotation
}
private func makePDF(
at url: URL,
pageCount: Int,
creator: String? = "Shotdeck",
subject: String? = nil,
annotations: [(page: Int, annotation: PDFAnnotation)] = []
) throws {
let document = PDFDocument()
for index in 0..<pageCount {
let page = PDFPage()
page.setBounds(pageBounds, for: .mediaBox)
document.insert(page, at: index)
}
var attributes = [PDFDocumentAttribute: Any]()
if let creator { attributes[.creatorAttribute] = creator }
if let subject { attributes[.subjectAttribute] = subject }
document.documentAttributes = attributes
for item in annotations {
guard let page = document.page(at: item.page) else {
throw NSError(domain: "WP5Test", code: 1)
}
page.addAnnotation(item.annotation)
}
guard document.write(to: url) else {
throw NSError(domain: "WP5Test", code: 2)
}
}
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5b-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: cleanup.appendingPathComponent("root", isDirectory: true),
outbox: cleanup.appendingPathComponent("outbox", isDirectory: true),
watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true)
)
return (paths, cleanup)
}
/// Guards `confirmation(expectedCount: 1)` against a create+rename double-event.
private final class ConfirmOnce: @unchecked Sendable {
private let lock = NSLock()
private var fired = false
func run(_ body: () -> Void) {
lock.lock()
defer { lock.unlock() }
guard !fired else { return }
fired = true
body()
}
}
@Test("W-25 Duplicate-name suffix is the normal AirDrop return (scanNow)")
func w25_duplicateNameSuffixIsRecognizedByScanNow() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134205 2.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: nil,
subject: "33333333-3333-3333-3333-333333333333",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let found = try await watcher.scanNow()
#expect(found.count == 1)
let doc = try #require(found.first)
#expect(doc.fileURL.lastPathComponent == "Shotdeck-20260830-134205 2.pdf")
#expect(doc.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
#expect(doc.pageCount == 1)
#expect(doc.annotatedPages == [1])
#expect(doc.isCommented == true)
let commented = try await ledger.commented()
#expect(commented.count == 1)
#expect(commented[0].fileURL.lastPathComponent == pdfURL.lastPathComponent)
#expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
}
@Test("W-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()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134218.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: "Preview",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let found = try await watcher.scanNow()
#expect(found.isEmpty)
let all = try await ledger.all()
#expect(all.isEmpty)
}
@Test("W-27 FSEvents live callback fires on a real file arrival")
func w27_fsEventsCallbackFiresOnRealArrival() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-140000.pdf")
let once = ConfirmOnce()
try await confirmation("watcher reports the arrived PDF", expectedCount: 1) { confirm in
do {
try await watcher.start { docs in
if docs.contains(where: { $0.fileURL.lastPathComponent == pdfURL.lastPathComponent }) {
once.run { confirm() }
}
}
// Simulate AirDrop's own write-then-rename so the watcher must survive that pattern.
let tmpURL = pdfURL.appendingPathExtension("inprogress")
try makePDF(
at: tmpURL, pageCount: 1, creator: "Shotdeck",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
try FileManager.default.moveItem(at: tmpURL, to: pdfURL)
} catch {
print("fsEventsCallbackFiresOnRealArrival error: \(error)")
await watcher.stop()
throw error
}
// Budget: 400ms debounce + FS latency + the 250ms stability re-read + PDFKit open.
// 5s is a generous, fixed ceiling never a "some time" wait.
try await Task.sleep(for: .seconds(5))
await watcher.stop()
await watcher.stop()
}
}
@@ -0,0 +1,590 @@
import CoreGraphics
import Foundation
import ImageIO
import Testing
import ShotdeckCore
// MARK: - 1. append writes a real, decodable PNG
@Test
func appendWritesARealDecodablePNGMatchingSourceDimensions() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let width = 12
let height = 7
let png = try makePNGData(width: width, height: height, red: 0.9, green: 0.1, blue: 0.2)
let store = try SpoolStore(paths: paths)
let capturedAt = Date(timeIntervalSince1970: 1_700_000_000)
let capture = try await store.append(
pngData: png, pixelWidth: width, pixelHeight: height, scale: 2.0, capturedAt: capturedAt)
let session = try await store.currentSession()
#expect(capture.pixelWidth == width)
#expect(capture.pixelHeight == height)
let url = await store.imageURL(for: capture, in: session)
let expected = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName)
#expect(url.path == expected.path)
#expect(FileManager.default.fileExists(atPath: url.path))
let onDisk = try Data(contentsOf: url)
#expect(onDisk == png)
let decoded = try #require(pngDimensions(at: url))
#expect(decoded.width == width)
#expect(decoded.height == height)
}
// MARK: - 2. remaining-manifest sequence rule (coordinator correction)
@Test
func sequencesFollowRemainingManifestMaxAndAreNotRenumbered() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let c1 = try await store.append(
pngData: try makePNGData(width: 4, height: 4, red: 1, green: 0, blue: 0),
pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date())
let c2 = try await store.append(
pngData: try makePNGData(width: 6, height: 4, red: 0, green: 1, blue: 0),
pixelWidth: 6, pixelHeight: 4, scale: 1.0, capturedAt: Date())
let c3 = try await store.append(
pngData: try makePNGData(width: 8, height: 4, red: 0, green: 0, blue: 1),
pixelWidth: 8, pixelHeight: 4, scale: 1.0, capturedAt: Date())
#expect(c1.sequence == 1)
#expect(c2.sequence == 2)
#expect(c3.sequence == 3)
_ = try await store.remove(captureID: c2.id)
let afterMiddle = try await store.currentSession()
#expect(afterMiddle.captures.map(\.sequence) == [1, 3])
#expect(afterMiddle.captures.map(\.id) == [c1.id, c3.id])
let c4 = try await store.append(
pngData: try makePNGData(width: 10, height: 4, red: 1, green: 1, blue: 0),
pixelWidth: 10, pixelHeight: 4, scale: 1.0, capturedAt: Date())
#expect(c4.sequence == 4)
_ = try await store.remove(captureID: c4.id)
let afterLast = try await store.currentSession()
#expect(afterLast.captures.map(\.sequence) == [1, 3])
let c4b = try await store.append(
pngData: try makePNGData(width: 12, height: 4, red: 1, green: 0, blue: 1),
pixelWidth: 12, pixelHeight: 4, scale: 1.0, capturedAt: Date())
#expect(c4b.sequence == 4)
#expect(c4b.fileName != c4.fileName)
#expect(c4b.id != c4.id)
let sessionDir = paths.sessionDirectory(afterLast.id)
#expect(FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4b.fileName).path))
#expect(FileManager.default.fileExists(
atPath: sessionDir.appendingPathComponent("removed", isDirectory: true)
.appendingPathComponent(c4.fileName).path))
#expect(!FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4.fileName).path))
}
// MARK: - 3. remove moves PNG into removed/
@Test
func removeMovesThePNGUnchangedIntoRemovedAndLeavesTheOriginalPathEmpty() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 8, height: 5, red: 0.2, green: 0.5, blue: 0.8)
let capture = try await store.append(
pngData: png, pixelWidth: 8, pixelHeight: 5, scale: 2.0, capturedAt: Date())
let session = try await store.currentSession()
let sessionDir = paths.sessionDirectory(session.id)
let originalURL = sessionDir.appendingPathComponent(capture.fileName)
let removedURL = sessionDir.appendingPathComponent("removed", isDirectory: true)
.appendingPathComponent(capture.fileName)
let updated = try await store.remove(captureID: capture.id)
#expect(updated.captures.contains(where: { $0.id == capture.id }) == false)
#expect(!FileManager.default.fileExists(atPath: originalURL.path))
#expect(FileManager.default.fileExists(atPath: removedURL.path))
let moved = try Data(contentsOf: removedURL)
#expect(moved == png)
}
// MARK: - 4. remove of unknown id throws with zero filesystem change
@Test
func removeOfUnknownIDThrowsAndLeavesDiskByteIdentical() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
_ = try await store.append(
pngData: try makePNGData(width: 4, height: 4, red: 0.1, green: 0.2, blue: 0.3),
pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date())
let beforeSession = try await store.currentSession()
let beforeFiles = try snapshotFiles(under: root)
do {
_ = try await store.remove(captureID: UUID())
Issue.record("expected remove of an unknown id to throw")
} catch let error as ShotdeckError {
guard case .spoolWriteFailed = error else {
Issue.record("expected spoolWriteFailed, got \(error)")
return
}
} catch {
Issue.record("expected ShotdeckError, got \(error)")
return
}
let afterSession = try await store.currentSession()
#expect(afterSession == beforeSession)
let afterFiles = try snapshotFiles(under: root)
#expect(afterFiles == beforeFiles)
}
// MARK: - 5. orphan recovery
@Test
func orphanPNGWrittenBehindTheStoreIsRecoveredOnReopen() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let session = try await store.currentSession()
let width = 11
let height = 9
let png = try makePNGData(width: width, height: height, red: 0.4, green: 0.7, blue: 0.1)
let orphanName = "007-ABCD1234.png"
let dest = paths.sessionDirectory(session.id).appendingPathComponent(orphanName)
try AtomicFile.write(png, to: dest)
let reopened = try SpoolStore(paths: paths)
let recovered = try await reopened.currentSession()
let match = try #require(recovered.captures.first { $0.fileName == orphanName })
#expect(match.pixelWidth == width)
#expect(match.pixelHeight == height)
#expect(match.sequence == 7)
#expect(match.scale == 1.0)
}
// MARK: - 6. removed/ files are not resurrected
@Test
func orphanRecoveryDoesNotResurrectFilesInRemoved() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let session = try await store.currentSession()
let sessionDir = paths.sessionDirectory(session.id)
let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true)
try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true)
let hiddenName = "009-FEEDFACE.png"
try AtomicFile.write(
try makePNGData(width: 5, height: 5, red: 0.9, green: 0.9, blue: 0.1),
to: removedDir.appendingPathComponent(hiddenName))
let reopened = try SpoolStore(paths: paths)
let reconciled = try await reopened.currentSession()
#expect(reconciled.captures.contains(where: { $0.fileName == hiddenName }) == false)
#expect(FileManager.default.fileExists(atPath: removedDir.appendingPathComponent(hiddenName).path))
}
// MARK: - 7. missing-image drop
@Test
func missingPNGReferencedByManifestIsDroppedAndOthersSurvive() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let keep = try await store.append(
pngData: try makePNGData(width: 6, height: 6, red: 0.1, green: 0.8, blue: 0.2),
pixelWidth: 6, pixelHeight: 6, scale: 1.0, capturedAt: Date())
let drop = try await store.append(
pngData: try makePNGData(width: 7, height: 7, red: 0.8, green: 0.1, blue: 0.2),
pixelWidth: 7, pixelHeight: 7, scale: 1.0, capturedAt: Date())
let session = try await store.currentSession()
try FileManager.default.removeItem(
at: paths.sessionDirectory(session.id).appendingPathComponent(drop.fileName))
let reopened = try SpoolStore(paths: paths)
let reconciled = try await reopened.currentSession()
#expect(reconciled.captures.map(\.id) == [keep.id])
#expect(reconciled.captures.contains(where: { $0.id == drop.id }) == false)
}
// MARK: - 8. corrupt manifest rebuild
@Test
func corruptManifestIsQuarantinedAndPNGsAreRebuiltIntoANewManifest() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let first = try await store.append(
pngData: try makePNGData(width: 8, height: 6, red: 0.3, green: 0.3, blue: 0.9),
pixelWidth: 8, pixelHeight: 6, scale: 2.0, capturedAt: Date())
let second = try await store.append(
pngData: try makePNGData(width: 9, height: 6, red: 0.9, green: 0.3, blue: 0.3),
pixelWidth: 9, pixelHeight: 6, scale: 2.0, capturedAt: Date())
let session = try await store.currentSession()
let sessionDir = paths.sessionDirectory(session.id)
let manifestURL = sessionDir.appendingPathComponent("session.json")
let garbage = Data("{ not json".utf8)
try AtomicFile.write(garbage, to: manifestURL)
let reopened = try SpoolStore(paths: paths)
let rebuilt = try await reopened.currentSession()
#expect(rebuilt.id == session.id)
let contents = try FileManager.default.contentsOfDirectory(at: sessionDir, includingPropertiesForKeys: nil)
let corruptFiles = contents.filter { $0.lastPathComponent.hasPrefix("session.json.corrupt-") }
#expect(corruptFiles.count == 1)
let quarantined = try Data(contentsOf: try #require(corruptFiles.first))
#expect(quarantined == garbage)
let names = Set(rebuilt.captures.map(\.fileName))
#expect(names.contains(first.fileName))
#expect(names.contains(second.fileName))
let recoveredFirst = try #require(rebuilt.captures.first { $0.fileName == first.fileName })
#expect(recoveredFirst.pixelWidth == 8)
#expect(recoveredFirst.pixelHeight == 6)
}
// MARK: - 9. archiveCurrent moves the directory
@Test
func archiveCurrentMovesTheSessionUnderArchiveAndOpensAFreshEmptySession() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 10, height: 8, red: 0.5, green: 0.1, blue: 0.6)
let capture = try await store.append(
pngData: png, pixelWidth: 10, pixelHeight: 8, scale: 1.0, capturedAt: Date())
let open = try await store.currentSession()
let archived = try await store.archiveCurrent(pdfFileName: "shotdeck-review.pdf")
#expect(archived.state == .archived)
#expect(archived.pdfFileName == "shotdeck-review.pdf")
#expect(archived.id == open.id)
#expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(open.id).path))
let archivedPNG = paths.archiveDirectory(open.id).appendingPathComponent(capture.fileName)
#expect(FileManager.default.fileExists(atPath: archivedPNG.path))
#expect(try Data(contentsOf: archivedPNG) == png)
let current = try await store.currentSession()
#expect(current.id != open.id)
#expect(current.isEmpty)
#expect(current.state == .open)
#expect(FileManager.default.fileExists(
atPath: paths.sessionDirectory(current.id).appendingPathComponent("session.json").path))
}
// MARK: - 10. archiveCurrent on empty throws
@Test
func archiveCurrentOnEmptySessionThrowsAndDoesNotMoveTheDirectory() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let session = try await store.currentSession()
let before = try snapshotFiles(under: root)
do {
_ = try await store.archiveCurrent(pdfFileName: "never.pdf")
Issue.record("expected archiveCurrent on an empty session to throw")
} catch let error as ShotdeckError {
guard case .spoolWriteFailed = error else {
Issue.record("expected spoolWriteFailed, got \(error)")
return
}
} catch {
Issue.record("expected ShotdeckError, got \(error)")
return
}
#expect(FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path))
#expect(try snapshotFiles(under: root) == before)
}
// MARK: - 11. interrupted-archive, both exist
@Test
func interruptedArchiveBothExistSupersedesTheSpoolCopyAndKeepsArchive() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 8, height: 8, red: 0.2, green: 0.2, blue: 0.8)
let capture = try await store.append(
pngData: png, pixelWidth: 8, pixelHeight: 8, scale: 1.0, capturedAt: Date())
let archived = try await store.archiveCurrent(pdfFileName: "sent.pdf")
let archiveDir = paths.archiveDirectory(archived.id)
let spoolCopy = paths.sessionDirectory(archived.id)
try FileManager.default.copyItem(at: archiveDir, to: spoolCopy)
let archivePNGBefore = try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName))
let reopened = try SpoolStore(paths: paths)
_ = try await reopened.currentSession()
#expect(FileManager.default.fileExists(atPath: archiveDir.path))
#expect(!FileManager.default.fileExists(atPath: spoolCopy.path))
#expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == archivePNGBefore)
let spoolEntries = try FileManager.default.contentsOfDirectory(
at: paths.spool, includingPropertiesForKeys: [.isDirectoryKey])
let superseded = spoolEntries.filter {
$0.lastPathComponent.hasPrefix("\(archived.id.uuidString).superseded-")
}
#expect(superseded.count == 1)
let supersededPNG = try #require(superseded.first).appendingPathComponent(capture.fileName)
#expect(FileManager.default.fileExists(atPath: supersededPNG.path))
#expect(try Data(contentsOf: supersededPNG) == png)
}
// MARK: - 12. interrupted-archive, manifest-only
@Test
func interruptedArchiveManifestOnlyCompletesTheMoveIntoArchive() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 6, height: 8, red: 0.7, green: 0.4, blue: 0.1)
let capture = try await store.append(
pngData: png, pixelWidth: 6, pixelHeight: 8, scale: 1.0, capturedAt: Date())
let session = try await store.currentSession()
let marked = session.markArchived(pdfFileName: "partial.pdf")
try AtomicFile.writeJSON(
marked, to: paths.sessionDirectory(session.id).appendingPathComponent("session.json"))
let reopened = try SpoolStore(paths: paths)
let current = try await reopened.currentSession()
#expect(current.id != session.id)
#expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path))
let archiveDir = paths.archiveDirectory(session.id)
#expect(FileManager.default.fileExists(atPath: archiveDir.path))
#expect(FileManager.default.fileExists(atPath: archiveDir.appendingPathComponent(capture.fileName).path))
#expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == png)
let archived = try await reopened.archivedSessions()
#expect(archived.contains(where: { $0.id == session.id }))
}
// MARK: - 13. archivedSessions newest createdAt first
@Test
func archivedSessionsReturnsNewestCreatedAtFirst() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let newest = UUID(uuidString: "00000000-0000-4000-8000-000000000003")!
let oldest = UUID(uuidString: "00000000-0000-4000-8000-000000000001")!
let middle = UUID(uuidString: "00000000-0000-4000-8000-000000000002")!
let tNewest = Date(timeIntervalSince1970: 1_700_000_200)
let tOldest = Date(timeIntervalSince1970: 1_700_000_000)
let tMiddle = Date(timeIntervalSince1970: 1_700_000_100)
try seedSession(id: newest, createdAt: tNewest, state: .archived, directory: paths.archiveDirectory(newest))
try seedSession(id: oldest, createdAt: tOldest, state: .archived, directory: paths.archiveDirectory(oldest))
try seedSession(id: middle, createdAt: tMiddle, state: .archived, directory: paths.archiveDirectory(middle))
let store = try SpoolStore(paths: paths)
let listed = try await store.archivedSessions()
#expect(listed.map(\.id) == [newest, middle, oldest])
}
// MARK: - 14. newest-session tie-break by greater UUID string
@Test
func newestSessionTieBreakPicksTheLexicographicallyGreaterUUID() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let smaller = UUID(uuidString: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")!
let greater = UUID(uuidString: "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB")!
let createdAt = Date(timeIntervalSince1970: 1_700_000_500)
try seedSession(id: smaller, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(smaller))
try seedSession(id: greater, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(greater))
let first = try SpoolStore(paths: paths)
let firstID = try await first.currentSession().id
let second = try SpoolStore(paths: paths)
let secondID = try await second.currentSession().id
#expect(firstID == greater)
#expect(secondID == greater)
}
// MARK: - 15. filename shape
@Test
func appendFilenameMatchesSequenceDashEightHexSuffix() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let capture = try await store.append(
pngData: try makePNGData(width: 4, height: 3, red: 0.1, green: 0.1, blue: 0.1),
pixelWidth: 4, pixelHeight: 3, scale: 1.0, capturedAt: Date())
let hex = String(capture.id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased()
#expect(capture.fileName == "001-\(hex).png")
let session = try await store.currentSession()
let url = await store.imageURL(for: capture, in: session)
#expect(url.lastPathComponent == capture.fileName)
#expect(FileManager.default.fileExists(atPath: url.path))
}
// MARK: - 16. startNewSession
@Test
func startNewSessionThrowsWhenNonEmptyAndMintsANewIdWhenEmpty() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
_ = try await store.append(
pngData: try makePNGData(width: 5, height: 5, red: 0.4, green: 0.2, blue: 0.6),
pixelWidth: 5, pixelHeight: 5, scale: 1.0, capturedAt: Date())
let before = try snapshotFiles(under: root)
let occupied = try await store.currentSession()
do {
_ = try await store.startNewSession()
Issue.record("expected startNewSession to throw while captures are present")
} catch let error as ShotdeckError {
guard case .spoolWriteFailed = error else {
Issue.record("expected spoolWriteFailed, got \(error)")
return
}
} catch {
Issue.record("expected ShotdeckError, got \(error)")
return
}
#expect(try snapshotFiles(under: root) == before)
#expect(try await store.currentSession().id == occupied.id)
_ = try await store.remove(captureID: occupied.captures[0].id)
let emptyID = try await store.currentSession().id
let fresh = try await store.startNewSession()
#expect(fresh.id != emptyID)
#expect(fresh.isEmpty)
#expect(FileManager.default.fileExists(
atPath: paths.sessionDirectory(emptyID).appendingPathComponent("session.json").path))
}
// MARK: - 17. durability-ordering smoke test
@Test
func appendReturnsOnlyAfterThePNGBytesAreAlreadyOnDisk() async throws {
let (root, paths) = try makeIsolatedPaths()
defer { try? FileManager.default.removeItem(at: root) }
let store = try SpoolStore(paths: paths)
let png = try makePNGData(width: 13, height: 11, red: 0.15, green: 0.55, blue: 0.95)
let capture = try await store.append(
pngData: png, pixelWidth: 13, pixelHeight: 11, scale: 2.0, capturedAt: Date())
let session = try await store.currentSession()
let url = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName)
let onDisk = try Data(contentsOf: url)
#expect(onDisk == png)
}
// MARK: - Fixtures
private func makeIsolatedPaths() throws -> (root: URL, paths: AppSupportPaths) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-spool-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: root,
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
return (root, paths)
}
private func makePNGData(
width: Int,
height: Int,
red: CGFloat,
green: CGFloat,
blue: CGFloat
) throws -> Data {
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw FixtureError.pngGenerationFailed
}
context.setFillColor(red: red, green: green, blue: blue, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let image = context.makeImage() else {
throw FixtureError.pngGenerationFailed
}
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else {
throw FixtureError.pngGenerationFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw FixtureError.pngGenerationFailed
}
return buffer as Data
}
private func pngDimensions(at url: URL) -> (width: Int, height: Int)? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?,
let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue
else { return nil }
return (width, height)
}
private func seedSession(
id: UUID,
createdAt: Date,
state: SessionState,
directory: URL
) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let session = CaptureSession(id: id, createdAt: createdAt, state: state, captures: [], pdfFileName: nil)
try AtomicFile.writeJSON(session, to: directory.appendingPathComponent("session.json"))
}
private func snapshotFiles(under root: URL) throws -> [String: Data] {
let fm = FileManager.default
var files: [String: Data] = [:]
guard let enumerator = fm.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { return files }
let rootPath = root.standardizedFileURL.path
for case let url as URL in enumerator {
let values = try url.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { continue }
var relative = url.standardizedFileURL.path
if relative.hasPrefix(rootPath) {
relative = String(relative.dropFirst(rootPath.count))
if relative.hasPrefix("/") { relative = String(relative.dropFirst()) }
}
files[relative] = try Data(contentsOf: url)
}
return files
}
private enum FixtureError: Error {
case pngGenerationFailed
}
+3 -2
View File
@@ -11,7 +11,7 @@ cd "$ROOT"
IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
BUNDLE_ID="ai.flowmaster.shotdeck" BUNDLE_ID="ai.flowmaster.shotdeck"
APP_BUNDLE="${ROOT}/.build/Shotdeck.app" APP_BUNDLE="${ROOT}/.build/Redline.app"
SKIP_SIGN=0 SKIP_SIGN=0
for arg in "$@"; do for arg in "$@"; do
@@ -27,7 +27,7 @@ for arg in "$@"; do
esac esac
done done
echo "==> Building Shotdeck (release)" echo "==> Building Redline (release)"
swift build -c release --product Shotdeck swift build -c release --product Shotdeck
BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck" BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck"
@@ -43,6 +43,7 @@ mkdir -p "${APP_BUNDLE}/Contents/Resources"
cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck" cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck" chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist" 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 if [[ "${SKIP_SIGN}" -eq 1 ]]; then
echo echo
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
APP_BUNDLE="${ROOT}/.build/Redline.app"
STAGING="${ROOT}/.build/dmg-staging"
DMG="${ROOT}/.build/Redline.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 Redline.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}/Redline.app"
ln -s /Applications "${STAGING}/Applications"
echo "==> Creating ${DMG}"
mkdir -p "$(dirname "${DMG}")"
hdiutil create -volname "Redline" -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}/Redline.app" ]]; then
echo "Verification failed: Redline.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}/Redline.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}"
+324
View File
@@ -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 (161024 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 n5, 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)")
+287
View File
@@ -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 <version> [\"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"