diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a383133 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.build/ +.swiftpm/ +Packages/ +*.xcodeproj +xcuserdata/ +DerivedData/ +.DS_Store +.netrc diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..2755b1e --- /dev/null +++ b/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleExecutable + Shotdeck + CFBundleIconFile + AppIcon + CFBundleIdentifier + ai.flowmaster.shotdeck + CFBundleName + Redline + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 2 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Flowmaster FZC LLC. All rights reserved. + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e8361c4 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Shotdeck", + platforms: [ + .macOS(.v14), + ], + products: [ + .library(name: "ShotdeckCore", targets: ["ShotdeckCore"]), + .executable(name: "Shotdeck", targets: ["Shotdeck"]), + ], + dependencies: [], + targets: [ + .target( + name: "ShotdeckCore", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "Shotdeck", + dependencies: ["ShotdeckCore"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "ShotdeckCoreTests", + dependencies: ["ShotdeckCore"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + ] +) diff --git a/README.md b/README.md index 3c0ab7f..584de7b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ -# shotdeck +# Redline -macOS menu-bar app: hotkey region capture into a review PDF with PASS/FAIL boxes, AirDrop send, annotation-return tracking \ No newline at end of file +A macOS menu-bar app that captures a remembered screen region, builds a one-screenshot-per-page PDF, AirDrops it to an iPad for markup, then watches for the annotated file to come back. + +## Hotkeys + +- **⌥⇧1** — pick a new region, then capture it. +- **⌥⇧2** — capture the remembered region instantly (no picker). + +## Screen Recording permission + +On first launch macOS asks once for Screen Recording. Grant it at **System Settings > Privacy & Security > Screen Recording**. You never have to do this again as long as the app is not re-signed with a different identity. + +The grant is bound to the bundle identifier `ai.flowmaster.shotdeck` plus the code signature. Changing either one forces a fresh prompt. + +## Build + +```bash +swift build && swift test +./scripts/build-app.sh # signed .app for daily use +open .build/Redline.app +``` diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..f3396f0 Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift new file mode 100644 index 0000000..47897ad --- /dev/null +++ b/Sources/Shotdeck/AppModel.swift @@ -0,0 +1,362 @@ +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 + /// Absolute URL of the PDF composed this run, if any. Used by "Reveal last PDF". + public private(set) var lastComposedPDFURL: URL? + /// Currently bound capture combo (the last one Carbon accepted, or the preferred load). + private(set) var captureHotkey: HotkeyPreference + var hotkeyDisplayString: String { captureHotkey.displayString } + /// Staged update offered in the menu. Set only after checksum + payload validation. + public private(set) var updateAvailable: (version: String, notes: String)? + + let paths: AppSupportPaths + let spool: SpoolStore + let composer: PDFComposer + let capturer: ScreenCapturer + let hotkeys: HotkeyCenter + let picker: RegionPickerController + let ledger: ReturnLedger + let watcher: ReturnWatcher + let updateChecker: UpdateChecker + + 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() + self.updateChecker = UpdateChecker() + self.updateChecker.onChecked = { [weak self] in + guard let self else { return } + self.updateAvailable = self.updateChecker.availableUpdate + if let message = self.updateChecker.statusMessage { + self.setStatus(message) + } + } + } + + // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. + + 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) + } + func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } + + /// True when a last-composed PDF path is known this run, or the newest + /// `Redline-*.pdf` in the outbox exists on disk. + var canRevealLastPDF: Bool { revealablePDFURL() != nil } + + public func revealLastPDF() { + guard let url = revealablePDFURL() else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + func revealablePDFURL() -> URL? { + if let last = lastComposedPDFURL, FileManager.default.fileExists(atPath: last.path) { + return last + } + return newestOutboxRedlinePDF() + } + + func newestOutboxRedlinePDF() -> URL? { + let fm = FileManager.default + let items = (try? fm.contentsOfDirectory( + at: outboxURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + let matches = items.filter { + $0.lastPathComponent.hasPrefix("Redline-") && $0.pathExtension.lowercased() == "pdf" + } + return matches.max { a, b in + let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + return da < db + } + } + + public var iconState: MenuIconState { + if !screenRecordingGranted { return .recordingMissing } + 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.") + } + + let skipSchedule = + ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil + || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil + || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil + if !skipSchedule { + updateChecker.startSchedule() + } + } + + /// Installs the staged update over `/Applications/Redline.app` and relaunches. + /// Does nothing unless the user clicked the menu row. + public func installUpdate() { + updateChecker.installStaged() + } + + /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new + /// 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) in + picker.pick { picked in cont.resume(returning: picked) } + } + guard let picked = target else { + setStatus("No region selected.") + return + } + persistRegion(picked) + } + guard let region = target else { return } + + do { + let image = try await capturer.capture(region) + let capture = try await spool.append( + pngData: image.pngData, + pixelWidth: image.pixelWidth, + pixelHeight: image.pixelHeight, + scale: image.scale, + capturedAt: Date() + ) + replaceSession(try await spool.currentSession()) + setStatus("Captured page \(capture.sequence).") + } catch { + screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted + setStatus((error as? ShotdeckError)?.errorDescription ?? "The screenshot could not be taken.") + } + } + + public func rePickRegion() async { + let picked = await withCheckedContinuation { (cont: CheckedContinuation) in + picker.pick { cont.resume(returning: $0) } + } + guard let picked else { return } + persistRegion(picked) + setStatus("Region set: \(Int(picked.rect.width)) × \(Int(picked.rect.height)).") + } + + public func removeCapture(id: UUID) async { + do { + // D-11: SpoolStore.remove MOVES the PNG to /removed/; it is never unlinked. + replaceSession(try await spool.remove(captureID: id)) + } catch { + setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not remove that capture.") + } + } + + public func copyCommentedLinks() async { + do { + let text = try await ledger.clipboardText() + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + let count = commentedReturns.count + setStatus("Copied \(count) link\(count == 1 ? "" : "s").") + } catch { + setStatus((error as? ShotdeckError)?.errorDescription ?? "Nothing to copy.") + } + } + + public func openSpoolFolder() { + NSWorkspace.shared.open(paths.spool) + } + + public func openScreenRecordingSettings() { + guard let url = URL(string: + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { + setStatus("Could not open System Settings.") + return + } + NSWorkspace.shared.open(url) + } + + 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 + } +} diff --git a/Sources/Shotdeck/HotkeyPreference.swift b/Sources/Shotdeck/HotkeyPreference.swift new file mode 100644 index 0000000..e0c53ea --- /dev/null +++ b/Sources/Shotdeck/HotkeyPreference.swift @@ -0,0 +1,151 @@ +import AppKit +import Carbon.HIToolbox +import Foundation + +/// User-chosen capture hotkey. `modifiers` are Carbon bits (`cmdKey`, `optionKey`, +/// `shiftKey`, `controlKey`), matching `HotkeyCenter.register`. +struct HotkeyPreference: Codable, Equatable, Sendable { + var keyCode: UInt32 + var modifiers: UInt32 + + static let defaultsKey = "ai.flowmaster.shotdeck.hotkey" + + /// ⌥⇧2 — kVK_ANSI_2 (19) with optionKey|shiftKey. + static let `default` = HotkeyPreference( + keyCode: 19, + modifiers: UInt32(optionKey) | UInt32(shiftKey) + ) + + static func load(defaults: UserDefaults = .standard) -> HotkeyPreference { + guard let data = defaults.data(forKey: defaultsKey), + let decoded = try? JSONDecoder().decode(HotkeyPreference.self, from: data), + decoded.modifiers != 0 + else { return .default } + return decoded + } + + func save(defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.defaultsKey) + } + + /// ⌘⌥⇧⌃ in that order, then a name for common keycodes. + var displayString: String { + var s = "" + if modifiers & UInt32(cmdKey) != 0 { s += "⌘" } + if modifiers & UInt32(optionKey) != 0 { s += "⌥" } + if modifiers & UInt32(shiftKey) != 0 { s += "⇧" } + if modifiers & UInt32(controlKey) != 0 { s += "⌃" } + s += Self.keyName(for: keyCode) + return s + } + + /// `nil` when the event is modifier-only or has no ⌘⌥⇧⌃ flags. + static func fromKeyEvent(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> HotkeyPreference? { + switch Int(keyCode) { + case kVK_Shift, kVK_RightShift, + kVK_Command, kVK_RightCommand, + kVK_Option, kVK_RightOption, + kVK_Control, kVK_RightControl, + kVK_CapsLock, kVK_Function: + return nil + default: + break + } + var carbon: UInt32 = 0 + if modifierFlags.contains(.command) { carbon |= UInt32(cmdKey) } + if modifierFlags.contains(.option) { carbon |= UInt32(optionKey) } + if modifierFlags.contains(.shift) { carbon |= UInt32(shiftKey) } + if modifierFlags.contains(.control) { carbon |= UInt32(controlKey) } + guard carbon != 0 else { return nil } + return HotkeyPreference(keyCode: UInt32(keyCode), modifiers: carbon) + } + + private static func keyName(for keyCode: UInt32) -> String { + switch Int(keyCode) { + case kVK_ANSI_A: return "A" + case kVK_ANSI_B: return "B" + case kVK_ANSI_C: return "C" + case kVK_ANSI_D: return "D" + case kVK_ANSI_E: return "E" + case kVK_ANSI_F: return "F" + case kVK_ANSI_G: return "G" + case kVK_ANSI_H: return "H" + case kVK_ANSI_I: return "I" + case kVK_ANSI_J: return "J" + case kVK_ANSI_K: return "K" + case kVK_ANSI_L: return "L" + case kVK_ANSI_M: return "M" + case kVK_ANSI_N: return "N" + case kVK_ANSI_O: return "O" + case kVK_ANSI_P: return "P" + case kVK_ANSI_Q: return "Q" + case kVK_ANSI_R: return "R" + case kVK_ANSI_S: return "S" + case kVK_ANSI_T: return "T" + case kVK_ANSI_U: return "U" + case kVK_ANSI_V: return "V" + case kVK_ANSI_W: return "W" + case kVK_ANSI_X: return "X" + case kVK_ANSI_Y: return "Y" + case kVK_ANSI_Z: return "Z" + case kVK_ANSI_0: return "0" + case kVK_ANSI_1: return "1" + case kVK_ANSI_2: return "2" + case kVK_ANSI_3: return "3" + case kVK_ANSI_4: return "4" + case kVK_ANSI_5: return "5" + case kVK_ANSI_6: return "6" + case kVK_ANSI_7: return "7" + case kVK_ANSI_8: return "8" + case kVK_ANSI_9: return "9" + case kVK_ANSI_Equal: return "=" + case kVK_ANSI_Minus: return "-" + case kVK_ANSI_RightBracket: return "]" + case kVK_ANSI_LeftBracket: return "[" + case kVK_ANSI_Quote: return "'" + case kVK_ANSI_Semicolon: return ";" + case kVK_ANSI_Backslash: return "\\" + case kVK_ANSI_Comma: return "," + case kVK_ANSI_Slash: return "/" + case kVK_ANSI_Period: return "." + case kVK_ANSI_Grave: return "`" + case kVK_Space: return "Space" + case kVK_Return: return "Return" + case kVK_Tab: return "Tab" + case kVK_Delete: return "Delete" + case kVK_ForwardDelete: return "Fwd Delete" + case kVK_Escape: return "Esc" + case kVK_Home: return "Home" + case kVK_End: return "End" + case kVK_PageUp: return "Page Up" + case kVK_PageDown: return "Page Down" + case kVK_Help: return "Help" + case kVK_LeftArrow: return "←" + case kVK_RightArrow: return "→" + case kVK_DownArrow: return "↓" + case kVK_UpArrow: return "↑" + case kVK_F1: return "F1" + case kVK_F2: return "F2" + case kVK_F3: return "F3" + case kVK_F4: return "F4" + case kVK_F5: return "F5" + case kVK_F6: return "F6" + case kVK_F7: return "F7" + case kVK_F8: return "F8" + case kVK_F9: return "F9" + case kVK_F10: return "F10" + case kVK_F11: return "F11" + case kVK_F12: return "F12" + case kVK_F13: return "F13" + case kVK_F14: return "F14" + case kVK_F15: return "F15" + case kVK_F16: return "F16" + case kVK_F17: return "F17" + case kVK_F18: return "F18" + case kVK_F19: return "F19" + case kVK_F20: return "F20" + default: return "Key \(keyCode)" + } + } +} diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift new file mode 100644 index 0000000..5b239cb --- /dev/null +++ b/Sources/Shotdeck/MenuBarView.swift @@ -0,0 +1,196 @@ +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) { + if let update = model.updateAvailable { + Button { + model.installUpdate() + } label: { + actionLabel("Update to \(update.version)") + .foregroundStyle(Color.accentColor) + } + } + + Button { + let anchor = NSApp.keyWindow?.contentView + if let sender = model as? SendCapable { + 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 { + model.revealLastPDF() + } label: { + actionLabel("Reveal last PDF") + } + .disabled(!model.canRevealLastPDF) + + 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 } + } +} diff --git a/Sources/Shotdeck/PanelSnapshot.swift b/Sources/Shotdeck/PanelSnapshot.swift new file mode 100644 index 0000000..e6580e3 --- /dev/null +++ b/Sources/Shotdeck/PanelSnapshot.swift @@ -0,0 +1,295 @@ +import AppKit +import CoreGraphics +import Darwin +import Foundation +import ImageIO +import PDFKit +import SwiftUI +import ShotdeckCore + +/// Headless offscreen renderer for `MenuBarView` / `SettingsView`. +/// Driven by `SHOTDECK_SNAPSHOT_DIR`; never touches the real Application Support spool. +enum PanelSnapshot { + private static let panelWidth: CGFloat = 340 + + /// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the + /// env var is unset; otherwise writes the six panel PNGs, prints each path, and `exit`s. + @MainActor + static func runIfRequested() { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"], + !raw.isEmpty + else { return } + + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + + Task { @MainActor in + do { + try await captureAll(to: URL(fileURLWithPath: raw, isDirectory: true)) + exit(0) + } catch { + fputs("PanelSnapshot failed: \(error)\n", stderr) + exit(1) + } + } + + app.run() + exit(0) + } + + @MainActor + private static func captureAll(to directory: URL) async throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let (model, root) = try makeIsolatedModel() + defer { try? FileManager.default.removeItem(at: root) } + + let region = sampleRegion() + + // 01 — Screen Recording missing (no public mutator; snapshot-only seam). + model.snapshotSetScreenRecordingGranted(false) + model.replaceRegion(nil) + try renderMenuBar(model: model, to: directory, name: "01-no-permission") + + // 02 — granted, no region picked. + model.snapshotSetScreenRecordingGranted(true) + model.replaceRegion(nil) + try renderMenuBar(model: model, to: directory, name: "02-no-region") + + // 03 — region set, empty session. + model.replaceRegion(region) + try renderMenuBar(model: model, to: directory, name: "03-empty-session") + + // 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode. + let swatches: [(CGFloat, CGFloat, CGFloat)] = [ + (0.85, 0.22, 0.18), + (0.18, 0.62, 0.32), + (0.16, 0.38, 0.82), + ] + for (red, green, blue) in swatches { + let png = try makePNGData(width: 192, height: 108, red: red, green: green, blue: blue) + _ = try await model.spool.append( + pngData: png, + pixelWidth: 192, + pixelHeight: 108, + scale: 2.0, + capturedAt: Date() + ) + } + model.replaceSession(try await model.spool.currentSession()) + try renderMenuBar(model: model, to: directory, name: "04-captures-present") + + // 05 — two inspected PDFs in the temp ledger, one marked / one not. + try await seedReturns(model: model) + try renderMenuBar(model: model, to: directory, name: "05-returns-present") + + // 06 — SettingsView against the same isolated model. + try render( + SettingsView().environment(model), + to: directory.appendingPathComponent("panel-06-settings.png") + ) + } + + @MainActor + private static func renderMenuBar(model: AppModel, to directory: URL, name: String) throws { + try render( + MenuBarView().environment(model), + to: directory.appendingPathComponent("panel-\(name).png") + ) + } + + @MainActor + private static func render(_ view: some View, to url: URL) throws { + let wrapped = view + .frame(width: panelWidth, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .background(Color(nsColor: .windowBackgroundColor)) + + let hosting = NSHostingView(rootView: wrapped) + hosting.wantsLayer = true + hosting.appearance = NSAppearance(named: .aqua) + + let window = NSWindow( + contentRect: NSRect(x: -10_000, y: -10_000, width: panelWidth, height: 64), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.backgroundColor = .windowBackgroundColor + window.isOpaque = true + window.alphaValue = 0 + window.contentView = hosting + window.orderBack(nil) + + hosting.layoutSubtreeIfNeeded() + var size = hosting.fittingSize + if size.height < 1 { + size.height = hosting.intrinsicContentSize.height + } + if size.height < 1 { size.height = 240 } + size.width = panelWidth + size.height = ceil(size.height) + + hosting.setFrameSize(size) + window.setContentSize(size) + hosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + + let bounds = hosting.bounds + guard let rep = hosting.bitmapImageRepForCachingDisplay(in: bounds) else { + throw SnapshotError.renderFailed(url.lastPathComponent) + } + hosting.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + throw SnapshotError.encodeFailed(url.lastPathComponent) + } + try png.write(to: url) + print(url.path) + fflush(stdout) + + window.contentView = nil + window.close() + } + + @MainActor + private static func makeIsolatedModel() throws -> (model: AppModel, root: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-panel-snapshot-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder) + return (model, root) + } + + @MainActor + private static func seedReturns(model: AppModel) async throws { + let watch = model.paths.watchFolder + let unmarkedURL = watch.appendingPathComponent("Shotdeck-20260901-120000.pdf") + let markedURL = watch.appendingPathComponent("Shotdeck-20260901-120100.pdf") + try writeShotdeckPDF(to: unmarkedURL, marked: false) + try writeShotdeckPDF(to: markedURL, marked: true) + + let unmarked = try AnnotationInspector.inspect(fileURL: unmarkedURL) + let marked = try AnnotationInspector.inspect(fileURL: markedURL) + try await model.ledger.record(unmarked) + try await model.ledger.record(marked) + model.setReturns( + all: try await model.ledger.all(), + commented: try await model.ledger.commented() + ) + } + + private static func sampleRegion() -> CaptureRegion { + CaptureRegion( + displayID: CGMainDisplayID(), + rect: CGRect(x: 120, y: 80, width: 800, height: 600), + capturedScale: 2.0 + ) + } + + private static func makePNGData( + width: Int, + height: Int, + red: CGFloat, + green: CGFloat, + blue: CGFloat + ) throws -> Data { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw SnapshotError.pngGenerationFailed + } + context.setFillColor(red: red, green: green, blue: blue, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + throw SnapshotError.pngGenerationFailed + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + buffer, + "public.png" as CFString, + 1, + nil + ) else { + throw SnapshotError.pngGenerationFailed + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw SnapshotError.pngGenerationFailed + } + return buffer as Data + } + + private static func writeShotdeckPDF(to url: URL, marked: Bool) throws { + let document = PDFDocument() + let page = PDFPage() + page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox) + document.insert(page, at: 0) + document.documentAttributes = [ + PDFDocumentAttribute.creatorAttribute: "Shotdeck", + PDFDocumentAttribute.subjectAttribute: UUID().uuidString, + ] + if marked { + let annotation = PDFAnnotation( + bounds: CGRect(x: 72, y: 400, width: 220, height: 36), + forType: .highlight, + withProperties: nil + ) + page.addAnnotation(annotation) + } + guard document.write(to: url) else { + throw SnapshotError.pdfWriteFailed(url.lastPathComponent) + } + } +} + +extension AppModel { + /// `screenRecordingGranted` is `public private(set)` with no seam mutator. + /// Snapshot-only: the KeyPath setter exists at runtime; compile-time access is file-private. + func snapshotSetScreenRecordingGranted(_ granted: Bool) { + // private(set) types this as KeyPath; the setter still exists on the @Observable storage. + let writable: ReferenceWritableKeyPath = unsafeBitCast( + \AppModel.screenRecordingGranted, to: ReferenceWritableKeyPath.self + ) + self[keyPath: writable] = granted + } +} + +private enum SnapshotError: Error, CustomStringConvertible { + case renderFailed(String) + case encodeFailed(String) + case pngGenerationFailed + case pdfWriteFailed(String) + + var description: String { + switch self { + case .renderFailed(let name): return "bitmapImageRepForCachingDisplay failed for \(name)" + case .encodeFailed(let name): return "PNG encode failed for \(name)" + case .pngGenerationFailed: return "CoreGraphics PNG generation failed" + case .pdfWriteFailed(let name): return "could not write \(name)" + } + } +} diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift new file mode 100644 index 0000000..b30c703 --- /dev/null +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -0,0 +1,540 @@ +import AppKit +import CoreGraphics +import Darwin +import Foundation +import ImageIO +import ShotdeckCore + +/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`. +/// 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() + // Hop off this MainActor job so the SEND-TRUTH Task can run; do not + // exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then + // chains to UPDATE-SELFTEST (or exits if that phase is not requested). + runSendTruthPhase() + } + + /// 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) + } + + /// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet. + /// Fail path must leave the session open in the temp spool; success path archives + /// and mints a fresh empty session. Scheduled as a new MainActor job because this + /// function is called from inside `execute()` — a nested run-loop wait would never + /// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting. + private static func runSendTruthPhase() { + Task { @MainActor in + do { + try await executeSendTruth() + print("SEND-TRUTH PASS") + fflush(stdout) + if !startUpdateSelfTestIfRequested() { + exit(0) + } + } catch { + print("SEND-TRUTH FAIL \(error)") + fflush(stdout) + exit(1) + } + } + } + + private static func executeSendTruth() async throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder) + + let png = try makeTinyPNGData() + _ = try await model.spool.append( + pngData: png, + pixelWidth: 64, + pixelHeight: 48, + scale: 1, + capturedAt: Date() + ) + model.replaceSession(try await model.spool.currentSession()) + let openID = model.session.id + guard !model.session.isEmpty else { + sendTruthFail("seeded session was empty") + } + + let pending = try await model.composePDFForSend() + guard fm.fileExists(atPath: pending.fileURL.path) else { + sendTruthFail("PDF was not written") + } + + model.handleDidFailToShareItems(fileName: pending.fileName) + let still = try await model.spool.currentSession() + guard still.id == openID, !still.isEmpty, still.state == .open else { + sendTruthFail("fail path archived or replaced the session") + } + let spoolDir = paths.sessionDirectory(openID) + guard fm.fileExists(atPath: spoolDir.path) else { + sendTruthFail("fail path: session missing from temp spool") + } + guard let status = model.statusLine, status.contains("nothing was sent") else { + sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")") + } + + await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount) + let fresh = try await model.spool.currentSession() + guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else { + sendTruthFail("success path did not mint a fresh empty session") + } + let archived = try await model.spool.archivedSessions() + guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else { + sendTruthFail("success path did not archive the session") + } + let archiveDir = paths.archiveDirectory(openID) + guard fm.fileExists(atPath: archiveDir.path) else { + sendTruthFail("success path: archive dir missing") + } + guard !fm.fileExists(atPath: spoolDir.path) else { + sendTruthFail("success path: session still in spool") + } + } + + private static func makeTinyPNGData() throws -> Data { + let width = 64 + let height = 48 + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + sendTruthFail("could not create PNG context") + } + context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + sendTruthFail("could not make CGImage") + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + buffer, + "public.png" as CFString, + 1, + nil + ) else { + sendTruthFail("could not create PNG destination") + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + sendTruthFail("could not finalize PNG") + } + return buffer as Data + } + + private static func sendTruthFail(_ reason: String) -> Never { + print("SEND-TRUTH FAIL \(reason)") + fflush(stdout) + exit(1) + } + + /// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via + /// `checkNow`, then `installStaged` into the env dir — never `/Applications`. + /// Returns true when the async phase was scheduled (it calls `exit` itself). + @discardableResult + private static func startUpdateSelfTestIfRequested() -> Bool { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"], + !raw.isEmpty + else { return false } + + let output = URL(fileURLWithPath: raw, isDirectory: true) + Task { @MainActor in + do { + try await runUpdateSelfTest(outputDirectory: output) + print("UPDATE-SELFTEST PASS version=99.0.0") + fflush(stdout) + exit(0) + } catch let error as UpdateSelfTestError { + updateFail(error.description) + } catch { + updateFail(String(describing: error)) + } + } + return true + } + + private static func runUpdateSelfTest(outputDirectory: URL) async throws { + let fm = FileManager.default + try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + guard let sourceApp = ownAppBundleURL() else { + throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))") + } + + let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true) + if fm.fileExists(atPath: payload.path) { + try fm.removeItem(at: payload) + } + try fm.createDirectory(at: payload, withIntermediateDirectories: true) + let fakeApp = payload.appendingPathComponent("Redline.app") + try fm.copyItem(at: sourceApp, to: fakeApp) + + let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist") + let plistData = try Data(contentsOf: plistURL) + guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else { + throw UpdateSelfTestError.detail("could not parse copied Info.plist") + } + plist["CFBundleShortVersionString"] = "99.0.0" + let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try rewritten.write(to: plistURL) + + let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip") + if fm.fileExists(atPath: zipURL.path) { + try fm.removeItem(at: zipURL) + } + try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) + + let zipData = try Data(contentsOf: zipURL) + let hex = UpdateChecker.sha256Hex(zipData) + + let appcastURL = outputDirectory.appendingPathComponent("appcast.json") + let appcast: [String: String] = [ + "version": "99.0.0", + "zipURL": zipURL.absoluteString, + "sha256": hex, + "notes": "UPDATE-SELFTEST", + ] + let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) + try appcastData.write(to: appcastURL) + + let defaults = UserDefaults.standard + let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) + defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) + defer { + if let previous { + defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey) + } else { + defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) + } + } + + let (model, isolatedRoot) = try makeIsolatedUpdateModel() + defer { try? fm.removeItem(at: isolatedRoot) } + + await model.updateChecker.checkNow() + + guard model.updateAvailable?.version == "99.0.0" else { + throw UpdateSelfTestError.detail( + "updateAvailable=\(model.updateAvailable?.version ?? "nil")" + ) + } + guard let staged = model.updateChecker.stagedAppURL else { + throw UpdateSelfTestError.detail("staged payload missing") + } + guard staged.lastPathComponent == "Redline.app" else { + throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)") + } + let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck") + guard fm.fileExists(atPath: stagedExe.path) else { + throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing") + } + + let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true) + if fm.fileExists(atPath: targetRoot.path) { + try fm.removeItem(at: targetRoot) + } + let target = targetRoot.appendingPathComponent("Redline.app") + model.updateChecker.installStaged(to: target) + + let installedPlist = target.appendingPathComponent("Contents/Info.plist") + guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any], + let installedVersion = installed["CFBundleShortVersionString"] as? String + else { + throw UpdateSelfTestError.detail("installed Info.plist unreadable") + } + guard installedVersion == "99.0.0" else { + throw UpdateSelfTestError.detail("installed version \(installedVersion)") + } + } + + private static func ownAppBundleURL() -> URL? { + let bundle = Bundle.main.bundleURL + if bundle.pathExtension == "app" { return bundle } + let up3 = bundle + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + if up3.pathExtension == "app" { return up3 } + return nil + } + + private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + return (model, root) + } + + private static func runDitto(arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = arguments + let err = Pipe() + process.standardError = err + process.standardOutput = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw UpdateSelfTestError.detail("ditto failed: \(message)") + } + } + + private static func updateFail(_ detail: String) -> Never { + print("UPDATE-SELFTEST FAIL \(detail)") + fflush(stdout) + exit(1) + } + + private static func interpolate(_ step: Int) -> NSPoint { + let t = CGFloat(step) / CGFloat(dragSteps) + return NSPoint( + 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) + } +} + +private enum UpdateSelfTestError: Error, CustomStringConvertible { + case detail(String) + var description: String { + switch self { + case .detail(let s): return s + } + } +} diff --git a/Sources/Shotdeck/RegionPickerController.swift b/Sources/Shotdeck/RegionPickerController.swift new file mode 100644 index 0000000..114fdcf --- /dev/null +++ b/Sources/Shotdeck/RegionPickerController.swift @@ -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) + } +} diff --git a/Sources/Shotdeck/RegionPickerWindow.swift b/Sources/Shotdeck/RegionPickerWindow.swift new file mode 100644 index 0000000..5ffaa3c --- /dev/null +++ b/Sources/Shotdeck/RegionPickerWindow.swift @@ -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 + ) + } +} diff --git a/Sources/Shotdeck/ReturnsList.swift b/Sources/Shotdeck/ReturnsList.swift new file mode 100644 index 0000000..7df705a --- /dev/null +++ b/Sources/Shotdeck/ReturnsList.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +func newestReturnsForDisplay(_ returns: [ReturnedDocument], limit: Int = 8) -> [ReturnedDocument] { + Array(returns.sorted { $0.detectedAt > $1.detectedAt }.prefix(limit)) +} + +func returnMarkLabel(_ document: ReturnedDocument) -> String { + guard document.isCommented else { return "not marked" } + let count = document.annotatedPages.count + return "\(count) page\(count == 1 ? "" : "s") marked" +} + +struct ReturnsList: View { + let returns: [ReturnedDocument] + + var body: some View { + let visible = newestReturnsForDisplay(returns) + if visible.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Came back from your device") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(visible) { document in + Button { + NSWorkspace.shared.activateFileViewerSelecting([document.fileURL]) + } label: { + HStack(spacing: 8) { + Text(document.fileURL.lastPathComponent) + .lineLimit(1) + Spacer(minLength: 8) + Text(returnMarkLabel(document)) + .font(.caption) + .foregroundStyle(document.isCommented ? .primary : .secondary) + } + } + .buttonStyle(.plain) + .help(DubaiTime.stamp(document.detectedAt)) + } + } + } + } +} + +extension AppModel: ReturnsSectionProviding { + public func returnsSection() -> AnyView { + guard !allReturns.isEmpty else { return AnyView(EmptyView()) } + return AnyView(ReturnsList(returns: allReturns)) + } +} diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift new file mode 100644 index 0000000..d4d818a --- /dev/null +++ b/Sources/Shotdeck/SendController.swift @@ -0,0 +1,120 @@ +import AppKit +import Darwin +import Foundation +import ShotdeckCore + +/// Result of composing a send PDF. Kept so the self-test can drive the share +/// outcome without presenting a real AirDrop sheet. +struct ComposedSend: Sendable { + let fileName: String + let fileURL: URL + let pageCount: Int +} + +extension AppModel: SendCapable { + public func send(anchor: NSView?) async { + guard !session.isEmpty, !isSending else { return } + setSending(true) + + let pending: ComposedSend + do { + pending = try await composePDFForSend() + } catch { + // Never unlink the published PDF, and never unlink the temp file either: + // a rename failure would leave the complete document at the temp name. + setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + setSending(false) + return + } + + guard let anchor else { + handleDidFailToShareItems(fileName: pending.fileName) + setSending(false) + return + } + + do { + try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in + guard let self else { return } + if success { + await self.handleDidShareItems( + fileName: pending.fileName, + pageCount: pending.pageCount + ) + } else { + self.handleDidFailToShareItems(fileName: pending.fileName) + } + self.setSending(false) + } + } catch { + // canPerform false, no service, or no visible window: same as cancel. + handleDidFailToShareItems(fileName: pending.fileName) + setSending(false) + } + } + + /// Writes the PDF to the outbox and records its path. Does not archive the session + /// and does not present AirDrop — that happens only after the share completes. + func composePDFForSend() async throws -> ComposedSend { + let workingSession = session + let composer = self.composer + // Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect. + 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))" + + // 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 + + guard FileManager.default.fileExists(atPath: finalURL.path) else { + throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") + } + rememberLastComposedPDF(finalURL) + return ComposedSend( + fileName: fileName, + fileURL: finalURL, + pageCount: workingSession.captures.count + ) + } + + /// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam. + func handleDidShareItems(fileName: String, pageCount: Int) async { + guard !session.isEmpty else { return } + do { + _ = try await spool.archiveCurrent(pdfFileName: fileName) + replaceSession(try await spool.currentSession()) + let pageWord = pageCount == 1 ? "page" : "pages" + setStatus("Sent — \(pageCount) \(pageWord).") + } catch { + setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.") + } + } + + /// `NSSharingServiceDelegate.sharingService(_:didFailToShareItems:error:)` seam, + /// also used when `canPerform` is false or the user cancels. Does not archive. + func handleDidFailToShareItems(fileName: String) { + setStatus( + "AirDrop didn't complete — nothing was sent. Your captures are still here; the PDF is on your \(outboxDisplayName) as \(fileName)." + ) + } +} diff --git a/Sources/Shotdeck/SessionStrip.swift b/Sources/Shotdeck/SessionStrip.swift new file mode 100644 index 0000000..4786340 --- /dev/null +++ b/Sources/Shotdeck/SessionStrip.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +struct SessionStrip: View { + @Environment(AppModel.self) private var model + + var body: some View { + if model.session.captures.isEmpty { + Text("Nothing captured yet.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 6) { + ForEach(model.session.captures) { capture in + SessionThumb(capture: capture) + } + } + } + .frame(height: 64) + } + } +} + +private struct SessionThumb: View { + @Environment(AppModel.self) private var model + let capture: Capture + @State private var hovering = false + + var body: some View { + ZStack(alignment: .topLeading) { + thumbnail + Text("\(capture.sequence)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.black.opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .padding(3) + if hovering { + VStack { + Spacer() + Button("Remove") { + Task { await model.removeCapture(id: capture.id) } + } + .font(.system(size: 10, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 3) + .background(.black.opacity(0.7)) + } + } + } + .frame(height: 64) + .clipped() + .onHover { hovering = $0 } + .help(DubaiTime.stamp(capture.capturedAt)) + } + + @ViewBuilder + private var thumbnail: some View { + let url = model.paths.sessionDirectory(model.session.id).appendingPathComponent(capture.fileName) + if let image = NSImage(contentsOf: url) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 64) + } else { + Rectangle() + .fill(Color.secondary.opacity(0.2)) + .frame(width: 64, height: 64) + } + } +} diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift new file mode 100644 index 0000000..4f33f99 --- /dev/null +++ b/Sources/Shotdeck/SettingsView.swift @@ -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 + } +} diff --git a/Sources/Shotdeck/Sharing.swift b/Sources/Shotdeck/Sharing.swift new file mode 100644 index 0000000..7d26be0 --- /dev/null +++ b/Sources/Shotdeck/Sharing.swift @@ -0,0 +1,114 @@ +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). + /// + /// `onFinished` is invoked on the main actor when the sheet completes: `true` for + /// `didShareItems`, `false` for `didFailToShareItems` (including user cancel). + static func airDrop( + fileURL: URL, + from view: NSView, + onFinished: @escaping @MainActor @Sendable (Bool) async -> Void + ) throws { + guard let service = NSSharingService(named: .sendViaAirDrop), + service.canPerform(withItems: [fileURL]) else { + throw ShotdeckError.airDropUnavailable + } + // 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, + onFinished: onFinished + ) + 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, so +/// `live` is the strong reference that keeps this object alive until the sheet +/// reports success or failure (including cancel). +@MainActor +private final class AirDropSession: NSObject, NSSharingServiceDelegate { + static var live: [AirDropSession] = [] + + let service: NSSharingService + let window: NSWindow + let view: NSView + let onFinished: @MainActor @Sendable (Bool) async -> Void + private var reported = false + + init( + service: NSSharingService, + window: NSWindow, + view: NSView, + onFinished: @escaping @MainActor @Sendable (Bool) async -> Void + ) { + self.service = service + self.window = window + self.view = view + self.onFinished = onFinished + } + + static func keepAlive(_ session: AirDropSession) { + live.append(session) + } + + private func drop() { + Self.live.removeAll { $0 === self } + } + + private func report(_ success: Bool) { + guard !reported else { return } + reported = true + Task { @MainActor in + await self.onFinished(success) + self.drop() + } + } + + func sharingService( + _ sharingService: NSSharingService, + sourceWindowForShareItems items: [Any], + sharingContentScope: UnsafeMutablePointer + ) -> NSWindow? { + sharingContentScope.pointee = .item + return window + } + + func sharingService( + _ sharingService: NSSharingService, + sourceFrameOnScreenForShareItem item: Any + ) -> NSRect { + let inWindow = view.convert(view.bounds, to: nil) + return window.convertToScreen(inWindow) + } + + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + report(true) + } + + func sharingService( + _ sharingService: NSSharingService, + didFailToShareItems items: [Any], + error: any Error + ) { + report(false) + } +} diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift new file mode 100644 index 0000000..207f51a --- /dev/null +++ b/Sources/Shotdeck/UpdateChecker.swift @@ -0,0 +1,281 @@ +import AppKit +import CryptoKit +import Foundation + +/// Built-in updater. Checks an appcast, stages a verified payload, and installs +/// only when the user clicks the menu row — never automatically. +@MainActor +final class UpdateChecker { + static let appcastURLDefaultsKey = "ai.flowmaster.shotdeck.appcastURL" + static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")! + static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app") + + private(set) var availableUpdate: (version: String, notes: String)? + private(set) var stagedAppURL: URL? + private(set) var statusMessage: String? + + var onChecked: (() -> Void)? + + private let urlSession: URLSession + private var repeatingTimer: Timer? + private var firstCheckTask: Task? + private var isChecking = false + private var stagingDirectory: URL? + + init() { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 15 + config.httpCookieAcceptPolicy = .never + config.httpShouldSetCookies = false + config.httpCookieStorage = nil + config.urlCache = nil + urlSession = URLSession(configuration: config) + } + + /// First check 10 seconds after start, then every 6 hours. Stages only — never installs. + func startSchedule() { + firstCheckTask?.cancel() + firstCheckTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + await self?.checkNow() + } + repeatingTimer?.invalidate() + let timer = Timer(timeInterval: 6 * 60 * 60, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.checkNow() + } + } + RunLoop.main.add(timer, forMode: .common) + repeatingTimer = timer + } + + func checkNow() async { + guard !isChecking else { return } + isChecking = true + defer { isChecking = false } + + let appcast: Appcast + do { + appcast = try await fetchAppcast() + } catch { + statusMessage = "Could not check for updates." + onChecked?() + return + } + + guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { + clearOffer() + statusMessage = nil + onChecked?() + return + } + + do { + try await downloadAndStage(appcast) + availableUpdate = (version: appcast.version, notes: appcast.notes ?? "") + statusMessage = nil + } catch UpdateCheckError.checksumMismatch { + discardStaging() + availableUpdate = nil + statusMessage = "Update file failed the checksum — not installed." + } catch { + discardStaging() + availableUpdate = nil + statusMessage = "The update could not be prepared." + } + onChecked?() + } + + /// Copies the staged app onto `target` with ditto (in place; never deletes the old app). + /// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test + /// can assert the installed Info.plist without killing the process. + func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) { + guard let staged = stagedAppURL else { + statusMessage = "No update is staged." + onChecked?() + return + } + + do { + try FileManager.default.createDirectory( + at: target.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path]) + } catch { + statusMessage = "The update could not be installed." + onChecked?() + return + } + + let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil + if isSelfTest { return } + + do { + try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path]) + } catch { + statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." + onChecked?() + return + } + NSApp.terminate(nil) + } + + static func resolvedAppcastURL() -> URL { + if let env = ProcessInfo.processInfo.environment["REDLINE_APPCAST_URL"], + !env.isEmpty, + let url = URL(string: env) + { + return url + } + if let stored = UserDefaults.standard.string(forKey: appcastURLDefaultsKey), + !stored.isEmpty, + let url = URL(string: stored) + { + return url + } + return defaultAppcastURL + } + + static func currentVersion() -> String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + } + + static func isNewer(_ candidate: String, than current: String) -> Bool { + let a = semverParts(candidate) + let b = semverParts(current) + for i in 0..<3 { + if a[i] != b[i] { return a[i] > b[i] } + } + return false + } + + static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Private + + private struct Appcast: Decodable { + var version: String + var zipURL: URL + var sha256: String + var notes: String? + } + + private enum UpdateCheckError: Error { + case checksumMismatch + case invalidPayload + case httpStatus(Int) + case processFailed(String) + } + + private func fetchAppcast() async throws -> Appcast { + let data = try await fetchData(from: Self.resolvedAppcastURL()) + return try JSONDecoder().decode(Appcast.self, from: data) + } + + private func fetchData(from url: URL) async throws -> Data { + if url.isFileURL { + return try Data(contentsOf: url) + } + let (data, response) = try await urlSession.data(from: url) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw UpdateCheckError.httpStatus(http.statusCode) + } + return data + } + + private func downloadAndStage(_ appcast: Appcast) async throws { + let zipData = try await fetchData(from: appcast.zipURL) + let expected = appcast.sha256.trimmingCharacters(in: .whitespacesAndNewlines) + let actual = Self.sha256Hex(zipData) + guard actual.caseInsensitiveCompare(expected) == .orderedSame else { + throw UpdateCheckError.checksumMismatch + } + + discardStaging() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-update-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + stagingDirectory = root + + let zipURL = root.appendingPathComponent("update.zip") + try zipData.write(to: zipURL) + + let extracted = root.appendingPathComponent("extracted", isDirectory: true) + try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true) + try Self.runProcess( + executable: "/usr/bin/ditto", + arguments: ["-x", "-k", zipURL.path, extracted.path] + ) + + guard let appURL = Self.findRedlineApp(in: extracted) else { + throw UpdateCheckError.invalidPayload + } + let executable = appURL.appendingPathComponent("Contents/MacOS/Shotdeck") + guard FileManager.default.fileExists(atPath: executable.path) else { + throw UpdateCheckError.invalidPayload + } + stagedAppURL = appURL + } + + private func clearOffer() { + availableUpdate = nil + discardStaging() + } + + private func discardStaging() { + if let stagingDirectory { + try? FileManager.default.removeItem(at: stagingDirectory) + } + stagingDirectory = nil + stagedAppURL = nil + } + + private static func findRedlineApp(in directory: URL) -> URL? { + let fm = FileManager.default + let direct = directory.appendingPathComponent("Redline.app") + if fm.fileExists(atPath: direct.path) { return direct } + + guard let enumerator = fm.enumerator( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return nil } + + while let item = enumerator.nextObject() as? URL { + if item.lastPathComponent == "Redline.app" { + return item + } + if item.pathExtension == "app" { + enumerator.skipDescendants() + } + } + return nil + } + + private static func semverParts(_ string: String) -> [Int] { + let core = string.split(separator: "-").first.map(String.init) ?? string + var parts = core.split(separator: ".").prefix(3).map { Int($0) ?? 0 } + while parts.count < 3 { parts.append(0) } + return parts + } + + private static func runProcess(executable: String, arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let err = Pipe() + process.standardError = err + process.standardOutput = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw UpdateCheckError.processFailed("\(executable) failed: \(message)") + } + } +} diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift new file mode 100644 index 0000000..3b61e42 --- /dev/null +++ b/Sources/Shotdeck/main.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`. +// App.main() is the equivalent entry point. +if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { + MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } +} +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) + ) + } +} diff --git a/Sources/ShotdeckCore/Capture/CaptureRegion.swift b/Sources/ShotdeckCore/Capture/CaptureRegion.swift new file mode 100644 index 0000000..d5a7ab2 --- /dev/null +++ b/Sources/ShotdeckCore/Capture/CaptureRegion.swift @@ -0,0 +1,58 @@ +import Foundation +import AppKit // NSScreen, NSDeviceDescriptionKey — macOS-only target, no portability concern + +/// A remembered rectangle, in CoreGraphics global display coordinates (origin top-left, +/// y increasing downward — see the conversion note below). +public struct CaptureRegion: Codable, Sendable, Equatable { + public let displayID: CGDirectDisplayID + public let rect: CGRect + public let capturedScale: CGFloat + + public init(displayID: CGDirectDisplayID, rect: CGRect, capturedScale: CGFloat) { + self.displayID = displayID + self.rect = rect + self.capturedScale = capturedScale + } + + /// Converts an AppKit rect in GLOBAL AppKit coordinates (bottom-left origin, y increasing + /// upward, anchored at the bottom-left of the PRIMARY screen — exactly what + /// `NSWindow.frame`, `NSEvent.mouseLocation` and `NSScreen.frame` already report; no + /// screen-local conversion is needed here) into global CoreGraphics coordinates + /// (top-left origin, y increasing downward, same primary-screen anchor). Both coordinate + /// systems share the SAME horizontal origin and the SAME anchor rectangle (the primary + /// screen's bounds) — only the vertical axis is mirrored around the primary's height. + /// That is why the flip below is correct for every attached screen, including one with a + /// negative AppKit x (to the left of primary) or a y that places it above the primary + /// (whose CG y then comes out negative): x is untouched, and the primary height is the + /// one fixed reference both systems agree on regardless of which physical screen the + /// rect is actually on. + public static func fromAppKit(rect: CGRect, on screen: NSScreen) -> CaptureRegion { + let displayID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber)?.uint32Value ?? CGMainDisplayID() + let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height + return fromAppKit(rect: rect, displayID: displayID, + capturedScale: screen.backingScaleFactor, primaryHeight: primaryHeight) + } + + /// Injectable overload for deterministic geometry tests — no dependency on real attached + /// displays. The public overload above is a thin adapter over this. + static func fromAppKit(rect: CGRect, displayID: CGDirectDisplayID, + capturedScale: CGFloat, primaryHeight: CGFloat) -> CaptureRegion { + let cgY = primaryHeight - rect.origin.y - rect.height + let cgRect = CGRect(x: rect.origin.x, y: cgY, width: rect.width, height: rect.height) + return CaptureRegion(displayID: displayID, rect: cgRect, capturedScale: capturedScale) + } + + /// True when `displayID` is still attached AND `rect` still lies inside its current + /// bounds. Uses `CGDisplayIsActive` (returns 0 safely for an unknown id, never traps) and + /// `CGDisplayBounds` — both real CoreGraphics calls, no injection needed since this is a + /// live-state check by design. + public var isStillValid: Bool { + guard CGDisplayIsActive(displayID) != 0 else { return false } + let bounds = CGDisplayBounds(displayID) + return bounds.contains(rect) + } + + /// Persisted in UserDefaults under this key BY THE APP (WP-4a), never by this type. + public static let defaultsKey = "ai.flowmaster.shotdeck.region" +} diff --git a/Sources/ShotdeckCore/Capture/HotkeyCenter.swift b/Sources/ShotdeckCore/Capture/HotkeyCenter.swift new file mode 100644 index 0000000..ef2e821 --- /dev/null +++ b/Sources/ShotdeckCore/Capture/HotkeyCenter.swift @@ -0,0 +1,115 @@ +import Carbon.HIToolbox +import Foundation + +/// Carbon's C event handler cannot capture Swift closures, so live handlers are kept in one +/// process-wide table keyed by the numeric EventHotKeyID Carbon hands back on fire. Safe +/// because InstallEventHandler on GetApplicationEventTarget() always delivers on the main +/// thread, and every access here happens either from a @MainActor HotkeyCenter method or from +/// the C callback below, which this process only ever invokes on the main run loop. +private final class HotkeyDispatchTable: @unchecked Sendable { + static let shared = HotkeyDispatchTable() + private var handlers: [UInt32: @MainActor () -> Void] = [:] + private init() {} + func set(_ numericID: UInt32, _ handler: @escaping @MainActor () -> Void) { + handlers[numericID] = handler + } + func remove(_ numericID: UInt32) { handlers.removeValue(forKey: numericID) } + func fire(_ numericID: UInt32) { MainActor.assumeIsolated { handlers[numericID]?() } } +} + +private func shotdeckCarbonHotkeyHandler( + _ nextHandler: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + var hotKeyID = EventHotKeyID() + let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID) + guard status == noErr else { return status } + HotkeyDispatchTable.shared.fire(hotKeyID.id) + return noErr +} + +/// Four-character creator code used for every `EventHotKeyID` this process registers. +private let shotdeckHotKeySignature: OSType = 0x53484F54 // "SHOT" + +@MainActor +public final class HotkeyCenter { + private var bindings: [String: (ref: EventHotKeyRef, numericID: UInt32)] = [:] + private var nextNumericID: UInt32 = 1 + + private static var didInstallGlobalCarbonHandler = false + private static var carbonHandlerRef: EventHandlerRef? + + public init() { HotkeyCenter.installGlobalCarbonHandlerIfNeeded() } + + /// Registers a hotkey. `id` is a caller-chosen stable identifier. Re-registering the same + /// `id` first unregisters its previous binding, then attempts the new one (idempotent). + /// Returns false when Carbon's `RegisterEventHotKey` reports a non-`noErr` status — the + /// most common cause is the same keyCode+modifiers combination already being claimed + /// system-wide by this or another process. + @discardableResult + public func register( + id: String, + keyCode: UInt32, + modifiers: UInt32, + handler: @escaping @MainActor () -> Void + ) -> Bool { + unregister(id: id) + + let hotKeyID = EventHotKeyID(signature: shotdeckHotKeySignature, id: nextNumericID) + var ref: EventHotKeyRef? + let status = RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &ref + ) + guard status == noErr, let ref else { + return false + } + bindings[id] = (ref: ref, numericID: nextNumericID) + HotkeyDispatchTable.shared.set(nextNumericID, handler) + nextNumericID += 1 + return true + } + + public func unregister(id: String) { + guard let binding = bindings.removeValue(forKey: id) else { return } + _ = UnregisterEventHotKey(binding.ref) + HotkeyDispatchTable.shared.remove(binding.numericID) + } + + public func unregisterAll() { + let ids = Array(bindings.keys) + for id in ids { + unregister(id: id) + } + } + + /// Installs the process-wide Carbon hot-key pressed handler exactly once. + private static func installGlobalCarbonHandlerIfNeeded() { + guard !didInstallGlobalCarbonHandler else { return } + didInstallGlobalCarbonHandler = true + + var handlerRef: EventHandlerRef? + var eventTypes = [ + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: OSType(kEventHotKeyPressed) + ) + ] + let status = InstallEventHandler( + GetApplicationEventTarget(), + shotdeckCarbonHotkeyHandler, + 1, + &eventTypes, + nil, + &handlerRef + ) + if status == noErr { + carbonHandlerRef = handlerRef + } + } +} diff --git a/Sources/ShotdeckCore/Capture/ScreenCapturer.swift b/Sources/ShotdeckCore/Capture/ScreenCapturer.swift new file mode 100644 index 0000000..b6158fc --- /dev/null +++ b/Sources/ShotdeckCore/Capture/ScreenCapturer.swift @@ -0,0 +1,118 @@ +import ScreenCaptureKit +import CoreGraphics +import ImageIO +import UniformTypeIdentifiers +import AppKit // NSScreen only, to read backingScaleFactor as a fallback +import Foundation + +public struct CapturedImage: Sendable { + public let pngData: Data + public let pixelWidth: Int + public let pixelHeight: Int + public let scale: CGFloat +} + +public struct ScreenCapturer: Sendable { + public init() {} + + /// Does NOT prompt. Reports the current Screen Recording grant. + public static var isScreenRecordingGranted: Bool { CGPreflightScreenCaptureAccess() } + + /// Captures `region` at the display's true backing scale. Throws + /// `.screenRecordingNotGranted` when the grant is absent — never requests it. + public func capture(_ region: CaptureRegion) async throws -> CapturedImage { + guard Self.isScreenRecordingGranted else { + throw ShotdeckError.screenRecordingNotGranted + } + + let content: SCShareableContent + do { + content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + } catch { + throw ShotdeckError.captureFailed(underlying: error.localizedDescription) + } + + guard let display = content.displays.first(where: { $0.displayID == region.displayID }) else { + throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID) + } + + // Bounds/clamp: a resolution change often shrinks the display bounds by a few + // points without the region Ben picked being meaningfully wrong; 80% keeps a real + // recapture usable while a smaller overlap (e.g. a genuinely different display + // swapped in) still throws. + let rect: CGRect + if display.frame.contains(region.rect) { + rect = region.rect + } else { + let overlap = display.frame.intersection(region.rect) + let originalArea = region.rect.width * region.rect.height + let overlapArea = overlap.width * overlap.height + if originalArea > 0 && overlapArea / originalArea >= 0.8 { + rect = overlap + } else { + throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID) + } + } + + let localRect = CGRect( + x: rect.minX - display.frame.minX, + y: rect.minY - display.frame.minY, + width: rect.width, + height: rect.height + ) + + guard let mode = CGDisplayCopyDisplayMode(region.displayID) else { + throw ShotdeckError.captureFailed( + underlying: "no display mode for displayID \(region.displayID)" + ) + } + guard mode.width > 0 else { + throw ShotdeckError.captureFailed( + underlying: "display mode width is 0 for displayID \(region.displayID)" + ) + } + let scale = CGFloat(mode.pixelWidth) / CGFloat(mode.width) + + let filter = SCContentFilter(display: display, excludingWindows: []) + let configuration = SCStreamConfiguration() + configuration.sourceRect = localRect + configuration.width = Int((localRect.width * scale).rounded()) + configuration.height = Int((localRect.height * scale).rounded()) + configuration.scalesToFit = false + configuration.showsCursor = false + + let cgImage: CGImage + do { + cgImage = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: configuration + ) + } catch { + throw ShotdeckError.captureFailed(underlying: error.localizedDescription) + } + + let data = NSMutableData() + guard let dest = CGImageDestinationCreateWithData( + data, + UTType.png.identifier as CFString, + 1, + nil + ) else { + throw ShotdeckError.captureFailed(underlying: "could not create PNG image destination") + } + CGImageDestinationAddImage(dest, cgImage, nil) + guard CGImageDestinationFinalize(dest) else { + throw ShotdeckError.captureFailed(underlying: "PNG encoding failed to finalize") + } + + return CapturedImage( + pngData: data as Data, + pixelWidth: cgImage.width, + pixelHeight: cgImage.height, + scale: scale + ) + } +} diff --git a/Sources/ShotdeckCore/Model/Capture.swift b/Sources/ShotdeckCore/Model/Capture.swift new file mode 100644 index 0000000..15eea33 --- /dev/null +++ b/Sources/ShotdeckCore/Model/Capture.swift @@ -0,0 +1,43 @@ +import Foundation + +/// One screenshot plus everything needed to place it on a PDF page. +public struct Capture: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + /// 1-based position within its session. Stable; never renumbered on delete. + public let sequence: Int + /// File name only (e.g. "001-A1B2C3D4.png"), never a path. + /// The directory is always the owning session's directory. + public let fileName: String + /// Pixel dimensions of the PNG on disk. + public let pixelWidth: Int + public let pixelHeight: Int + /// Backing scale the capture was taken at (2.0 on Retina). Needed so the PDF + /// composer lays the image out at its true point size, not its pixel size. + public let scale: CGFloat + /// When the screenshot was taken. Always stored as an absolute instant; + /// rendered in Asia/Dubai wherever a human sees it. + public let capturedAt: Date + + public init( + id: UUID, + sequence: Int, + fileName: String, + pixelWidth: Int, + pixelHeight: Int, + scale: CGFloat, + capturedAt: Date + ) { + self.id = id + self.sequence = sequence + self.fileName = fileName + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + self.scale = scale + self.capturedAt = capturedAt + } + + /// True when the image is wider than it is tall. Drives page orientation. + public var isLandscape: Bool { + pixelWidth > pixelHeight + } +} diff --git a/Sources/ShotdeckCore/Model/CaptureSession.swift b/Sources/ShotdeckCore/Model/CaptureSession.swift new file mode 100644 index 0000000..c12644e --- /dev/null +++ b/Sources/ShotdeckCore/Model/CaptureSession.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum SessionState: String, Codable, Sendable { + case open // accepting captures + case archived // its PDF has been built and sent; kept forever, never deleted +} + +/// The manifest persisted as session.json alongside the PNGs. +public struct CaptureSession: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + public let createdAt: Date + public private(set) var state: SessionState + public private(set) var captures: [Capture] + /// Set when the PDF is built, so a re-send reuses the same file. + public private(set) var pdfFileName: String? + + public init( + id: UUID, + createdAt: Date, + state: SessionState, + captures: [Capture], + pdfFileName: String? + ) { + self.id = id + self.createdAt = createdAt + self.state = state + self.captures = captures + self.pdfFileName = pdfFileName + } + + public var isEmpty: Bool { + captures.isEmpty + } + + /// max(sequence)+1, or 1 when empty. + public var nextSequence: Int { + (captures.map(\.sequence).max() ?? 0) + 1 + } + + /// Value-semantic: returns a new session; does not mutate `self`. + public func appending(_ capture: Capture) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: state, + captures: captures + [capture], + pdfFileName: pdfFileName + ) + } + + /// Value-semantic: returns a new session; does not mutate `self`. + /// Sequence numbers of remaining captures are left unchanged. + public func removing(captureID: UUID) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: state, + captures: captures.filter { $0.id != captureID }, + pdfFileName: pdfFileName + ) + } + + /// Value-semantic: returns a new session; does not mutate `self`. + public func markArchived(pdfFileName: String) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: .archived, + captures: captures, + pdfFileName: pdfFileName + ) + } +} diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift new file mode 100644 index 0000000..a188a64 --- /dev/null +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum ShotdeckError: Error, LocalizedError, Sendable { + case screenRecordingNotGranted + case noRegionRemembered + case displayNoLongerConnected(displayID: UInt32) + case captureFailed(underlying: String) + case spoolWriteFailed(path: String, underlying: String) + case manifestCorrupt(path: String) + case pdfCompositionFailed(reason: String) + case airDropUnavailable + case noCommentedReturns + + public var errorDescription: String? { + switch self { + case .screenRecordingNotGranted: + return "Screen Recording is turned off. Grant it in System Settings to capture." + case .noRegionRemembered: + return "No capture region is set. Choose 'Re-select area' from the Redline menu." + case .displayNoLongerConnected: + return "The display used for capture is no longer connected." + case .captureFailed(let underlying): + return "The screenshot could not be taken. \(underlying)" + case .spoolWriteFailed(_, let underlying): + return "The screenshot could not be saved. \(underlying)" + case .manifestCorrupt: + return "This session's file is damaged and cannot be opened." + case .pdfCompositionFailed(let reason): + return "The PDF could not be built. \(reason)" + case .airDropUnavailable: + return "AirDrop is not available right now." + case .noCommentedReturns: + return "None of the returned PDFs have comments on them." + } + } +} diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift new file mode 100644 index 0000000..5fbfbc8 --- /dev/null +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -0,0 +1,209 @@ +import AppKit +import CoreGraphics +import CoreText +import Foundation +import ImageIO +import os + +private let pdfLog = Logger(subsystem: "ai.flowmaster.shotdeck", category: "PDF") + +public struct PDFComposer: Sendable { + public init() {} + + @discardableResult + public func compose( + session: CaptureSession, + imageURL: (Capture) -> URL, + title: String, + to outputURL: URL + ) throws -> Int { + if session.isEmpty { + throw ShotdeckError.pdfCompositionFailed(reason: "session has no captures") + } + + var loaded: [(Capture, CGImage)] = [] + loaded.reserveCapacity(session.captures.count) + for capture in session.captures { + let url = imageURL(capture) + if let image = Self.loadCGImage(from: url) { + loaded.append((capture, image)) + } else { + pdfLog.error( + "skipping capture \(capture.id.uuidString, privacy: .public) seq \(capture.sequence, privacy: .public): image unreadable at \(url.path, privacy: .public)" + ) + } + } + + guard !loaded.isEmpty else { + throw ShotdeckError.pdfCompositionFailed(reason: "no readable images in session") + } + + let pageCount = loaded.count + let tmpURL = outputURL + .deletingLastPathComponent() + .appendingPathComponent(".tmp-\(UUID().uuidString)-\(outputURL.lastPathComponent)") + + do { + try Self.writePDF( + loaded: loaded, + pageCount: pageCount, + title: title, + sessionID: session.id, + to: tmpURL + ) + + let handle = try FileHandle(forWritingTo: tmpURL) + try handle.synchronize() + try handle.close() + + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + try FileManager.default.moveItem(at: tmpURL, to: outputURL) + return pageCount + } catch { + try? FileManager.default.removeItem(at: tmpURL) + if let shotdeck = error as? ShotdeckError { + throw shotdeck + } + throw ShotdeckError.pdfCompositionFailed(reason: error.localizedDescription) + } + } + + public static func fileName(for _: CaptureSession) -> String { + "Redline-\(DubaiTime.fileStamp(Date())).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.size) + return [kCGPDFContextMediaBox as String: data] as CFDictionary + } + + private static func drawText( + _ string: String, + font: NSFont, + color: CGColor, + at origin: CGPoint, + in context: CGContext + ) { + let attributes: [CFString: Any] = [ + kCTFontAttributeName: font, + kCTForegroundColorAttributeName: color, + ] + guard let attributed = CFAttributedStringCreate( + nil, + string as CFString, + attributes as CFDictionary + ) else { + return + } + let line = CTLineCreateWithAttributedString(attributed) + context.textMatrix = .identity + context.textPosition = origin + CTLineDraw(line, context) + } + + private static func strokeTickBox(_ box: CGRect, in context: CGContext, color: CGColor) { + let path = CGPath( + roundedRect: box, + cornerWidth: 2, + cornerHeight: 2, + transform: nil + ) + context.addPath(path) + context.setStrokeColor(color) + context.setLineWidth(1.0) + context.strokePath() + } + + private static func loadCGImage(from url: URL) -> CGImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetCount(source) > 0 + else { + return nil + } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } +} diff --git a/Sources/ShotdeckCore/PDF/PageLayout.swift b/Sources/ShotdeckCore/PDF/PageLayout.swift new file mode 100644 index 0000000..7cd7dfe --- /dev/null +++ b/Sources/ShotdeckCore/PDF/PageLayout.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import CoreText +import Foundation + +public struct PageLayout: Sendable, Equatable { + public let isLandscape: Bool + public let pageRect: CGRect + public let headerRect: CGRect + public let imageBoxRect: CGRect + public let imageRect: CGRect + + public let pageNumberText: String + public let pageNumberOrigin: CGPoint + public let timestampText: String + public let timestampOrigin: CGPoint + + public let passLabelOrigin: CGPoint + public let failLabelOrigin: CGPoint + public let passTickBox: CGRect + public let failTickBox: CGRect + + /// Constructed at access time so `PageLayout` stays Sendable under Swift 6 + /// (`NSFont` is not Sendable). Same `NSFont.systemFont` values used for measurement. + public var pageNumberFont: NSFont { NSFont.systemFont(ofSize: 9, weight: .semibold) } + public var timestampFont: NSFont { NSFont.systemFont(ofSize: 9, weight: .regular) } + public var tickLabelFont: NSFont { NSFont.systemFont(ofSize: 8, weight: .semibold) } + + private static let marginAll: CGFloat = 18 + private static let headerHeight: CGFloat = 26 + private static let headerImageGap: CGFloat = 10 + private static let tickBoxSize: CGFloat = 13 + private static let tickGroupGap: CGFloat = 8 + private static let portraitPage = CGSize(width: 595, height: 842) + private static let landscapePage = CGSize(width: 842, height: 595) + + /// `pageIndex` and `pageCount` are 1-based / total, over ACTUALLY-WRITTEN pages + /// (see SPEC decision D-D), not `capture.sequence` / `session.captures.count`. + public init(capture: Capture, pageIndex: Int, pageCount: Int) { + let pageNumberFont = NSFont.systemFont(ofSize: 9, weight: .semibold) + let timestampFont = NSFont.systemFont(ofSize: 9, weight: .regular) + let tickLabelFont = NSFont.systemFont(ofSize: 8, weight: .semibold) + + isLandscape = capture.isLandscape + pageRect = CGRect(origin: .zero, size: isLandscape ? Self.landscapePage : Self.portraitPage) + let W = pageRect.width + let H = pageRect.height + + let contentMinX = Self.marginAll + let contentMaxX = W - Self.marginAll + let contentMinY = Self.marginAll + let contentMaxY = H - Self.marginAll + let contentWidth = contentMaxX - contentMinX + + headerRect = CGRect( + x: contentMinX, + y: contentMaxY - Self.headerHeight, + width: contentWidth, + height: Self.headerHeight + ) + + imageBoxRect = CGRect( + x: contentMinX, + y: contentMinY, + width: contentWidth, + height: headerRect.minY - Self.headerImageGap - contentMinY + ) + + let pointSize = CGSize( + width: CGFloat(capture.pixelWidth) / capture.scale, + height: CGFloat(capture.pixelHeight) / capture.scale + ) + let fitScale = min( + imageBoxRect.width / pointSize.width, + imageBoxRect.height / pointSize.height, + 1.0 + ) + let drawnSize = CGSize( + width: pointSize.width * fitScale, + height: pointSize.height * fitScale + ) + imageRect = CGRect( + x: imageBoxRect.midX - drawnSize.width / 2, + y: imageBoxRect.midY - drawnSize.height / 2, + width: drawnSize.width, + height: drawnSize.height + ) + + let metricsFont = timestampFont as CTFont + let ascent = CTFontGetAscent(metricsFont) + let descent = CTFontGetDescent(metricsFont) + let baselineY = headerRect.minY + (headerRect.height - (ascent + descent)) / 2 + descent + + pageNumberText = "\(pageIndex) / \(pageCount)" + pageNumberOrigin = CGPoint(x: headerRect.minX, y: baselineY) + + timestampText = DubaiTime.stamp(capture.capturedAt) + let pageNumberWidth = Self.measuredWidth(pageNumberText, font: pageNumberFont) + timestampOrigin = CGPoint(x: headerRect.minX + pageNumberWidth + 10, y: baselineY) + + let groupRightX = headerRect.maxX + let failBoxMinX = groupRightX - Self.tickBoxSize + let tickBoxY = headerRect.midY - Self.tickBoxSize / 2 + failTickBox = CGRect( + x: failBoxMinX, + y: tickBoxY, + width: Self.tickBoxSize, + height: Self.tickBoxSize + ) + + let failLabelWidth = Self.measuredWidth("FAIL", font: tickLabelFont) + let failLabelMaxX = failTickBox.minX - Self.tickGroupGap + failLabelOrigin = CGPoint(x: failLabelMaxX - failLabelWidth, y: baselineY) + + let passBoxMaxX = failLabelOrigin.x - Self.tickGroupGap + passTickBox = CGRect( + x: passBoxMaxX - Self.tickBoxSize, + y: tickBoxY, + width: Self.tickBoxSize, + height: Self.tickBoxSize + ) + + let passLabelWidth = Self.measuredWidth("PASS", font: tickLabelFont) + let passLabelMaxX = passTickBox.minX - Self.tickGroupGap + passLabelOrigin = CGPoint(x: passLabelMaxX - passLabelWidth, y: baselineY) + } + + public static func == (lhs: PageLayout, rhs: PageLayout) -> Bool { + lhs.isLandscape == rhs.isLandscape + && lhs.pageRect == rhs.pageRect + && lhs.headerRect == rhs.headerRect + && lhs.imageBoxRect == rhs.imageBoxRect + && lhs.imageRect == rhs.imageRect + && lhs.pageNumberText == rhs.pageNumberText + && lhs.pageNumberOrigin == rhs.pageNumberOrigin + && lhs.timestampText == rhs.timestampText + && lhs.timestampOrigin == rhs.timestampOrigin + && lhs.passLabelOrigin == rhs.passLabelOrigin + && lhs.failLabelOrigin == rhs.failLabelOrigin + && lhs.passTickBox == rhs.passTickBox + && lhs.failTickBox == rhs.failTickBox + } + + private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { + let attributed = NSAttributedString(string: text, attributes: [.font: font]) + let line = CTLineCreateWithAttributedString(attributed) + return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + } +} diff --git a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift new file mode 100644 index 0000000..5b376e8 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift @@ -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 = [ + PDFAnnotationSubtype.ink.rawValue, + PDFAnnotationSubtype.highlight.rawValue, + PDFAnnotationSubtype.underline.rawValue, + PDFAnnotationSubtype.strikeOut.rawValue, + // PDFKit has no PDFAnnotationSubtype.squiggly member (unsupported renderer), + // but Apple Markup still writes Adobe /Squiggly objects that we must count. + PDFAnnotationSubtype(rawValue: "/Squiggly").rawValue, + PDFAnnotationSubtype.freeText.rawValue, + PDFAnnotationSubtype.square.rawValue, + PDFAnnotationSubtype.circle.rawValue, + PDFAnnotationSubtype.line.rawValue, + PDFAnnotationSubtype.stamp.rawValue, + PDFAnnotationSubtype.text.rawValue, + ] + + // .link ignored: a PDF hyperlink is structural, not a human mark. + // .popup ignored: it is always the companion of another annotation; counting it + // would double-count a single human mark as two. + // .widget ignored: a form field. Shotdeck's own PASS/FAIL boxes are page content + // (drawn by WP-2), never PDFAnnotation objects — a widget seen here can only be + // introduced by a third-party tool flattening/reopening the file, and is not a + // human mark either way. + + /// PDFKit's `PDFAnnotation.type` may omit the leading slash that + /// `PDFAnnotationSubtype.rawValue` includes; compare against the slash form. + private static func pdfTypeName(_ type: String) -> String { + type.hasPrefix("/") ? type : "/" + type + } + + private static func isHumanMark(_ annotation: PDFAnnotation) -> Bool { + guard let raw = annotation.type else { return false } + let type = pdfTypeName(raw) + guard humanMarkTypes.contains(type) else { return false } + if type == PDFAnnotationSubtype.ink.rawValue { + let b = annotation.bounds + return b.width > 0 && b.height > 0 // zero-area ink = an undone stroke, not a mark + } + return true + } + + /// Opens the PDF at fileURL and reports which pages carry a genuine human mark. + /// Throws ShotdeckError.manifestCorrupt(path: fileURL.path) if PDFDocument cannot open it. + /// File modification date is never consulted; only persisted PDFAnnotation objects count. + public static func inspect(fileURL: URL) throws -> ReturnedDocument { + guard let document = PDFDocument(url: fileURL) else { + throw ShotdeckError.manifestCorrupt(path: fileURL.path) + } + var annotatedPages: [Int] = [] + for index in 0.. Bool { + if let creator = document.documentAttributes?[PDFDocumentAttribute.creatorAttribute] as? String { + // 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. + } +} diff --git a/Sources/ShotdeckCore/Returns/ReturnLedger.swift b/Sources/ShotdeckCore/Returns/ReturnLedger.swift new file mode 100644 index 0000000..a1c8fa9 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/ReturnLedger.swift @@ -0,0 +1,60 @@ +import Foundation + +public actor ReturnLedger { + private let fileURL: URL + private var entries: [URL: ReturnedDocument] + + /// Loads `returns.json` under paths.root if it exists; starts empty otherwise. + /// A file that cannot be decoded is renamed (never deleted) and the ledger starts empty. + public init(paths: AppSupportPaths) throws { + self.fileURL = paths.root.appendingPathComponent("returns.json") + if FileManager.default.fileExists(atPath: fileURL.path) { + let data = try Data(contentsOf: fileURL) + do { + let decoded = try JSONDecoder().decode([ReturnedDocument].self, from: data) + entries = Dictionary(decoded.map { ($0.fileURL, $0) }, uniquingKeysWith: { _, new in new }) + } catch { + let stamp = DubaiTime.fileStamp(Date()) + let corruptURL = fileURL.deletingLastPathComponent() + .appendingPathComponent("returns.json.corrupt-\(stamp)") + try FileManager.default.moveItem(at: fileURL, to: corruptURL) + Log.returns.error( + "returns.json could not be decoded; moved to \(corruptURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + entries = [:] + } + } else { + entries = [:] + } + } + + /// Upserts by fileURL — recording the same URL again replaces the prior entry + /// (the most recently recorded call wins, regardless of its detectedAt value). + public func record(_ document: ReturnedDocument) throws { + entries[document.fileURL] = document + try persist() + } + + /// Every recorded return, newest detectedAt first. + public func all() throws -> [ReturnedDocument] { + entries.values.sorted { $0.detectedAt > $1.detectedAt } + } + + /// Commented returns (isCommented == true), newest detectedAt first. + public func commented() throws -> [ReturnedDocument] { + try all().filter(\.isCommented) + } + + /// Absolute POSIX paths of commented returns, newest first, one per line, no + /// trailing newline. Throws ShotdeckError.noCommentedReturns when commented() is empty. + public func clipboardText() throws -> String { + let paths = try commented().map { $0.fileURL.path } + guard !paths.isEmpty else { throw ShotdeckError.noCommentedReturns } + return paths.joined(separator: "\n") + } + + private func persist() throws { + let data = try JSONEncoder().encode(Array(entries.values)) + try AtomicFile.write(data, to: fileURL) + } +} diff --git a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift new file mode 100644 index 0000000..829d3b8 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift @@ -0,0 +1,154 @@ +import Foundation +import PDFKit +import CoreServices // FSEventStream* APIs; system framework, no Package.swift change needed + +public actor ReturnWatcher { + private let ledger: ReturnLedger + private var watchFolder: URL + private var onChange: (@Sendable ([ReturnedDocument]) -> Void)? + private var stream: FSEventStreamRef? + private var bridge: FSEventBridge? + private var pendingScanTask: Task? + private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents") + + /// Watch folder is `paths.watchFolder`, which production constructs from + /// `FolderSettings.resolve().watch`. This type never calls FolderSettings; + /// `updateWatchFolder` is invoked by the UI layer only. + public init(paths: AppSupportPaths, ledger: ReturnLedger) { + self.ledger = ledger + self.watchFolder = paths.watchFolder // never a literal "~/Downloads" here + } + + /// Starts watching paths.watchFolder for returned PDFs. Performs one immediate + /// scanNow() before returning, then calls onChange after every subsequent debounced + /// batch (even if that batch's result is empty — the caller decides what to do). + public func start(onChange: @escaping @Sendable ([ReturnedDocument]) -> Void) async throws { + self.onChange = onChange + try startStream(on: watchFolder) + let found = try await scanNow() + onChange(found) + } + + /// Idempotent. Stops and releases the FSEventStream if one is running; safe to call + /// when never started or already stopped. Cancels any pending debounced scan. + public func stop() { + // Idempotent: nil stream / already-stopped is a no-op; never started is the same. + pendingScanTask?.cancel() + pendingScanTask = nil + if let stream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + stream = nil + bridge = nil + } + + /// Called by whoever owns the Settings "Choose..." folder action (WP-4c) after the user + /// picks a new watch folder. If the watcher was running, stops the old FSEventStream, + /// switches to the new folder, restarts, and performs one immediate scanNow (reporting + /// through the same onChange callback given to start()). If the watcher was never + /// started, only updates the stored folder for the next start() call. + public func updateWatchFolder(_ url: URL) async throws { + let wasRunning = stream != nil + stop() + watchFolder = url + guard wasRunning else { return } + try startStream(on: url) + let found = try await scanNow() + onChange?(found) + } + + /// Scans the watch folder once, immediately, without waiting for an event. Every + /// recognized, stable, openable 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.fromOpaque(info).takeUnretainedValue().notify() +} diff --git a/Sources/ShotdeckCore/Spool/SpoolStore.swift b/Sources/ShotdeckCore/Spool/SpoolStore.swift new file mode 100644 index 0000000..798399e --- /dev/null +++ b/Sources/ShotdeckCore/Spool/SpoolStore.swift @@ -0,0 +1,418 @@ +import Foundation +import ImageIO +import CoreGraphics +import Darwin + +public actor SpoolStore { + private let paths: AppSupportPaths + private var openSession: CaptureSession + + public init(paths: AppSupportPaths) throws { + self.paths = paths + let fm = FileManager.default + try Self.supersedeSpoolArchiveOverlaps(paths: paths, fileManager: fm) + try Self.finishInterruptedArchives(paths: paths, fileManager: fm) + let remainingIDs = try Self.listUUIDDirectories(in: paths.spool, fileManager: fm) + if remainingIDs.isEmpty { + self.openSession = try Self.createFreshSession(paths: paths) + } else { + var candidates: [CaptureSession] = [] + for id in remainingIDs { + let dir = paths.sessionDirectory(id) + candidates.append( + try Self.reconcileSessionDirectory( + at: dir, id: id, assumedStateIfRebuilt: .open, fileManager: fm)) + } + self.openSession = Self.pickNewest(first: candidates[0], rest: Array(candidates.dropFirst())) + } + } + + /// The session currently accepting captures. Cheap accessor — all reconciliation already + /// happened once, inside init. + public func currentSession() throws -> CaptureSession { + openSession + } + + /// Writes `pngData` to disk and fsyncs it BEFORE the manifest is touched, then updates and + /// durably writes the manifest. Order is non-negotiable: image durable -> manifest durable + /// -> return. Uses AtomicFile.write/writeJSON for both writes — never Data.write(to:). + public func append( + pngData: Data, pixelWidth: Int, pixelHeight: Int, + scale: CGFloat, capturedAt: Date + ) throws -> Capture { + let captureID = UUID() + let sequence = openSession.nextSequence + let fileName = "\(String(format: "%03d", sequence))-\(Self.hexSuffix(captureID)).png" + let sessionDir = paths.sessionDirectory(openSession.id) + let fileURL = sessionDir.appendingPathComponent(fileName) + try AtomicFile.write(pngData, to: fileURL) + let capture = Capture( + id: captureID, sequence: sequence, fileName: fileName, + pixelWidth: pixelWidth, pixelHeight: pixelHeight, + scale: scale, capturedAt: capturedAt) + let updated = openSession.appending(capture) + try AtomicFile.writeJSON(updated, to: sessionDir.appendingPathComponent("session.json")) + openSession = updated + return capture + } + + /// D-11: moves the capture's PNG into `/removed/` (created lazily) and drops + /// its manifest entry. NEVER unlinks/deletes a user PNG. Throws (no filesystem change) if + /// `captureID` is not present in the open session. + public func remove(captureID: UUID) throws -> CaptureSession { + guard let capture = openSession.captures.first(where: { $0.id == captureID }) else { + throw ShotdeckError.spoolWriteFailed( + path: paths.sessionDirectory(openSession.id).path, + underlying: "capture \(captureID) is not in the open session") + } + let sessionDir = paths.sessionDirectory(openSession.id) + let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true) + do { + try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true) + } catch { + throw ShotdeckError.spoolWriteFailed(path: removedDir.path, underlying: error.localizedDescription) + } + let sourceURL = sessionDir.appendingPathComponent(capture.fileName) + let destURL = removedDir.appendingPathComponent(capture.fileName) + guard rename(sourceURL.path, destURL.path) == 0 else { + throw ShotdeckError.spoolWriteFailed( + path: destURL.path, + underlying: "could not move the capture into removed/: \(String(cString: strerror(errno)))") + } + try AtomicFile.fsyncDirectory(at: removedDir) + let updated = openSession.removing(captureID: captureID) + try AtomicFile.writeJSON(updated, to: sessionDir.appendingPathComponent("session.json")) + openSession = updated + return updated + } + + /// Closes the open session (must be non-empty), moves its directory under archive/, records + /// pdfFileName, and starts a fresh empty open session. Returns the archived one. + public func archiveCurrent(pdfFileName: String) throws -> CaptureSession { + guard !openSession.isEmpty else { + throw ShotdeckError.spoolWriteFailed( + path: paths.sessionDirectory(openSession.id).path, + underlying: "cannot archive an empty session") + } + let archived = openSession.markArchived(pdfFileName: pdfFileName) + let sessionDir = paths.sessionDirectory(openSession.id) + try AtomicFile.writeJSON(archived, to: sessionDir.appendingPathComponent("session.json")) + let archiveDir = paths.archiveDirectory(openSession.id) + guard rename(sessionDir.path, archiveDir.path) == 0 else { + throw ShotdeckError.spoolWriteFailed( + path: sessionDir.path, + underlying: "could not move the session into archive/: \(String(cString: strerror(errno)))") + } + try AtomicFile.fsyncDirectory(at: paths.archive) + let fresh = try Self.createFreshSession(paths: paths) + openSession = fresh + return archived + } + + /// Abandons the open session only if it is empty (mints a new id/dir/manifest); throws, + /// with no filesystem change, if the open session has captures. + public func startNewSession() throws -> CaptureSession { + guard openSession.isEmpty else { + throw ShotdeckError.spoolWriteFailed( + path: paths.sessionDirectory(openSession.id).path, + underlying: "cannot start a new session: \(openSession.captures.count) capture(s) present in the open session") + } + let fresh = try Self.createFreshSession(paths: paths) + openSession = fresh + return fresh + } + + /// Absolute URL of a capture's PNG, in whichever top-level directory its session lives + /// (spool/ if session.state == .open, archive/ if .archived). + public func imageURL(for capture: Capture, in session: CaptureSession) -> URL { + let dir = session.state == .open ? paths.sessionDirectory(session.id) : paths.archiveDirectory(session.id) + return dir.appendingPathComponent(capture.fileName) + } + + /// Archived sessions, newest createdAt first. Lazily reconciles each archive/ directory + /// the same way init reconciles spool/ candidates (orphan recovery, missing-drop, corrupt + /// rebuild) — an archived session's manifest can degrade too and must self-heal without + /// ever losing a PNG. + public func archivedSessions() throws -> [CaptureSession] { + let ids = try Self.listUUIDDirectories(in: paths.archive, fileManager: .default) + var sessions: [CaptureSession] = [] + for id in ids { + sessions.append( + try Self.reconcileSessionDirectory( + at: paths.archiveDirectory(id), id: id, assumedStateIfRebuilt: .archived, fileManager: .default)) + } + return sessions.sorted { ($0.createdAt, $0.id.uuidString) > ($1.createdAt, $1.id.uuidString) } + } + + // MARK: - Reconciliation (static so they can run inside init) + + private static func listUUIDDirectories(in parent: URL, fileManager: FileManager) throws -> [UUID] { + let entries: [URL] + do { + entries = try fileManager.contentsOfDirectory( + at: parent, + includingPropertiesForKeys: [.isDirectoryKey], + options: []) + } catch { + throw ShotdeckError.spoolWriteFailed(path: parent.path, underlying: error.localizedDescription) + } + var ids: [UUID] = [] + for url in entries { + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard isDirectory else { continue } + if let id = UUID(uuidString: url.lastPathComponent) { + ids.append(id) + } + } + return ids + } + + private static func supersedeSpoolArchiveOverlaps(paths: AppSupportPaths, fileManager: FileManager) throws { + let spoolIDs = Set(try listUUIDDirectories(in: paths.spool, fileManager: fileManager)) + let archiveIDs = Set(try listUUIDDirectories(in: paths.archive, fileManager: fileManager)) + for id in spoolIDs.intersection(archiveIDs) { + let spoolDir = paths.sessionDirectory(id) + let supersededDir = paths.spool.appendingPathComponent( + "\(id.uuidString).superseded-\(DubaiTime.fileStamp(Date()))", isDirectory: true) + guard rename(spoolDir.path, supersededDir.path) == 0 else { + throw ShotdeckError.spoolWriteFailed( + path: spoolDir.path, + underlying: "could not supersede a duplicate spool copy: \(String(cString: strerror(errno)))") + } + try AtomicFile.fsyncDirectory(at: paths.spool) + Log.spool.warning("Found session \(id.uuidString, privacy: .public) in both spool/ and archive/; kept the archive copy and superseded the spool copy — nothing was deleted.") + } + } + + private static func finishInterruptedArchives(paths: AppSupportPaths, fileManager: FileManager) throws { + for id in try listUUIDDirectories(in: paths.spool, fileManager: fileManager) { + let spoolDir = paths.sessionDirectory(id) + let manifestURL = spoolDir.appendingPathComponent("session.json") + guard let data = try? Data(contentsOf: manifestURL), + let decoded = try? decodeSession(from: data), + decoded.id == id, decoded.state == .archived + else { continue } + let archiveDir = paths.archiveDirectory(id) + guard rename(spoolDir.path, archiveDir.path) == 0 else { + throw ShotdeckError.spoolWriteFailed( + path: spoolDir.path, + underlying: "could not complete an interrupted archive move: \(String(cString: strerror(errno)))") + } + try AtomicFile.fsyncDirectory(at: paths.archive) + Log.spool.warning("Completed an archive move for \(id.uuidString, privacy: .public) that was interrupted before this launch.") + } + } + + private static func reconcileSessionDirectory( + at dir: URL, + id: UUID, + assumedStateIfRebuilt: SessionState, + fileManager: FileManager + ) throws -> CaptureSession { + let manifestURL = dir.appendingPathComponent("session.json") + let decoded: CaptureSession? + if let data = try? Data(contentsOf: manifestURL), + let session = try? decodeSession(from: data), + session.id == id { + decoded = session + } else { + decoded = nil + } + + guard let session = decoded else { + return try rebuildManifest( + at: dir, + id: id, + assumedState: assumedStateIfRebuilt, + fileManager: fileManager) + } + + var present: [Capture] = [] + var missingCount = 0 + for capture in session.captures { + let fileURL = dir.appendingPathComponent(capture.fileName) + if fileManager.fileExists(atPath: fileURL.path) { + present.append(capture) + } else { + missingCount += 1 + } + } + let referenced = Set(session.captures.map(\.fileName)) + let pngs = try listCapturePNGs(in: dir, fileManager: fileManager) + var recoveredOrphans: [Capture] = [] + for pngURL in pngs where !referenced.contains(pngURL.lastPathComponent) { + if let recovered = recoverCapture(from: pngURL, fileManager: fileManager) { + recoveredOrphans.append(recovered) + } else { + Log.spool.error("Could not decode orphan PNG at \(pngURL.path, privacy: .public); leaving it on disk.") + } + } + let deletedTmp = deleteStrayTmpFiles(in: dir, fileManager: fileManager) + if missingCount == 0 && recoveredOrphans.isEmpty && !deletedTmp { + return session + } + let finalCaptures = (present + recoveredOrphans).sorted { $0.sequence < $1.sequence } + let updated = CaptureSession( + id: session.id, + createdAt: session.createdAt, + state: session.state, + captures: finalCaptures, + pdfFileName: session.pdfFileName) + try AtomicFile.writeJSON(updated, to: manifestURL) + Log.spool.warning("Reconciled session \(id.uuidString, privacy: .public): dropped \(missingCount) missing PNG(s), recovered \(recoveredOrphans.count) orphan(s).") + return updated + } + + private static func rebuildManifest( + at dir: URL, + id: UUID, + assumedState: SessionState, + fileManager: FileManager + ) throws -> CaptureSession { + let manifestURL = dir.appendingPathComponent("session.json") + if fileManager.fileExists(atPath: manifestURL.path) { + let corruptURL = dir.appendingPathComponent( + "session.json.corrupt-\(DubaiTime.fileStamp(Date()))") + do { + try fileManager.moveItem(at: manifestURL, to: corruptURL) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: manifestURL.path, + underlying: "could not quarantine a corrupt manifest: \(error.localizedDescription)") + } + } + + var recovered: [Capture] = [] + for pngURL in try listCapturePNGs(in: dir, fileManager: fileManager) { + if let capture = recoverCapture(from: pngURL, fileManager: fileManager) { + recovered.append(capture) + } else { + Log.spool.error("Could not decode PNG at \(pngURL.path, privacy: .public) while rebuilding the manifest; leaving it on disk.") + } + } + _ = deleteStrayTmpFiles(in: dir, fileManager: fileManager) + recovered.sort { $0.sequence < $1.sequence } + + let createdAt: Date + if let earliest = recovered.map(\.capturedAt).min() { + createdAt = earliest + } else { + createdAt = (try? dir.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() + } + + let rebuilt = CaptureSession( + id: id, + createdAt: createdAt, + state: assumedState, + captures: recovered, + pdfFileName: nil) + try AtomicFile.writeJSON(rebuilt, to: dir.appendingPathComponent("session.json")) + Log.spool.warning("Rebuilt manifest for \(id.uuidString, privacy: .public) from \(recovered.count) recovered PNG(s).") + if assumedState == .archived { + Log.spool.warning("Rebuilt an archived session \(id.uuidString, privacy: .public) from PNGs; pdfFileName could not be recovered.") + } + return rebuilt + } + + private static func recoverCapture(from fileURL: URL, fileManager: FileManager) -> Capture? { + guard fileManager.fileExists(atPath: fileURL.path) else { return nil } + guard let source = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?, + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue + else { return nil } + let name = fileURL.lastPathComponent + let sequence = Int(name.prefix(3)) ?? 1 + let capturedAt = (try? fileURL.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() + return Capture( + id: UUID(), + sequence: sequence, + fileName: name, + pixelWidth: width, + pixelHeight: height, + scale: 1.0, + capturedAt: capturedAt) + } + + private static func pickNewest(first: CaptureSession, rest: [CaptureSession]) -> CaptureSession { + rest.reduce(first) { current, candidate in + let currentKey = (current.createdAt, current.id.uuidString) + let candidateKey = (candidate.createdAt, candidate.id.uuidString) + return candidateKey > currentKey ? candidate : current + } + } + + private static func createFreshSession(paths: AppSupportPaths) throws -> CaptureSession { + let id = UUID() + let createdAt = Date() + let dir = paths.sessionDirectory(id) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + throw ShotdeckError.spoolWriteFailed(path: dir.path, underlying: error.localizedDescription) + } + let session = CaptureSession(id: id, createdAt: createdAt, state: .open, captures: [], pdfFileName: nil) + try AtomicFile.writeJSON(session, to: dir.appendingPathComponent("session.json")) + return session + } + + private static func hexSuffix(_ id: UUID) -> String { + String(id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased() + } + + private static func decodeSession(from data: Data) throws -> CaptureSession { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(CaptureSession.self, from: data) + } + + private static func isCapturePNGName(_ name: String) -> Bool { + guard name.hasSuffix(".png") else { return false } + let stem = String(name.dropLast(4)) + let parts = stem.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + parts[0].count == 3, + parts[0].allSatisfy(\.isNumber), + parts[1].count == 8, + parts[1].allSatisfy(\.isHexDigit) + else { return false } + return true + } + + private static func listCapturePNGs(in dir: URL, fileManager: FileManager) throws -> [URL] { + let entries: [URL] + do { + entries = try fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.isDirectoryKey], + options: []) + } catch { + throw ShotdeckError.spoolWriteFailed(path: dir.path, underlying: error.localizedDescription) + } + return entries.filter { url in + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard !isDirectory else { return false } + return isCapturePNGName(url.lastPathComponent) + } + } + + private static func deleteStrayTmpFiles(in dir: URL, fileManager: FileManager) -> Bool { + let entries = (try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [])) ?? [] + var deleted = false + for url in entries { + guard url.lastPathComponent.hasSuffix(".tmp") else { continue } + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard !isDirectory else { continue } + do { + try fileManager.removeItem(at: url) + deleted = true + } catch { + Log.spool.error("Could not remove stray temp file at \(url.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + return deleted + } +} diff --git a/Sources/ShotdeckCore/Support/AppSupportPaths.swift b/Sources/ShotdeckCore/Support/AppSupportPaths.swift new file mode 100644 index 0000000..7f1dccf --- /dev/null +++ b/Sources/ShotdeckCore/Support/AppSupportPaths.swift @@ -0,0 +1,65 @@ +import Foundation + +/// The only type in the app that knows the on-disk directory layout. +public struct AppSupportPaths: Sendable { + public let root: URL + public let spool: URL + public let archive: URL + public let outbox: URL + public let watchFolder: URL + + /// Production paths. + public static func standard() throws -> AppSupportPaths { + let fileManager = FileManager.default + let appSupportParent = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let desktop = try fileManager.url( + for: .desktopDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let downloads = try fileManager.url( + for: .downloadsDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let root = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true) + return try AppSupportPaths(root: root, outbox: desktop, watchFolder: downloads) + } + + /// Test paths rooted anywhere. Every directory is created if missing. + public init(root: URL, outbox: URL, watchFolder: URL) throws { + self.root = root + self.spool = root.appendingPathComponent("spool", isDirectory: true) + self.archive = root.appendingPathComponent("archive", isDirectory: true) + self.outbox = outbox + self.watchFolder = watchFolder + + try Self.createDirectory(self.root) + try Self.createDirectory(self.spool) + try Self.createDirectory(self.archive) + try Self.createDirectory(self.outbox) + try Self.createDirectory(self.watchFolder) + } + + public func sessionDirectory(_ id: UUID) -> URL { + spool.appendingPathComponent(id.uuidString, isDirectory: true) + } + + public func archiveDirectory(_ id: UUID) -> URL { + archive.appendingPathComponent(id.uuidString, isDirectory: true) + } + + private static func createDirectory(_ url: URL) throws { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + } +} diff --git a/Sources/ShotdeckCore/Support/AtomicFile.swift b/Sources/ShotdeckCore/Support/AtomicFile.swift new file mode 100644 index 0000000..d9cd30a --- /dev/null +++ b/Sources/ShotdeckCore/Support/AtomicFile.swift @@ -0,0 +1,104 @@ +import Foundation +import Darwin + +public enum AtomicFile { + /// Writes `data` to `url` durably: writes to `.tmp` in the SAME directory as `url`, + /// fsyncs that file descriptor, closes it, rename()s it onto `url` (atomic same-volume + /// rename), then opens `url`'s containing directory and fsyncs THAT too (a rename is only + /// durable once its directory entry is flushed). Never uses `Data.write(to:)` — that call + /// does not fsync. + public static func write(_ data: Data, to url: URL) throws { + let finalPath = url.path + let directoryURL = url.deletingLastPathComponent() + let tmpURL = directoryURL.appendingPathComponent(url.lastPathComponent + ".tmp") + let tmpPath = tmpURL.path + + // Clear a stale .tmp left by a previous crash. ENOENT (nothing to clear) is fine. + if unlink(tmpPath) != 0 && errno != ENOENT { + throw ShotdeckError.spoolWriteFailed( + path: finalPath, + underlying: "could not clear a stale temp file: \(String(cString: strerror(errno)))") + } + + let fd = open(tmpPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard fd >= 0 else { + throw ShotdeckError.spoolWriteFailed( + path: finalPath, underlying: "open failed: \(String(cString: strerror(errno)))") + } + + var writeFailure: String? + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + var remaining = raw.count + var pointer = raw.baseAddress + while remaining > 0 { + let n = Darwin.write(fd, pointer, remaining) + if n < 0 { + if errno == EINTR { continue } + writeFailure = "write failed: \(String(cString: strerror(errno)))" + break + } + if n == 0 { break } + remaining -= n + pointer = pointer?.advanced(by: n) + } + } + if let writeFailure { + close(fd) + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: writeFailure) + } + if fsync(fd) != 0 { + let message = "fsync failed: \(String(cString: strerror(errno)))" + close(fd) + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: message) + } + if close(fd) != 0 { + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed( + path: finalPath, underlying: "close failed: \(String(cString: strerror(errno)))") + } + if rename(tmpPath, finalPath) != 0 { + let message = "rename failed: \(String(cString: strerror(errno)))" + _ = unlink(tmpPath) + throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: message) + } + try fsyncDirectory(at: directoryURL) + } + + /// Encodes `value` with `JSONEncoder` (`.sortedKeys, .prettyPrinted`, + /// `.dateEncodingStrategy = .iso8601`) and writes it through `write(_:to:)`. + public static func writeJSON(_ value: T, to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + encoder.dateEncodingStrategy = .iso8601 + let data: Data + do { + data = try encoder.encode(value) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: url.path, underlying: "JSON encoding failed: \(error.localizedDescription)") + } + try write(data, to: url) + } + + /// Opens `url` (must be an existing directory) and fsyncs it. Used after any directory- + /// level `rename()` (moving/renaming a whole session directory) — the same durability + /// requirement as the internal directory-fsync inside `write(_:to:)`, exposed for callers + /// that rename directories themselves (SpoolStore). + public static func fsyncDirectory(at url: URL) throws { + let fd = open(url.path, O_RDONLY | O_DIRECTORY) + guard fd >= 0 else { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: "could not open directory for fsync: \(String(cString: strerror(errno)))") + } + let result = fsync(fd) + close(fd) + if result != 0 { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: "directory fsync failed: \(String(cString: strerror(errno)))") + } + } +} diff --git a/Sources/ShotdeckCore/Support/DubaiTime.swift b/Sources/ShotdeckCore/Support/DubaiTime.swift new file mode 100644 index 0000000..77313ac --- /dev/null +++ b/Sources/ShotdeckCore/Support/DubaiTime.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum DubaiTime { + private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'") + private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss") + + public static func stamp(_ date: Date) -> String { + stampFormatter.string(from: date) + } + + public static func fileStamp(_ date: Date) -> String { + fileStampFormatter.string(from: date) + } +} + +/// DateFormatter is not Sendable. This holder is the only shared mutable state +/// around a formatter: every read is serialized by the lock, so concurrent +/// calls (one per PDF page) cannot race. +private final class LockedDateFormatter: @unchecked Sendable { + private let lock = NSLock() + private let formatter: DateFormatter + + init(dateFormat: String) { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_GB") + guard let dubai = TimeZone(identifier: "Asia/Dubai") else { + preconditionFailure("The Asia/Dubai time zone is missing from this system.") + } + formatter.timeZone = dubai + formatter.dateFormat = dateFormat + self.formatter = formatter + } + + func string(from date: Date) -> String { + lock.lock() + defer { lock.unlock() } + return formatter.string(from: date) + } +} diff --git a/Sources/ShotdeckCore/Support/FolderSettings.swift b/Sources/ShotdeckCore/Support/FolderSettings.swift new file mode 100644 index 0000000..618b1ae --- /dev/null +++ b/Sources/ShotdeckCore/Support/FolderSettings.swift @@ -0,0 +1,111 @@ +import Foundation + +/// User-configurable overrides for the outbox (PDF send destination) and watch folder +/// (AirDrop return), backed by UserDefaults. Shotdeck is NOT App-Sandboxed (no +/// `com.apple.security.app-sandbox` entitlement in this build — it is a plain, unsandboxed +/// SwiftPM executable). Security-scoped bookmarks exist to let a SANDBOXED app retain access +/// to a user-picked file/folder outside its container across relaunches; an unsandboxed +/// process already has the invoking user's own filesystem permissions on every launch, so a +/// plain absolute path stored in UserDefaults is sufficient — including for a mounted network +/// share, which resolves by path the same as any local folder for as long as it is mounted. +public enum FolderSettings { + public static let outboxDefaultsKey = "ai.flowmaster.shotdeck.outbox" + public static let watchFolderDefaultsKey = "ai.flowmaster.shotdeck.watchFolder" + + /// Raw stored path (or nil if never set / cleared). For display in Settings — does NOT + /// validate that the path still exists. + public static func storedOutboxPath(defaults: UserDefaults = .standard) -> String? { + defaults.string(forKey: outboxDefaultsKey) + } + + public static func storedWatchFolderPath(defaults: UserDefaults = .standard) -> String? { + defaults.string(forKey: watchFolderDefaultsKey) + } + + /// Persists a user-chosen folder as its absolute POSIX path. + public static func setOutbox(_ url: URL, defaults: UserDefaults = .standard) { + defaults.set(url.path, forKey: outboxDefaultsKey) + } + + public static func setWatchFolder(_ url: URL, defaults: UserDefaults = .standard) { + defaults.set(url.path, forKey: watchFolderDefaultsKey) + } + + /// Removes the override; resolve() falls back to the default again. + public static func resetOutbox(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: outboxDefaultsKey) + } + + public static func resetWatchFolder(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: watchFolderDefaultsKey) + } + + /// Resolves both folders. A stored override wins only if it is set AND the directory it + /// names still exists; otherwise falls back to the default (Desktop / Downloads). Never + /// throws — a missing/bad override is logged via `Log.ui` and silently replaced. + public static func resolve( + defaults: UserDefaults = .standard, + fileManager: FileManager = .default + ) -> (outbox: URL, watch: URL) { + let outbox = resolveOne( + storedPath: storedOutboxPath(defaults: defaults), + fallback: defaultOutbox(fileManager: fileManager), + label: "outbox", fileManager: fileManager) + let watch = resolveOne( + storedPath: storedWatchFolderPath(defaults: defaults), + fallback: defaultWatchFolder(fileManager: fileManager), + label: "watch", fileManager: fileManager) + return (outbox, watch) + } + + /// Builds an `AppSupportPaths` using `root` (defaults to the standard + /// `~/Library/Application Support/Shotdeck`, computed the same way + /// `AppSupportPaths.standard()` does, when `root` is nil) plus whatever `resolve()` + /// returns for outbox/watch. This is the ONLY place that combines FolderSettings with + /// AppSupportPaths — call this everywhere in the app instead of `AppSupportPaths.standard()`. + /// `root` is exposed purely so tests can point it at a temporary directory instead of the + /// user's real Application Support folder. + public static func resolvedAppSupportPaths( + root: URL? = nil, + defaults: UserDefaults = .standard, + fileManager: FileManager = .default + ) throws -> AppSupportPaths { + let resolvedRoot: URL + if let root { + resolvedRoot = root + } else { + let appSupportParent = try fileManager.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + resolvedRoot = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true) + } + let folders = resolve(defaults: defaults, fileManager: fileManager) + return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch) + } + + private static func defaultOutbox(fileManager: FileManager) -> URL { + fileManager.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Desktop", isDirectory: true) + } + + private static func defaultWatchFolder(fileManager: FileManager) -> URL { + fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads", isDirectory: true) + } + + private static func resolveOne( + storedPath: String?, + fallback: URL, + label: String, + fileManager: FileManager + ) -> URL { + guard let storedPath else { return fallback } + var isDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory) + guard exists, isDirectory.boolValue else { + Log.ui.warning("Configured \(label, privacy: .public) folder \(storedPath, privacy: .public) no longer exists; falling back to the default.") + return fallback + } + return URL(fileURLWithPath: storedPath, isDirectory: true) + } +} diff --git a/Sources/ShotdeckCore/Support/Log.swift b/Sources/ShotdeckCore/Support/Log.swift new file mode 100644 index 0000000..b230dfb --- /dev/null +++ b/Sources/ShotdeckCore/Support/Log.swift @@ -0,0 +1,9 @@ +import os + +public enum Log { + public static let spool = Logger(subsystem: "ai.flowmaster.shotdeck", category: "spool") + public static let pdf = Logger(subsystem: "ai.flowmaster.shotdeck", category: "pdf") + public static let capture = Logger(subsystem: "ai.flowmaster.shotdeck", category: "capture") + public static let returns = Logger(subsystem: "ai.flowmaster.shotdeck", category: "returns") + public static let ui = Logger(subsystem: "ai.flowmaster.shotdeck", category: "ui") +} diff --git a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift new file mode 100644 index 0000000..8bf6b34 --- /dev/null +++ b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift @@ -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.. (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) +} diff --git a/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift b/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift new file mode 100644 index 0000000..3576430 --- /dev/null +++ b/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +import ShotdeckCore + +@Test +func appSupportPathsCreatesEveryNamedDirectory() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-paths-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let root = temporaryRoot.appendingPathComponent("root", isDirectory: true) + let outbox = temporaryRoot.appendingPathComponent("outbox", isDirectory: true) + let watchFolder = temporaryRoot.appendingPathComponent("watch", isDirectory: true) + + let paths = try AppSupportPaths(root: root, outbox: outbox, watchFolder: watchFolder) + + #expect(directoryExists(paths.root)) + #expect(directoryExists(paths.spool)) + #expect(directoryExists(paths.archive)) + #expect(directoryExists(paths.outbox)) + #expect(directoryExists(paths.watchFolder)) + + #expect(paths.spool.path == root.appendingPathComponent("spool", isDirectory: true).path) + #expect(paths.archive.path == root.appendingPathComponent("archive", isDirectory: true).path) +} + +@Test +func sessionAndArchiveDirectoriesAreDistinctUnderTheRightParents() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-dirs-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let paths = try AppSupportPaths( + root: temporaryRoot.appendingPathComponent("root", isDirectory: true), + outbox: temporaryRoot.appendingPathComponent("outbox", isDirectory: true), + watchFolder: temporaryRoot.appendingPathComponent("watch", isDirectory: true) + ) + + let sessionID = UUID() + let sessionDirectory = paths.sessionDirectory(sessionID) + let archiveDirectory = paths.archiveDirectory(sessionID) + + #expect(sessionDirectory.path != archiveDirectory.path) + #expect(sessionDirectory.deletingLastPathComponent().path == paths.spool.path) + #expect(archiveDirectory.deletingLastPathComponent().path == paths.archive.path) + #expect(sessionDirectory.lastPathComponent == sessionID.uuidString) + #expect(archiveDirectory.lastPathComponent == sessionID.uuidString) +} + +@Test +func dubaiTimeStampRendersAKnownInstantInAsiaDubai() throws { + var calendar = Calendar(identifier: .gregorian) + let dubai = try #require(TimeZone(identifier: "Asia/Dubai")) + calendar.timeZone = dubai + + let date = try #require( + calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 30, + hour: 13, + minute: 42, + second: 5 + )) + ) + + #expect(DubaiTime.stamp(date) == "30 Aug 2026, 13:42 Dubai") + #expect(DubaiTime.fileStamp(date) == "20260830-134205") +} + +private func directoryExists(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue +} diff --git a/Tests/ShotdeckCoreTests/AtomicFileTests.swift b/Tests/ShotdeckCoreTests/AtomicFileTests.swift new file mode 100644 index 0000000..f81bb7a --- /dev/null +++ b/Tests/ShotdeckCoreTests/AtomicFileTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +import ShotdeckCore + +@Test +func atomicWriteProducesByteIdenticalFileAndLeavesNoTmp() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-write") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("payload.bin") + let data = Data("shotdeck-durable-bytes".utf8) + + try AtomicFile.write(data, to: url) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == data) + #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) +} + +@Test +func atomicWriteToTheSameURLTwiceKeepsTheSecondPayload() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-rewrite") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("payload.bin") + let first = Data("first-pass".utf8) + let second = Data("second-pass-wins".utf8) + + try AtomicFile.write(first, to: url) + try AtomicFile.write(second, to: url) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == second) + #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) +} + +@Test +func atomicWriteThrowsWhenParentDirectoryDoesNotExist() throws { + let missingParent = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-atomic-missing-\(UUID().uuidString)", isDirectory: true) + let url = missingParent.appendingPathComponent("payload.bin") + let data = Data("never-written".utf8) + + let error = try #require(throws: ShotdeckError.self) { + try AtomicFile.write(data, to: url) + } + guard case .spoolWriteFailed(let path, _) = error else { + Issue.record("expected spoolWriteFailed, got \(error)") + return + } + #expect(path == url.path) + #expect(!FileManager.default.fileExists(atPath: url.path)) +} + +@Test +func atomicWriteClearsAStaleTmpAndWritesTheNewPayload() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-stale-tmp") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("payload.bin") + let tmpURL = directory.appendingPathComponent(url.lastPathComponent + ".tmp") + let garbage = Data("stale-crash-garbage".utf8) + let newData = Data("recovered-payload".utf8) + + try garbage.write(to: tmpURL) + #expect(FileManager.default.fileExists(atPath: tmpURL.path)) + + try AtomicFile.write(newData, to: url) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == newData) + #expect(!FileManager.default.fileExists(atPath: tmpURL.path)) +} + +@Test +func writeJSONSortsKeysPrettyPrintsAndEncodesDatesAsISO8601() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-json-format") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("session.json") + let date = try iso8601Date(year: 2026, month: 8, day: 30, hour: 9, minute: 42, second: 5) + let value = JSONProbe(zebra: "last", apple: 7, capturedAt: date) + + try AtomicFile.writeJSON(value, to: url) + + let raw = try Data(contentsOf: url) + let text = try #require(String(data: raw, encoding: .utf8)) + + let apple = try #require(text.range(of: "\"apple\"")) + let capturedAt = try #require(text.range(of: "\"capturedAt\"")) + let zebra = try #require(text.range(of: "\"zebra\"")) + #expect(apple.lowerBound < capturedAt.lowerBound) + #expect(capturedAt.lowerBound < zebra.lowerBound) + #expect(text.contains("\n")) + #expect(text.contains(" \"apple\"")) + #expect(text.contains("2026-08-30T09:42:05Z")) + #expect(!text.contains("\(date.timeIntervalSinceReferenceDate)")) + #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) +} + +@Test +func writeJSONRoundTripsThroughISO8601Decoder() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-json-roundtrip") + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("session.json") + let date = try iso8601Date(year: 2026, month: 8, day: 31, hour: 13, minute: 5, second: 9) + let original = JSONProbe(zebra: "keep", apple: 42, capturedAt: date) + + try AtomicFile.writeJSON(original, to: url) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(JSONProbe.self, from: Data(contentsOf: url)) + #expect(decoded == original) +} + +@Test +func fsyncDirectorySucceedsOnADirectoryAndThrowsOnMissingOrFilePaths() throws { + let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-fsync-dir") + defer { try? FileManager.default.removeItem(at: directory) } + + try AtomicFile.fsyncDirectory(at: directory) + + let missing = directory.appendingPathComponent("does-not-exist", isDirectory: true) + let missingError = try #require(throws: ShotdeckError.self) { + try AtomicFile.fsyncDirectory(at: missing) + } + guard case .spoolWriteFailed = missingError else { + Issue.record("expected spoolWriteFailed for a missing path, got \(missingError)") + return + } + + let fileURL = directory.appendingPathComponent("not-a-directory.bin") + try AtomicFile.write(Data("file".utf8), to: fileURL) + let fileError = try #require(throws: ShotdeckError.self) { + try AtomicFile.fsyncDirectory(at: fileURL) + } + guard case .spoolWriteFailed = fileError else { + Issue.record("expected spoolWriteFailed for a file path, got \(fileError)") + return + } +} + +private struct JSONProbe: Codable, Equatable { + var zebra: String + var apple: Int + var capturedAt: Date +} + +private func makeTemporaryDirectory(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func iso8601Date( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int +) throws -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + return try #require( + calendar.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + )) + ) +} diff --git a/Tests/ShotdeckCoreTests/CaptureRegionTests.swift b/Tests/ShotdeckCoreTests/CaptureRegionTests.swift new file mode 100644 index 0000000..792b9d1 --- /dev/null +++ b/Tests/ShotdeckCoreTests/CaptureRegionTests.swift @@ -0,0 +1,171 @@ +import Carbon.HIToolbox +import Foundation +import Testing +@testable import ShotdeckCore + +@Test("GEO-1 primary-only 1080-tall screen, AppKit rect (100,100,400,300)") +func geo1_primaryOnlyAppKitRectConvertsToExpectedCGRect() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 100, y: 100, width: 400, height: 300), + displayID: 1, + capturedScale: 2, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: 100, y: 680, width: 400, height: 300)) +} + +@Test("GEO-2 rect flush to AppKit bottom (y=0), h=300, primaryHeight=1080") +func geo2_flushToAppKitBottomYieldsCGY780() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 50, y: 0, width: 200, height: 300), + displayID: 1, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect.origin.y == 780) +} + +@Test("GEO-3 rect flush to AppKit top (y+h=primaryHeight), primaryHeight=1080, h=300") +func geo3_flushToAppKitTopYieldsCGY0() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 50, y: 780, width: 200, height: 300), + displayID: 1, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect.origin.y == 0) +} + +@Test("GEO-4 round-trip AppKit→CG via injectable overload, then invert recovers original") +func geo4_invertRecoverOriginalAppKitRect() { + let original = CGRect(x: 100, y: 100, width: 400, height: 300) + let primaryHeight: CGFloat = 1080 + let region = CaptureRegion.fromAppKit( + rect: original, + displayID: 1, + capturedScale: 2, + primaryHeight: primaryHeight + ) + let appKitY = primaryHeight - region.rect.origin.y - region.rect.height + let recovered = CGRect( + x: region.rect.origin.x, + y: appKitY, + width: region.rect.width, + height: region.rect.height + ) + #expect(recovered == original) +} + +@Test("GEO-5 isStillValid for displayID 999_999 is false and does not trap") +func geo5_unknownDisplayIsNotStillValid() { + let region = CaptureRegion( + displayID: 999_999, + rect: CGRect(x: 0, y: 0, width: 100, height: 100), + capturedScale: 1 + ) + #expect(region.isStillValid == false) +} + +@Test("GEO-6 CaptureRegion JSONEncoder→JSONDecoder round-trip equals original") +func geo6_codableRoundTripEqualsOriginal() throws { + let original = CaptureRegion( + displayID: 42, + rect: CGRect(x: 10, y: 20, width: 300, height: 400), + capturedScale: 2 + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CaptureRegion.self, from: data) + #expect(decoded == original) +} + +@Test("GEO-7 secondary screen negative x: AppKit (−1800,200,500,400) → CG (−1800,480,500,400)") +func geo7_secondaryNegativeXLeavesXUnchanged() { + // Secondary AppKit frame (−1920, 0, 1920, 1080); primaryHeight=1080. + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: -1800, y: 200, width: 500, height: 400), + displayID: 2, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: -1800, y: 480, width: 500, height: 400)) +} + +@Test("GEO-8 secondary screen stacked above primary: AppKit (300,1300,600,350) → CG (300,−570,600,350)") +func geo8_secondaryPositiveYProducesNegativeCGY() { + // Secondary AppKit frame (0, 1080, 1920, 1080); primaryHeight=1080. + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 300, y: 1300, width: 600, height: 350), + displayID: 2, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: 300, y: -570, width: 600, height: 350)) +} + +/// Carbon registrations are process-wide, so these cases must not run in parallel. +@Suite(.serialized) +@MainActor +struct HotkeyCenterCarbonTests { + /// kVK_ANSI_2. The app registers this combo once (WP-4a); tests only exercise the registrar. + private let captureKeyCode: UInt32 = 19 + private let captureModifiers = UInt32(optionKey) | UInt32(shiftKey) + + @Test("HK-1 register id a with Option-Shift-2 returns true") + func hk1_registerReturnsTrue() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let ok = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !ok { + Issue.record("HK-1: RegisterEventHotKey returned non-noErr inside swift test (no NSApplication). Carbon registration did not function headlessly.") + return + } + #expect(ok) + } + + @Test("HK-2 register same combo under a different id returns false") + func hk2_duplicateComboRejected() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !first { + Issue.record("HK-2: first RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate duplicate-combo rejection.") + return + } + let second = center.register(id: "b", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(second == false) + } + + @Test("HK-3 unregister frees the combination so a later register succeeds") + func hk3_unregisterFreesCombination() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !first { + Issue.record("HK-3: first RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate unregister.") + return + } + center.unregister(id: "a") + let again = center.register(id: "c", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(again) + } + + @Test("HK-4 unregisterAll frees combinations so a later register succeeds") + func hk4_unregisterAllFreesCombinations() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + let other = center.register( + id: "other", + keyCode: UInt32(kVK_ANSI_3), + modifiers: captureModifiers + ) {} + if !first { + Issue.record("HK-4: RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate unregisterAll.") + return + } + _ = other + center.unregisterAll() + let again = center.register(id: "c", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(again) + } +} diff --git a/Tests/ShotdeckCoreTests/FolderSettingsTests.swift b/Tests/ShotdeckCoreTests/FolderSettingsTests.swift new file mode 100644 index 0000000..855b67e --- /dev/null +++ b/Tests/ShotdeckCoreTests/FolderSettingsTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +import ShotdeckCore + +@Test +func resolveWithoutStoredKeysReturnsDesktopAndDownloads() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + let expectedWatch = try #require( + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ) + + #expect(resolved.outbox.path == expectedOutbox.path) + #expect(resolved.watch.path == expectedWatch.path) +} + +@Test +func setOutboxToAnExistingDirectoryIsReturnedByResolve() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-outbox-set") + defer { try? FileManager.default.removeItem(at: outbox) } + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedWatch = try #require( + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ) + + #expect(resolved.outbox.path == outbox.path) + #expect(resolved.watch.path == expectedWatch.path) + #expect(FolderSettings.storedOutboxPath(defaults: suite.defaults) == outbox.path) +} + +@Test +func resolveFallsBackWhenTheStoredOutboxDirectoryIsGone() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-outbox-gone") + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + try FileManager.default.removeItem(at: outbox) + + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + #expect(resolved.outbox.path == expectedOutbox.path) +} + +@Test +func setWatchFolderMirrorsOutboxOverrideAndFallbackIndependently() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let watch = try makeTemporaryDirectory(prefix: "shotdeck-watch-set") + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + + FolderSettings.setWatchFolder(watch, defaults: suite.defaults) + let withOverride = FolderSettings.resolve(defaults: suite.defaults) + #expect(withOverride.watch.path == watch.path) + #expect(withOverride.outbox.path == expectedOutbox.path) + + try FileManager.default.removeItem(at: watch) + let afterDelete = FolderSettings.resolve(defaults: suite.defaults) + let expectedWatch = try #require( + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ) + #expect(afterDelete.watch.path == expectedWatch.path) + #expect(afterDelete.outbox.path == expectedOutbox.path) +} + +@Test +func resetOutboxClearsTheOverride() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-outbox-reset") + defer { try? FileManager.default.removeItem(at: outbox) } + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + FolderSettings.resetOutbox(defaults: suite.defaults) + + let resolved = FolderSettings.resolve(defaults: suite.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + #expect(resolved.outbox.path == expectedOutbox.path) + #expect(FolderSettings.storedOutboxPath(defaults: suite.defaults) == nil) +} + +@Test +func resolvedAppSupportPathsUsesTheOutboxOverrideAndCreatesSpoolArchive() throws { + let suite = try makeDefaultsSuite() + defer { tearDown(suite) } + let root = try makeTemporaryDirectory(prefix: "shotdeck-paths-root") + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-paths-outbox") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outbox) + } + + FolderSettings.setOutbox(outbox, defaults: suite.defaults) + let paths = try FolderSettings.resolvedAppSupportPaths(root: root, defaults: suite.defaults) + + #expect(paths.outbox.path == outbox.path) + #expect(directoryExists(paths.spool)) + #expect(directoryExists(paths.archive)) + #expect(paths.spool.deletingLastPathComponent().path == root.path) + #expect(paths.archive.deletingLastPathComponent().path == root.path) +} + +@Test +func userDefaultsSuitesDoNotLeakFolderOverrides() throws { + let suiteA = try makeDefaultsSuite() + let suiteB = try makeDefaultsSuite() + defer { + tearDown(suiteA) + tearDown(suiteB) + } + let outbox = try makeTemporaryDirectory(prefix: "shotdeck-suite-a-outbox") + defer { try? FileManager.default.removeItem(at: outbox) } + + FolderSettings.setOutbox(outbox, defaults: suiteA.defaults) + + let resolvedA = FolderSettings.resolve(defaults: suiteA.defaults) + let resolvedB = FolderSettings.resolve(defaults: suiteB.defaults) + let expectedOutbox = try #require( + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ) + + #expect(resolvedA.outbox.path == outbox.path) + #expect(resolvedB.outbox.path == expectedOutbox.path) +} + +private struct DefaultsSuite { + let name: String + let defaults: UserDefaults +} + +private func makeDefaultsSuite() throws -> DefaultsSuite { + let name = "shotdeck-test-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return DefaultsSuite(name: name, defaults: defaults) +} + +private func tearDown(_ suite: DefaultsSuite) { + suite.defaults.removePersistentDomain(forName: suite.name) +} + +private func makeTemporaryDirectory(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func directoryExists(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue +} diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift new file mode 100644 index 0000000..0b3987c --- /dev/null +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -0,0 +1,590 @@ +import CoreGraphics +import Foundation +import ImageIO +import PDFKit +import ShotdeckCore +import Testing +import UniformTypeIdentifiers + +enum TestFixtureError: Error { case cannotCreateBitmap, cannotCreatePNG, cannotFinalizePNG } + +func writeSolidPNG( + pixelWidth: Int, + pixelHeight: Int, + red: UInt8, + green: UInt8, + blue: UInt8, + to url: URL +) throws { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw TestFixtureError.cannotCreateBitmap } + context.setFillColor(CGColor( + red: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: 1 + )) + context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) + else { throw TestFixtureError.cannotCreatePNG } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { throw TestFixtureError.cannotFinalizePNG } +} + +private let fixtureDate = Date(timeIntervalSince1970: 1_777_482_180) + +private func makeCapture( + sequence: Int, + pixelWidth: Int, + pixelHeight: Int, + scale: CGFloat = 1, + capturedAt: Date = fixtureDate, + id: UUID = UUID(), + fileName: String? = nil +) -> Capture { + Capture( + id: id, + sequence: sequence, + fileName: fileName ?? String(format: "%03d.png", sequence), + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + scale: scale, + capturedAt: capturedAt + ) +} + +private func makeSession( + captures: [Capture], + id: UUID = UUID(), + createdAt: Date = fixtureDate +) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: .open, + captures: captures, + pdfFileName: nil + ) +} + +private func makeScratchDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-pdf-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func writePNG( + for capture: Capture, + red: UInt8, + green: UInt8, + blue: UInt8, + in directory: URL +) throws -> URL { + let url = directory.appendingPathComponent(capture.fileName) + try writeSolidPNG( + pixelWidth: capture.pixelWidth, + pixelHeight: capture.pixelHeight, + red: red, + green: green, + blue: blue, + to: url + ) + return url +} + +private func imageURLMap(_ urls: [UUID: URL]) -> (Capture) -> URL { + { capture in + urls[capture.id] ?? URL(fileURLWithPath: "/nonexistent/\(capture.id.uuidString).png") + } +} + +private func openDocument(_ url: URL) throws -> PDFDocument { + let document = try #require(PDFDocument(url: url)) + return document +} + +private func pdftoppm(pdf: URL, prefix: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/pdftoppm") + process.arguments = ["-png", "-r", "110", pdf.path, prefix.path] + let err = Pipe() + process.standardError = err + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw ShotdeckError.pdfCompositionFailed(reason: "pdftoppm failed: \(message)") + } +} + +private struct RGBABitmap { + let width: Int + let height: Int + let bytesPerRow: Int + let data: Data + + func pixel(x: Int, y: Int) -> (UInt8, UInt8, UInt8)? { + guard x >= 0, y >= 0, x < width, y < height else { return nil } + let offset = y * bytesPerRow + x * 4 + guard offset + 2 < data.count else { return nil } + return (data[offset], data[offset + 1], data[offset + 2]) + } + + func containsNonWhite(x0: Int, y0: Int, x1: Int, y1: Int) -> Bool { + let xStart = max(0, x0) + let yStart = max(0, y0) + let xEnd = min(width, x1) + let yEnd = min(height, y1) + guard xStart < xEnd, yStart < yEnd else { return false } + for y in yStart.. RGBABitmap { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + let width = image.width + let height = image.height + let bytesPerRow = width * 4 + var data = Data(count: bytesPerRow * height) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw TestFixtureError.cannotCreateBitmap + } + guard let context = CGContext( + data: base, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw TestFixtureError.cannotCreateBitmap + } + context.interpolationQuality = .none + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return RGBABitmap(width: width, height: height, bytesPerRow: bytesPerRow, data: data) + } +} + +private func pixelWindow( + for rect: CGRect, + pageHeightPt: CGFloat, + scale k: CGFloat +) -> (x0: Int, y0: Int, x1: Int, y1: Int) { + let pixelX0 = Int(floor(rect.minX * k)) + let pixelX1 = Int(ceil(rect.maxX * k)) + let pixelY0 = Int(floor((pageHeightPt - rect.maxY) * k)) + let pixelY1 = Int(ceil((pageHeightPt - rect.minY) * k)) + return (pixelX0, pixelY0, pixelX1, pixelY1) +} + +@Test("fileName uses compose time, not session.createdAt, and matches Redline-yyyyMMdd-HHmmss.pdf") +func fileNameUsesComposeTimeNotSessionCreatedAt() { + let old = Date(timeIntervalSince1970: 1_600_000_000) // 2020-09-13 + let session = makeSession(captures: [], createdAt: old) + let name = PDFComposer.fileName(for: session) + #expect(name.wholeMatch(of: /^Redline-\d{8}-\d{6}\.pdf$/) != nil) + #expect(!name.contains(DubaiTime.fileStamp(old))) + let today = String(DubaiTime.fileStamp(Date()).prefix(8)) + #expect(name.contains(today)) +} + +@Test("Three-page basic compose") +func threePageBasicCompose() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let c1 = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let c2 = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) + let c3 = makeCapture(sequence: 3, pixelWidth: 1200, pixelHeight: 800) + let urls: [UUID: URL] = [ + c1.id: try writePNG(for: c1, red: 200, green: 20, blue: 20, in: directory), + c2.id: try writePNG(for: c2, red: 20, green: 200, blue: 20, in: directory), + c3.id: try writePNG(for: c3, red: 20, green: 20, blue: 200, in: directory), + ] + let session = makeSession(captures: [c1, c2, c3]) + let output = directory.appendingPathComponent("three.pdf") + let pageCount = try PDFComposer().compose( + session: session, + imageURL: imageURLMap(urls), + title: "Three page", + to: output + ) + #expect(pageCount == 3) + let document = try openDocument(output) + #expect(document.pageCount == 3) + let p0 = try #require(document.page(at: 0)) + let p1 = try #require(document.page(at: 1)) + let p2 = try #require(document.page(at: 2)) + #expect(p0.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) + #expect(p1.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) + #expect(p2.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) +} + +@Test("Wide/tall page geometry exact") +func wideTallPageGeometryExact() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let tall = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) + let wideURL = try writePNG(for: wide, red: 10, green: 10, blue: 10, in: directory) + let tallURL = try writePNG(for: tall, red: 10, green: 10, blue: 10, in: directory) + + let wideOut = directory.appendingPathComponent("wide.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [wide]), + imageURL: { _ in wideURL }, + title: "Wide", + to: wideOut + ) + let tallOut = directory.appendingPathComponent("tall.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [tall]), + imageURL: { _ in tallURL }, + title: "Tall", + to: tallOut + ) + + let widePage = try #require(openDocument(wideOut).page(at: 0)) + let tallPage = try #require(openDocument(tallOut).page(at: 0)) + #expect(widePage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) + #expect(tallPage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) +} + +@Test("Square resolves to portrait (Capture.swift:39-42 isLandscape strict >)") +func squareResolvesToPortrait() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let square = makeCapture(sequence: 1, pixelWidth: 1000, pixelHeight: 1000) + let png = try writePNG(for: square, red: 80, green: 80, blue: 80, in: directory) + let output = directory.appendingPathComponent("square.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [square]), + imageURL: { _ in png }, + title: "Square", + to: output + ) + let page = try #require(openDocument(output).page(at: 0)) + #expect(page.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) +} + +@Test("Retina capture uses point size, not pixel size") +func retinaCaptureUsesPointSize() throws { + let capture = makeCapture(sequence: 1, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) + let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) + let expectedScale = min(806.0 / 1440.0, 523.0 / 810.0, 1.0) + let expected = CGSize(width: 1440.0 * expectedScale, height: 810.0 * expectedScale) + #expect(abs(layout.imageRect.width - expected.width) < 0.01) + #expect(abs(layout.imageRect.height - expected.height) < 0.01) + #expect(layout.imageRect.width < 2000) + #expect(layout.isLandscape) +} + +@Test("Zero PDFAnnotation objects on every page") +func zeroPDFAnnotationObjects() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let c1 = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 600) + let c2 = makeCapture(sequence: 2, pixelWidth: 600, pixelHeight: 800) + let c3 = makeCapture(sequence: 3, pixelWidth: 1000, pixelHeight: 1000) + let urls: [UUID: URL] = [ + c1.id: try writePNG(for: c1, red: 30, green: 30, blue: 30, in: directory), + c2.id: try writePNG(for: c2, red: 40, green: 40, blue: 40, in: directory), + c3.id: try writePNG(for: c3, red: 50, green: 50, blue: 50, in: directory), + ] + let output = directory.appendingPathComponent("no-annots.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [c1, c2, c3]), + imageURL: imageURLMap(urls), + title: "No annotations", + to: output + ) + let document = try openDocument(output) + #expect(document.pageCount == 3) + for index in 0.. 150 && c1.1 < 80 && c1.2 < 80) + #expect(c2.2 > 150 && c2.0 < 80 && c2.1 < 80) +} + +@Test("An unreadable (corrupt) PNG is treated exactly like a missing one (D-A)") +func unreadablePNGIsSkippedLikeMissing() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let good = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 500) + let bad = makeCapture(sequence: 2, pixelWidth: 800, pixelHeight: 500) + let goodURL = try writePNG(for: good, red: 30, green: 140, blue: 30, in: directory) + let badURL = directory.appendingPathComponent("corrupt.png") + try Data([0x00, 0x01, 0x02, 0xFF, 0xD8, 0x00]).write(to: badURL) + let urls: [UUID: URL] = [good.id: goodURL, bad.id: badURL] + let output = directory.appendingPathComponent("skip-corrupt.pdf") + let pageCount = try PDFComposer().compose( + session: makeSession(captures: [good, bad]), + imageURL: imageURLMap(urls), + title: "Skip corrupt", + to: output + ) + #expect(pageCount == 1) + let document = try openDocument(output) + #expect(document.pageCount == 1) +} + +@Test("Document attributes round-trip exactly") +func documentAttributesRoundTrip() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let sessionID = try #require(UUID(uuidString: "deadbeef-dead-4eef-8ead-deadbeef0001")) + let capture = makeCapture(sequence: 1, pixelWidth: 640, pixelHeight: 480) + let png = try writePNG(for: capture, red: 40, green: 40, blue: 40, in: directory) + let output = directory.appendingPathComponent("attrs.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [capture], id: sessionID), + imageURL: { _ in png }, + title: "Ben review — 2026-08-30", + to: output + ) + let document = try openDocument(output) + let attributes = try #require(document.documentAttributes) + #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "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) +} diff --git a/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift b/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift new file mode 100644 index 0000000..fc9b4dc --- /dev/null +++ b/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +import ShotdeckCore + +private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) { + let cleanup = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-wp5a-ledger-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: cleanup.appendingPathComponent("root", isDirectory: true), + outbox: cleanup.appendingPathComponent("outbox", isDirectory: true), + watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true) + ) + return (paths, cleanup) +} + +private func document( + path: String, + annotatedPages: [Int], + detectedAt: Date, + sessionID: UUID? = nil, + pageCount: Int = 1 +) -> ReturnedDocument { + ReturnedDocument( + fileURL: URL(fileURLWithPath: path), + sessionID: sessionID, + pageCount: pageCount, + annotatedPages: annotatedPages, + detectedAt: detectedAt + ) +} + +@Test("L-1 commented() returns only commented entries, newest first") +func l1_commentedReturnsOnlyCommentedNewestFirst() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let newer = document( + path: "/tmp/shotdeck-returns/newer.pdf", + annotatedPages: [1, 2], + detectedAt: Date(timeIntervalSince1970: 1_777_000_200) + ) + let older = document( + path: "/tmp/shotdeck-returns/older.pdf", + annotatedPages: [3], + detectedAt: Date(timeIntervalSince1970: 1_777_000_100) + ) + let clean = document( + path: "/tmp/shotdeck-returns/clean.pdf", + annotatedPages: [], + detectedAt: Date(timeIntervalSince1970: 1_777_000_300) + ) + + let ledger = try ReturnLedger(paths: paths) + try await ledger.record(older) + try await ledger.record(newer) + try await ledger.record(clean) + + let commented = try await ledger.commented() + #expect(commented.map(\.fileURL) == [newer.fileURL, older.fileURL]) + #expect(commented.map(\.annotatedPages) == [[1, 2], [3]]) +} + +@Test("L-2 clipboardText() exact format") +func l2_clipboardTextExactFormat() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let newer = document( + path: "/tmp/shotdeck-returns/newer.pdf", + annotatedPages: [1, 2], + detectedAt: Date(timeIntervalSince1970: 1_777_000_200) + ) + let older = document( + path: "/tmp/shotdeck-returns/older.pdf", + annotatedPages: [3], + detectedAt: Date(timeIntervalSince1970: 1_777_000_100) + ) + let clean = document( + path: "/tmp/shotdeck-returns/clean.pdf", + annotatedPages: [], + detectedAt: Date(timeIntervalSince1970: 1_777_000_300) + ) + + let ledger = try ReturnLedger(paths: paths) + try await ledger.record(older) + try await ledger.record(newer) + try await ledger.record(clean) + + let text = try await ledger.clipboardText() + #expect(text == newer.fileURL.path + "\n" + older.fileURL.path) + #expect(text.hasSuffix("\n") == false) +} + +@Test("L-3 Re-recording the same URL upserts") +func l3_rerecordingTheSameURLUpserts() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let url = URL(fileURLWithPath: "/tmp/shotdeck-returns/same.pdf") + let first = ReturnedDocument( + fileURL: url, + sessionID: nil, + pageCount: 1, + annotatedPages: [], + detectedAt: Date(timeIntervalSince1970: 1_777_000_400) + ) + let second = ReturnedDocument( + fileURL: url, + sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111"), + pageCount: 1, + annotatedPages: [1], + detectedAt: Date(timeIntervalSince1970: 1_777_000_100) + ) + + let ledger = try ReturnLedger(paths: paths) + try await ledger.record(first) + try await ledger.record(second) + + let all = try await ledger.all() + #expect(all.count == 1) + #expect(all[0].fileURL == url) + #expect(all[0].annotatedPages == [1]) + #expect(all[0].isCommented == true) + #expect(all[0].sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111")) +} + +@Test("L-4 clipboardText() throws when nothing is commented") +func l4_clipboardTextThrowsWhenNothingIsCommented() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let ledger = try ReturnLedger(paths: paths) + let emptyError = try await #require(throws: ShotdeckError.self) { + try await ledger.clipboardText() + } + guard case .noCommentedReturns = emptyError else { + Issue.record("expected noCommentedReturns on an empty ledger, got \(emptyError)") + return + } + + try await ledger.record(document( + path: "/tmp/shotdeck-returns/clean-only.pdf", + annotatedPages: [], + detectedAt: Date(timeIntervalSince1970: 1_777_000_500) + )) + let cleanError = try await #require(throws: ShotdeckError.self) { + try await ledger.clipboardText() + } + guard case .noCommentedReturns = cleanError else { + Issue.record("expected noCommentedReturns with only clean returns, got \(cleanError)") + return + } +} + +@Test("L-5 Ledger survives reopening from disk") +func l5_ledgerSurvivesReopeningFromDisk() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let first = document( + path: "/tmp/shotdeck-returns/a.pdf", + annotatedPages: [1], + detectedAt: Date(timeIntervalSince1970: 1_777_000_700), + sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111") + ) + let second = document( + path: "/tmp/shotdeck-returns/b.pdf", + annotatedPages: [], + detectedAt: Date(timeIntervalSince1970: 1_777_000_600) + ) + + do { + let ledger = try ReturnLedger(paths: paths) + try await ledger.record(first) + try await ledger.record(second) + } + + let reopened = try ReturnLedger(paths: paths) + let all = try await reopened.all() + #expect(all == [first, second]) +} + +@Test("L-6 Corrupt returns.json is renamed and the ledger starts empty") +func l6_corruptReturnsJSONIsRenamedAndStartsEmpty() async throws { + let (paths, cleanup) = try makeCasePaths() + defer { try? FileManager.default.removeItem(at: cleanup) } + + let returnsURL = paths.root.appendingPathComponent("returns.json") + try Data("{not-json".utf8).write(to: returnsURL) + #expect(FileManager.default.fileExists(atPath: returnsURL.path)) + + let ledger = try ReturnLedger(paths: paths) + #expect(try await ledger.all() == []) + #expect(FileManager.default.fileExists(atPath: returnsURL.path) == false) + + let names = try FileManager.default.contentsOfDirectory(atPath: paths.root.path) + let corrupt = names.filter { $0.hasPrefix("returns.json.corrupt-") } + #expect(corrupt.count == 1) + #expect(corrupt[0].contains(DubaiTime.fileStamp(Date()).prefix(8))) +} diff --git a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift new file mode 100644 index 0000000..95bce13 --- /dev/null +++ b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift @@ -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.. (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() + } +} diff --git a/Tests/ShotdeckCoreTests/SpoolStoreTests.swift b/Tests/ShotdeckCoreTests/SpoolStoreTests.swift new file mode 100644 index 0000000..88f35bc --- /dev/null +++ b/Tests/ShotdeckCoreTests/SpoolStoreTests.swift @@ -0,0 +1,590 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import ShotdeckCore + +// MARK: - 1. append writes a real, decodable PNG + +@Test +func appendWritesARealDecodablePNGMatchingSourceDimensions() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let width = 12 + let height = 7 + let png = try makePNGData(width: width, height: height, red: 0.9, green: 0.1, blue: 0.2) + let store = try SpoolStore(paths: paths) + let capturedAt = Date(timeIntervalSince1970: 1_700_000_000) + let capture = try await store.append( + pngData: png, pixelWidth: width, pixelHeight: height, scale: 2.0, capturedAt: capturedAt) + let session = try await store.currentSession() + + #expect(capture.pixelWidth == width) + #expect(capture.pixelHeight == height) + + let url = await store.imageURL(for: capture, in: session) + let expected = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName) + #expect(url.path == expected.path) + #expect(FileManager.default.fileExists(atPath: url.path)) + + let onDisk = try Data(contentsOf: url) + #expect(onDisk == png) + let decoded = try #require(pngDimensions(at: url)) + #expect(decoded.width == width) + #expect(decoded.height == height) +} + +// MARK: - 2. remaining-manifest sequence rule (coordinator correction) + +@Test +func sequencesFollowRemainingManifestMaxAndAreNotRenumbered() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let c1 = try await store.append( + pngData: try makePNGData(width: 4, height: 4, red: 1, green: 0, blue: 0), + pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date()) + let c2 = try await store.append( + pngData: try makePNGData(width: 6, height: 4, red: 0, green: 1, blue: 0), + pixelWidth: 6, pixelHeight: 4, scale: 1.0, capturedAt: Date()) + let c3 = try await store.append( + pngData: try makePNGData(width: 8, height: 4, red: 0, green: 0, blue: 1), + pixelWidth: 8, pixelHeight: 4, scale: 1.0, capturedAt: Date()) + #expect(c1.sequence == 1) + #expect(c2.sequence == 2) + #expect(c3.sequence == 3) + + _ = try await store.remove(captureID: c2.id) + let afterMiddle = try await store.currentSession() + #expect(afterMiddle.captures.map(\.sequence) == [1, 3]) + #expect(afterMiddle.captures.map(\.id) == [c1.id, c3.id]) + + let c4 = try await store.append( + pngData: try makePNGData(width: 10, height: 4, red: 1, green: 1, blue: 0), + pixelWidth: 10, pixelHeight: 4, scale: 1.0, capturedAt: Date()) + #expect(c4.sequence == 4) + + _ = try await store.remove(captureID: c4.id) + let afterLast = try await store.currentSession() + #expect(afterLast.captures.map(\.sequence) == [1, 3]) + + let c4b = try await store.append( + pngData: try makePNGData(width: 12, height: 4, red: 1, green: 0, blue: 1), + pixelWidth: 12, pixelHeight: 4, scale: 1.0, capturedAt: Date()) + #expect(c4b.sequence == 4) + #expect(c4b.fileName != c4.fileName) + #expect(c4b.id != c4.id) + + let sessionDir = paths.sessionDirectory(afterLast.id) + #expect(FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4b.fileName).path)) + #expect(FileManager.default.fileExists( + atPath: sessionDir.appendingPathComponent("removed", isDirectory: true) + .appendingPathComponent(c4.fileName).path)) + #expect(!FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4.fileName).path)) +} + +// MARK: - 3. remove moves PNG into removed/ + +@Test +func removeMovesThePNGUnchangedIntoRemovedAndLeavesTheOriginalPathEmpty() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let png = try makePNGData(width: 8, height: 5, red: 0.2, green: 0.5, blue: 0.8) + let capture = try await store.append( + pngData: png, pixelWidth: 8, pixelHeight: 5, scale: 2.0, capturedAt: Date()) + let session = try await store.currentSession() + let sessionDir = paths.sessionDirectory(session.id) + let originalURL = sessionDir.appendingPathComponent(capture.fileName) + let removedURL = sessionDir.appendingPathComponent("removed", isDirectory: true) + .appendingPathComponent(capture.fileName) + + let updated = try await store.remove(captureID: capture.id) + + #expect(updated.captures.contains(where: { $0.id == capture.id }) == false) + #expect(!FileManager.default.fileExists(atPath: originalURL.path)) + #expect(FileManager.default.fileExists(atPath: removedURL.path)) + let moved = try Data(contentsOf: removedURL) + #expect(moved == png) +} + +// MARK: - 4. remove of unknown id throws with zero filesystem change + +@Test +func removeOfUnknownIDThrowsAndLeavesDiskByteIdentical() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + _ = try await store.append( + pngData: try makePNGData(width: 4, height: 4, red: 0.1, green: 0.2, blue: 0.3), + pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date()) + let beforeSession = try await store.currentSession() + let beforeFiles = try snapshotFiles(under: root) + + do { + _ = try await store.remove(captureID: UUID()) + Issue.record("expected remove of an unknown id to throw") + } catch let error as ShotdeckError { + guard case .spoolWriteFailed = error else { + Issue.record("expected spoolWriteFailed, got \(error)") + return + } + } catch { + Issue.record("expected ShotdeckError, got \(error)") + return + } + + let afterSession = try await store.currentSession() + #expect(afterSession == beforeSession) + let afterFiles = try snapshotFiles(under: root) + #expect(afterFiles == beforeFiles) +} + +// MARK: - 5. orphan recovery + +@Test +func orphanPNGWrittenBehindTheStoreIsRecoveredOnReopen() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let session = try await store.currentSession() + let width = 11 + let height = 9 + let png = try makePNGData(width: width, height: height, red: 0.4, green: 0.7, blue: 0.1) + let orphanName = "007-ABCD1234.png" + let dest = paths.sessionDirectory(session.id).appendingPathComponent(orphanName) + try AtomicFile.write(png, to: dest) + + let reopened = try SpoolStore(paths: paths) + let recovered = try await reopened.currentSession() + let match = try #require(recovered.captures.first { $0.fileName == orphanName }) + #expect(match.pixelWidth == width) + #expect(match.pixelHeight == height) + #expect(match.sequence == 7) + #expect(match.scale == 1.0) +} + +// MARK: - 6. removed/ files are not resurrected + +@Test +func orphanRecoveryDoesNotResurrectFilesInRemoved() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let session = try await store.currentSession() + let sessionDir = paths.sessionDirectory(session.id) + let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true) + try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true) + let hiddenName = "009-FEEDFACE.png" + try AtomicFile.write( + try makePNGData(width: 5, height: 5, red: 0.9, green: 0.9, blue: 0.1), + to: removedDir.appendingPathComponent(hiddenName)) + + let reopened = try SpoolStore(paths: paths) + let reconciled = try await reopened.currentSession() + #expect(reconciled.captures.contains(where: { $0.fileName == hiddenName }) == false) + #expect(FileManager.default.fileExists(atPath: removedDir.appendingPathComponent(hiddenName).path)) +} + +// MARK: - 7. missing-image drop + +@Test +func missingPNGReferencedByManifestIsDroppedAndOthersSurvive() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let keep = try await store.append( + pngData: try makePNGData(width: 6, height: 6, red: 0.1, green: 0.8, blue: 0.2), + pixelWidth: 6, pixelHeight: 6, scale: 1.0, capturedAt: Date()) + let drop = try await store.append( + pngData: try makePNGData(width: 7, height: 7, red: 0.8, green: 0.1, blue: 0.2), + pixelWidth: 7, pixelHeight: 7, scale: 1.0, capturedAt: Date()) + let session = try await store.currentSession() + try FileManager.default.removeItem( + at: paths.sessionDirectory(session.id).appendingPathComponent(drop.fileName)) + + let reopened = try SpoolStore(paths: paths) + let reconciled = try await reopened.currentSession() + #expect(reconciled.captures.map(\.id) == [keep.id]) + #expect(reconciled.captures.contains(where: { $0.id == drop.id }) == false) +} + +// MARK: - 8. corrupt manifest rebuild + +@Test +func corruptManifestIsQuarantinedAndPNGsAreRebuiltIntoANewManifest() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let first = try await store.append( + pngData: try makePNGData(width: 8, height: 6, red: 0.3, green: 0.3, blue: 0.9), + pixelWidth: 8, pixelHeight: 6, scale: 2.0, capturedAt: Date()) + let second = try await store.append( + pngData: try makePNGData(width: 9, height: 6, red: 0.9, green: 0.3, blue: 0.3), + pixelWidth: 9, pixelHeight: 6, scale: 2.0, capturedAt: Date()) + let session = try await store.currentSession() + let sessionDir = paths.sessionDirectory(session.id) + let manifestURL = sessionDir.appendingPathComponent("session.json") + let garbage = Data("{ not json".utf8) + try AtomicFile.write(garbage, to: manifestURL) + + let reopened = try SpoolStore(paths: paths) + let rebuilt = try await reopened.currentSession() + #expect(rebuilt.id == session.id) + + let contents = try FileManager.default.contentsOfDirectory(at: sessionDir, includingPropertiesForKeys: nil) + let corruptFiles = contents.filter { $0.lastPathComponent.hasPrefix("session.json.corrupt-") } + #expect(corruptFiles.count == 1) + let quarantined = try Data(contentsOf: try #require(corruptFiles.first)) + #expect(quarantined == garbage) + + let names = Set(rebuilt.captures.map(\.fileName)) + #expect(names.contains(first.fileName)) + #expect(names.contains(second.fileName)) + let recoveredFirst = try #require(rebuilt.captures.first { $0.fileName == first.fileName }) + #expect(recoveredFirst.pixelWidth == 8) + #expect(recoveredFirst.pixelHeight == 6) +} + +// MARK: - 9. archiveCurrent moves the directory + +@Test +func archiveCurrentMovesTheSessionUnderArchiveAndOpensAFreshEmptySession() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let png = try makePNGData(width: 10, height: 8, red: 0.5, green: 0.1, blue: 0.6) + let capture = try await store.append( + pngData: png, pixelWidth: 10, pixelHeight: 8, scale: 1.0, capturedAt: Date()) + let open = try await store.currentSession() + let archived = try await store.archiveCurrent(pdfFileName: "shotdeck-review.pdf") + + #expect(archived.state == .archived) + #expect(archived.pdfFileName == "shotdeck-review.pdf") + #expect(archived.id == open.id) + #expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(open.id).path)) + + let archivedPNG = paths.archiveDirectory(open.id).appendingPathComponent(capture.fileName) + #expect(FileManager.default.fileExists(atPath: archivedPNG.path)) + #expect(try Data(contentsOf: archivedPNG) == png) + + let current = try await store.currentSession() + #expect(current.id != open.id) + #expect(current.isEmpty) + #expect(current.state == .open) + #expect(FileManager.default.fileExists( + atPath: paths.sessionDirectory(current.id).appendingPathComponent("session.json").path)) +} + +// MARK: - 10. archiveCurrent on empty throws + +@Test +func archiveCurrentOnEmptySessionThrowsAndDoesNotMoveTheDirectory() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let session = try await store.currentSession() + let before = try snapshotFiles(under: root) + + do { + _ = try await store.archiveCurrent(pdfFileName: "never.pdf") + Issue.record("expected archiveCurrent on an empty session to throw") + } catch let error as ShotdeckError { + guard case .spoolWriteFailed = error else { + Issue.record("expected spoolWriteFailed, got \(error)") + return + } + } catch { + Issue.record("expected ShotdeckError, got \(error)") + return + } + + #expect(FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path)) + #expect(try snapshotFiles(under: root) == before) +} + +// MARK: - 11. interrupted-archive, both exist + +@Test +func interruptedArchiveBothExistSupersedesTheSpoolCopyAndKeepsArchive() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let png = try makePNGData(width: 8, height: 8, red: 0.2, green: 0.2, blue: 0.8) + let capture = try await store.append( + pngData: png, pixelWidth: 8, pixelHeight: 8, scale: 1.0, capturedAt: Date()) + let archived = try await store.archiveCurrent(pdfFileName: "sent.pdf") + let archiveDir = paths.archiveDirectory(archived.id) + let spoolCopy = paths.sessionDirectory(archived.id) + try FileManager.default.copyItem(at: archiveDir, to: spoolCopy) + let archivePNGBefore = try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) + + let reopened = try SpoolStore(paths: paths) + _ = try await reopened.currentSession() + + #expect(FileManager.default.fileExists(atPath: archiveDir.path)) + #expect(!FileManager.default.fileExists(atPath: spoolCopy.path)) + #expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == archivePNGBefore) + + let spoolEntries = try FileManager.default.contentsOfDirectory( + at: paths.spool, includingPropertiesForKeys: [.isDirectoryKey]) + let superseded = spoolEntries.filter { + $0.lastPathComponent.hasPrefix("\(archived.id.uuidString).superseded-") + } + #expect(superseded.count == 1) + let supersededPNG = try #require(superseded.first).appendingPathComponent(capture.fileName) + #expect(FileManager.default.fileExists(atPath: supersededPNG.path)) + #expect(try Data(contentsOf: supersededPNG) == png) +} + +// MARK: - 12. interrupted-archive, manifest-only + +@Test +func interruptedArchiveManifestOnlyCompletesTheMoveIntoArchive() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let png = try makePNGData(width: 6, height: 8, red: 0.7, green: 0.4, blue: 0.1) + let capture = try await store.append( + pngData: png, pixelWidth: 6, pixelHeight: 8, scale: 1.0, capturedAt: Date()) + let session = try await store.currentSession() + let marked = session.markArchived(pdfFileName: "partial.pdf") + try AtomicFile.writeJSON( + marked, to: paths.sessionDirectory(session.id).appendingPathComponent("session.json")) + + let reopened = try SpoolStore(paths: paths) + let current = try await reopened.currentSession() + #expect(current.id != session.id) + #expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path)) + let archiveDir = paths.archiveDirectory(session.id) + #expect(FileManager.default.fileExists(atPath: archiveDir.path)) + #expect(FileManager.default.fileExists(atPath: archiveDir.appendingPathComponent(capture.fileName).path)) + #expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == png) + + let archived = try await reopened.archivedSessions() + #expect(archived.contains(where: { $0.id == session.id })) +} + +// MARK: - 13. archivedSessions newest createdAt first + +@Test +func archivedSessionsReturnsNewestCreatedAtFirst() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let newest = UUID(uuidString: "00000000-0000-4000-8000-000000000003")! + let oldest = UUID(uuidString: "00000000-0000-4000-8000-000000000001")! + let middle = UUID(uuidString: "00000000-0000-4000-8000-000000000002")! + let tNewest = Date(timeIntervalSince1970: 1_700_000_200) + let tOldest = Date(timeIntervalSince1970: 1_700_000_000) + let tMiddle = Date(timeIntervalSince1970: 1_700_000_100) + + try seedSession(id: newest, createdAt: tNewest, state: .archived, directory: paths.archiveDirectory(newest)) + try seedSession(id: oldest, createdAt: tOldest, state: .archived, directory: paths.archiveDirectory(oldest)) + try seedSession(id: middle, createdAt: tMiddle, state: .archived, directory: paths.archiveDirectory(middle)) + + let store = try SpoolStore(paths: paths) + let listed = try await store.archivedSessions() + #expect(listed.map(\.id) == [newest, middle, oldest]) +} + +// MARK: - 14. newest-session tie-break by greater UUID string + +@Test +func newestSessionTieBreakPicksTheLexicographicallyGreaterUUID() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let smaller = UUID(uuidString: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")! + let greater = UUID(uuidString: "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB")! + let createdAt = Date(timeIntervalSince1970: 1_700_000_500) + try seedSession(id: smaller, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(smaller)) + try seedSession(id: greater, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(greater)) + + let first = try SpoolStore(paths: paths) + let firstID = try await first.currentSession().id + let second = try SpoolStore(paths: paths) + let secondID = try await second.currentSession().id + #expect(firstID == greater) + #expect(secondID == greater) +} + +// MARK: - 15. filename shape + +@Test +func appendFilenameMatchesSequenceDashEightHexSuffix() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let capture = try await store.append( + pngData: try makePNGData(width: 4, height: 3, red: 0.1, green: 0.1, blue: 0.1), + pixelWidth: 4, pixelHeight: 3, scale: 1.0, capturedAt: Date()) + let hex = String(capture.id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased() + #expect(capture.fileName == "001-\(hex).png") + let session = try await store.currentSession() + let url = await store.imageURL(for: capture, in: session) + #expect(url.lastPathComponent == capture.fileName) + #expect(FileManager.default.fileExists(atPath: url.path)) +} + +// MARK: - 16. startNewSession + +@Test +func startNewSessionThrowsWhenNonEmptyAndMintsANewIdWhenEmpty() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + _ = try await store.append( + pngData: try makePNGData(width: 5, height: 5, red: 0.4, green: 0.2, blue: 0.6), + pixelWidth: 5, pixelHeight: 5, scale: 1.0, capturedAt: Date()) + let before = try snapshotFiles(under: root) + let occupied = try await store.currentSession() + + do { + _ = try await store.startNewSession() + Issue.record("expected startNewSession to throw while captures are present") + } catch let error as ShotdeckError { + guard case .spoolWriteFailed = error else { + Issue.record("expected spoolWriteFailed, got \(error)") + return + } + } catch { + Issue.record("expected ShotdeckError, got \(error)") + return + } + #expect(try snapshotFiles(under: root) == before) + #expect(try await store.currentSession().id == occupied.id) + + _ = try await store.remove(captureID: occupied.captures[0].id) + let emptyID = try await store.currentSession().id + let fresh = try await store.startNewSession() + #expect(fresh.id != emptyID) + #expect(fresh.isEmpty) + #expect(FileManager.default.fileExists( + atPath: paths.sessionDirectory(emptyID).appendingPathComponent("session.json").path)) +} + +// MARK: - 17. durability-ordering smoke test + +@Test +func appendReturnsOnlyAfterThePNGBytesAreAlreadyOnDisk() async throws { + let (root, paths) = try makeIsolatedPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try SpoolStore(paths: paths) + let png = try makePNGData(width: 13, height: 11, red: 0.15, green: 0.55, blue: 0.95) + let capture = try await store.append( + pngData: png, pixelWidth: 13, pixelHeight: 11, scale: 2.0, capturedAt: Date()) + let session = try await store.currentSession() + let url = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName) + let onDisk = try Data(contentsOf: url) + #expect(onDisk == png) +} + +// MARK: - Fixtures + +private func makeIsolatedPaths() throws -> (root: URL, paths: AppSupportPaths) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-spool-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + return (root, paths) +} + +private func makePNGData( + width: Int, + height: Int, + red: CGFloat, + green: CGFloat, + blue: CGFloat +) throws -> Data { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw FixtureError.pngGenerationFailed + } + context.setFillColor(red: red, green: green, blue: blue, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + throw FixtureError.pngGenerationFailed + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else { + throw FixtureError.pngGenerationFailed + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw FixtureError.pngGenerationFailed + } + return buffer as Data +} + +private func pngDimensions(at url: URL) -> (width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?, + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue + else { return nil } + return (width, height) +} + +private func seedSession( + id: UUID, + createdAt: Date, + state: SessionState, + directory: URL +) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let session = CaptureSession(id: id, createdAt: createdAt, state: state, captures: [], pdfFileName: nil) + try AtomicFile.writeJSON(session, to: directory.appendingPathComponent("session.json")) +} + +private func snapshotFiles(under root: URL) throws -> [String: Data] { + let fm = FileManager.default + var files: [String: Data] = [:] + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return files } + let rootPath = root.standardizedFileURL.path + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { continue } + var relative = url.standardizedFileURL.path + if relative.hasPrefix(rootPath) { + relative = String(relative.dropFirst(rootPath.count)) + if relative.hasPrefix("/") { relative = String(relative.dropFirst()) } + } + files[relative] = try Data(contentsOf: url) + } + return files +} + +private enum FixtureError: Error { + case pngGenerationFailed +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..bac4075 --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# macOS keys the Screen Recording permission to the bundle identifier plus the +# code signature. The identity below and --identifier ai.flowmaster.shotdeck must +# never change: altering either one makes the existing grant invalid and forces +# the user to approve Screen Recording again by hand. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" +BUNDLE_ID="ai.flowmaster.shotdeck" +APP_BUNDLE="${ROOT}/.build/Redline.app" + +SKIP_SIGN=0 +for arg in "$@"; do + case "${arg}" in + --skip-sign) + SKIP_SIGN=1 + ;; + *) + echo "Unknown argument: ${arg}" >&2 + echo "Usage: $0 [--skip-sign]" >&2 + exit 1 + ;; + esac +done + +echo "==> Building Redline (release)" +swift build -c release --product Shotdeck + +BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck" +if [[ ! -x "${BIN_PATH}" ]]; then + echo "Release binary not found at ${BIN_PATH}" >&2 + exit 1 +fi + +echo "==> Assembling ${APP_BUNDLE}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${APP_BUNDLE}/Contents/MacOS" +mkdir -p "${APP_BUNDLE}/Contents/Resources" +cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck" +chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck" +cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist" +cp "${ROOT}/Resources/AppIcon.icns" "${APP_BUNDLE}/Contents/Resources/AppIcon.icns" + +if [[ "${SKIP_SIGN}" -eq 1 ]]; then + echo + echo "************************************************************************" + echo "WARNING: --skip-sign was used. This app is UNSIGNED." + echo "The resulting app will trigger a fresh Screen Recording prompt and must" + echo "not be handed to the user." + echo "************************************************************************" + echo +else + if ! security find-identity -v -p codesigning | grep -Fq "${IDENTITY}"; then + echo "The codesigning identity '${IDENTITY}' is not in this shell's keychain search list." >&2 + echo "The login keychain is not reachable from this shell." >&2 + echo "Run this script from a normal login session so the app can be signed." >&2 + echo "Refusing to produce an unsigned app." >&2 + exit 1 + fi + + echo "==> Signing ${APP_BUNDLE}" + codesign --force --options runtime \ + --sign "${IDENTITY}" \ + --identifier "${BUNDLE_ID}" \ + "${APP_BUNDLE}" +fi + +echo +echo "App path: ${APP_BUNDLE}" +echo "Launch with: open ${APP_BUNDLE}" diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh new file mode 100755 index 0000000..adc4196 --- /dev/null +++ b/scripts/make-dmg.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +APP_BUNDLE="${ROOT}/.build/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}" diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..439c03e --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,324 @@ +#!/usr/bin/env swift +import Foundation +import CoreGraphics +import ImageIO + +// Programmatic Redline app icon. +// Draws a 1024×1024 master, writes AppIcon.iconset (16…1024 incl. @2x), +// and compiles Resources/AppIcon.icns via iconutil. +// +// Usage: +// swift scripts/make-icon.swift +// swift scripts/make-icon.swift --preview /path/to/icon-512.png + +private let masterSize = 1024 + +private enum Palette { + static let charcoalTop = CGColor(srgbRed: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 1) + static let charcoalBottom = CGColor(srgbRed: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 1) // #1C1C1E + static let white = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + static let redline = CGColor(srgbRed: 229.0 / 255.0, green: 72.0 / 255.0, blue: 63.0 / 255.0, alpha: 1) // #E5483F +} + +private struct IconsetEntry { + let filename: String + let pixels: Int +} + +private let iconsetEntries: [IconsetEntry] = [ + IconsetEntry(filename: "icon_16x16.png", pixels: 16), + IconsetEntry(filename: "icon_16x16@2x.png", pixels: 32), + IconsetEntry(filename: "icon_32x32.png", pixels: 32), + IconsetEntry(filename: "icon_32x32@2x.png", pixels: 64), + IconsetEntry(filename: "icon_128x128.png", pixels: 128), + IconsetEntry(filename: "icon_128x128@2x.png", pixels: 256), + IconsetEntry(filename: "icon_256x256.png", pixels: 256), + IconsetEntry(filename: "icon_256x256@2x.png", pixels: 512), + IconsetEntry(filename: "icon_512x512.png", pixels: 512), + IconsetEntry(filename: "icon_512x512@2x.png", pixels: 1024), +] + +// MARK: - Geometry + +/// Apple-style continuous-corner rounded square (squircle-like). +/// Superellipse |x/a|^n + |y/b|^n = 1 with n≈5, inset 1px so antialiased +/// edge pixels are not clipped by the bitmap. +private func continuousRoundedSquare(size: CGFloat, exponent n: CGFloat = 5.0, segments: Int = 256) -> CGPath { + let inset: CGFloat = 1 + let a = (size - inset * 2) / 2 + let cx = size / 2 + let cy = size / 2 + let twoOverN = 2 / n + let path = CGMutablePath() + for i in 0...segments { + let theta = CGFloat(i) / CGFloat(segments) * 2 * .pi + let ct = cos(theta) + let st = sin(theta) + let x = cx + (ct < 0 ? -1 : 1) * pow(abs(ct), twoOverN) * a + let y = cy + (st < 0 ? -1 : 1) * pow(abs(st), twoOverN) * a + if i == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + path.closeSubpath() + return path +} + +// MARK: - Drawing + +private func drawMasterIcon(size: Int) -> CGImage { + let s = CGFloat(size) + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue + guard let ctx = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + fputs("error: failed to create \(size)×\(size) bitmap context\n", stderr) + exit(1) + } + + ctx.setShouldAntialias(true) + ctx.setAllowsAntialiasing(true) + ctx.interpolationQuality = .high + + // Flip to top-left origin so "top-to-bottom gradient" is literal. + ctx.translateBy(x: 0, y: s) + ctx.scaleBy(x: 1, y: -1) + + ctx.clear(CGRect(x: 0, y: 0, width: s, height: s)) + + let squircle = continuousRoundedSquare(size: s) + + ctx.saveGState() + ctx.addPath(squircle) + ctx.clip() + + let gradient = CGGradient( + colorsSpace: colorSpace, + colors: [Palette.charcoalTop, Palette.charcoalBottom] as CFArray, + locations: [0, 1] + )! + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: s / 2, y: 0), + end: CGPoint(x: s / 2, y: s), + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] + ) + ctx.restoreGState() + + // Viewfinder corner brackets: thick L-strokes, rounded caps/joins, inset ~18%. + let inset = s * 0.18 + let bracketWidth = s * 0.095 + let arm = s * 0.155 + let centerline = inset + bracketWidth / 2 + + ctx.saveGState() + ctx.addPath(squircle) + ctx.clip() + ctx.setStrokeColor(Palette.white) + ctx.setLineWidth(bracketWidth) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + + func strokeBracket(cornerX: CGFloat, cornerY: CGFloat, dirX: CGFloat, dirY: CGFloat) { + let path = CGMutablePath() + path.move(to: CGPoint(x: cornerX + dirX * arm, y: cornerY)) + path.addLine(to: CGPoint(x: cornerX, y: cornerY)) + path.addLine(to: CGPoint(x: cornerX, y: cornerY + dirY * arm)) + ctx.addPath(path) + ctx.strokePath() + } + + // Top-left, top-right, bottom-left, bottom-right. + strokeBracket(cornerX: centerline, cornerY: centerline, dirX: 1, dirY: 1) + strokeBracket(cornerX: s - centerline, cornerY: centerline, dirX: -1, dirY: 1) + strokeBracket(cornerX: centerline, cornerY: s - centerline, dirX: 1, dirY: -1) + strokeBracket(cornerX: s - centerline, cornerY: s - centerline, dirX: -1, dirY: -1) + + // Bold redline slash over the frame, lower-left bracket area → upper-right. + let slashWidth = s * 0.078 + ctx.setStrokeColor(Palette.redline) + ctx.setLineWidth(slashWidth) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + let slashInset = centerline + arm * 0.12 + let slash = CGMutablePath() + slash.move(to: CGPoint(x: slashInset, y: s - slashInset)) + slash.addLine(to: CGPoint(x: s - slashInset, y: slashInset)) + ctx.addPath(slash) + ctx.strokePath() + ctx.restoreGState() + + guard let image = ctx.makeImage() else { + fputs("error: failed to materialize master CGImage\n", stderr) + exit(1) + } + return image +} + +private func scaledImage(_ image: CGImage, pixels: Int) -> CGImage { + if image.width == pixels && image.height == pixels { + return image + } + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue + guard let ctx = CGContext( + data: nil, + width: pixels, + height: pixels, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + fputs("error: failed to create \(pixels)×\(pixels) scale context\n", stderr) + exit(1) + } + ctx.interpolationQuality = .high + ctx.setShouldAntialias(true) + ctx.draw(image, in: CGRect(x: 0, y: 0, width: pixels, height: pixels)) + guard let out = ctx.makeImage() else { + fputs("error: failed to scale image to \(pixels)px\n", stderr) + exit(1) + } + return out +} + +private func writePNG(_ image: CGImage, to url: URL) { + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.removeItem(at: url) + } + guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { + fputs("error: cannot create PNG destination at \(url.path)\n", stderr) + exit(1) + } + CGImageDestinationAddImage(dest, image, nil) + if !CGImageDestinationFinalize(dest) { + fputs("error: failed to write PNG \(url.path)\n", stderr) + exit(1) + } +} + +// MARK: - Paths / CLI + +private func repoRoot() -> URL { + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let arg0 = URL(fileURLWithPath: CommandLine.arguments[0]) + let scriptURL: URL + if arg0.path.hasPrefix("/") { + scriptURL = arg0.standardizedFileURL + } else { + scriptURL = cwd.appendingPathComponent(arg0.path).standardizedFileURL + } + let fromScript = scriptURL.deletingLastPathComponent().deletingLastPathComponent() + if FileManager.default.fileExists(atPath: fromScript.appendingPathComponent("Package.swift").path) { + return fromScript + } + if FileManager.default.fileExists(atPath: cwd.appendingPathComponent("Package.swift").path) { + return cwd + } + fputs("error: could not find repo root (Package.swift)\n", stderr) + exit(1) +} + +private func parsePreviewPath() -> String? { + let args = CommandLine.arguments + var i = 1 + var preview: String? + while i < args.count { + let arg = args[i] + if arg == "--preview" { + i += 1 + guard i < args.count else { + fputs("error: --preview requires a path\n", stderr) + exit(1) + } + preview = args[i] + } else if arg.hasPrefix("--preview=") { + preview = String(arg.dropFirst("--preview=".count)) + } else if arg == "--help" || arg == "-h" { + fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr) + exit(0) + } else { + fputs("error: unknown argument \(arg)\n", stderr) + fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr) + exit(1) + } + i += 1 + } + return preview +} + +private func runIconutil(iconset: URL, icns: URL) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") + proc.arguments = ["-c", "icns", iconset.path, "-o", icns.path] + proc.standardOutput = FileHandle.standardOutput + proc.standardError = FileHandle.standardError + do { + try proc.run() + proc.waitUntilExit() + } catch { + fputs("error: failed to launch iconutil: \(error)\n", stderr) + exit(1) + } + if proc.terminationStatus != 0 { + fputs("error: iconutil exited \(proc.terminationStatus)\n", stderr) + exit(1) + } +} + +// MARK: - Main + +let root = repoRoot() +let resources = root.appendingPathComponent("Resources", isDirectory: true) +let icnsURL = resources.appendingPathComponent("AppIcon.icns") +let previewPath = parsePreviewPath() +let fm = FileManager.default + +print("==> Drawing \(masterSize)×\(masterSize) master") +let master = drawMasterIcon(size: masterSize) + +let iconset = fm.temporaryDirectory.appendingPathComponent("Redline-AppIcon-\(UUID().uuidString).iconset", isDirectory: true) + +do { + try fm.createDirectory(at: iconset, withIntermediateDirectories: true) + try fm.createDirectory(at: resources, withIntermediateDirectories: true) + + print("==> Writing AppIcon.iconset") + for entry in iconsetEntries { + let img = scaledImage(master, pixels: entry.pixels) + writePNG(img, to: iconset.appendingPathComponent(entry.filename)) + } + + if fm.fileExists(atPath: icnsURL.path) { + try fm.removeItem(at: icnsURL) + } + + print("==> Compiling \(icnsURL.path)") + runIconutil(iconset: iconset, icns: icnsURL) + + try? fm.removeItem(at: iconset) +} catch { + try? fm.removeItem(at: iconset) + fputs("error: \(error)\n", stderr) + exit(1) +} + +if let previewPath { + let previewURL = URL(fileURLWithPath: previewPath) + print("==> Writing 512px preview \(previewURL.path)") + writePNG(scaledImage(master, pixels: 512), to: previewURL) +} + +print("App icon: \(icnsURL.path)") diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh new file mode 100755 index 0000000..ca6550d --- /dev/null +++ b/scripts/publish-update.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-command Redline release: bump Info.plist, commit, signed build, zip, +# DMG, appcast, upload to mmd01, verify the public URLs. +# Hidden flag: --test — upload under .../redline/test/ and skip the git commit. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +PLIST="${ROOT}/Info.plist" +PLISTBUDDY="/usr/libexec/PlistBuddy" +REMOTE_HOST="mmd01" +REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" +PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline" +SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" + +usage() { + echo "Usage: $0 [\"notes\"]" >&2 + exit 1 +} + +TEST_MODE=0 +VERSION="" +NOTES="" +NOTES_SET=0 +for arg in "$@"; do + case "${arg}" in + --test) + TEST_MODE=1 + ;; + --help|-h) + usage + ;; + --*) + echo "Unknown argument: ${arg}" >&2 + usage + ;; + *) + if [[ -z "${VERSION}" ]]; then + VERSION="${arg}" + elif [[ "${NOTES_SET}" -eq 0 ]]; then + NOTES="${arg}" + NOTES_SET=1 + else + echo "Unexpected extra argument: ${arg}" >&2 + usage + fi + ;; + esac +done + +if [[ -z "${VERSION}" ]]; then + usage +fi + +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then + echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2 + exit 1 +fi + +if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then + echo "Version contains illegal path characters: ${VERSION}" >&2 + exit 1 +fi + +if [[ "${TEST_MODE}" -eq 1 ]]; then + REMOTE_DIR="${REMOTE_BASE}/test" + PUBLIC_DIR="${PUBLIC_BASE}/test" +else + REMOTE_DIR="${REMOTE_BASE}" + PUBLIC_DIR="${PUBLIC_BASE}" +fi + +ZIP_NAME="Redline-${VERSION}.zip" +DMG_NAME="Redline-${VERSION}.dmg" +ZIP_PATH="${ROOT}/.build/${ZIP_NAME}" +DMG_PATH="${ROOT}/.build/Redline.dmg" +APPCAST_PATH="${ROOT}/.build/appcast.json" +ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}" +APPCAST_URL="${PUBLIC_DIR}/appcast.json" + +if [[ "${TEST_MODE}" -eq 1 ]]; then + echo "==> Publish Redline ${VERSION} (test)" +else + echo "==> Publish Redline ${VERSION}" +fi +echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/" +echo " public: ${PUBLIC_DIR}/" + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Not inside a git work tree." >&2 + exit 1 +fi + +# Tracked files must match HEAD. Untracked files are ignored so this script +# can be dry-run (--test) before it is itself committed. +if [[ -n "$(git status --porcelain -uno)" ]]; then + echo "git tree is not clean; commit or stash before publishing." >&2 + git status --porcelain -uno >&2 + exit 1 +fi + +if [[ ! -x "${PLISTBUDDY}" ]]; then + echo "PlistBuddy not found at ${PLISTBUDDY}" >&2 + exit 1 +fi +if [[ ! -f "${PLIST}" ]]; then + echo "Info.plist not found at ${PLIST}" >&2 + exit 1 +fi + +restore_plist() { + git checkout -- "${PLIST}" >/dev/null 2>&1 || true +} + +if [[ "${TEST_MODE}" -eq 1 ]]; then + trap restore_plist EXIT +fi + +CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")" +if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then + echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2 + exit 1 +fi +NEW_BUILD=$((CURRENT_BUILD + 1)) + +echo "==> Bumping Info.plist" +echo " CFBundleShortVersionString -> ${VERSION}" +echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}" +"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}" +"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}" + +if [[ "${TEST_MODE}" -eq 0 ]]; then + echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)" + git add "${PLIST}" + git commit -m "release: v${VERSION}" +else + echo "==> --test: skipping git commit of version bump" +fi + +# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign. +# Re-run signed steps with the account's real home when the identity is missing. +signing_home() { + if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then + echo "${HOME}" + return + fi + local rh + rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -n "${rh}" && -d "${rh}" ]]; then + echo "${rh}" + else + echo "${HOME}" + fi +} + +run_signed() { + local sign_home + sign_home="$(signing_home)" + if [[ "${sign_home}" != "${HOME}" ]]; then + echo "==> Using HOME=${sign_home} so codesign can see the login keychain" + fi + HOME="${sign_home}" "$@" +} + +echo "==> Building signed Redline.app" +run_signed ./scripts/build-app.sh + +if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then + echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2 + exit 1 +fi + +echo "==> Zipping Redline.app -> ${ZIP_PATH}" +mkdir -p "${ROOT}/.build" +( + cd "${ROOT}/.build" + rm -f "${ZIP_NAME}" + ditto -c -k --keepParent Redline.app "${ZIP_NAME}" +) + +if [[ ! -s "${ZIP_PATH}" ]]; then + echo "Zip was not created at ${ZIP_PATH}" >&2 + exit 1 +fi + +echo "==> Building manual installer DMG" +run_signed ./scripts/make-dmg.sh + +if [[ ! -s "${DMG_PATH}" ]]; then + echo "DMG was not created at ${DMG_PATH}" >&2 + exit 1 +fi + +SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" +ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" +PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +echo "==> Zip SHA256: ${SHA256}" +echo " Zip bytes: ${ZIP_BYTES}" + +echo "==> Writing ${APPCAST_PATH}" +python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY' +import json +import sys + +version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:] +payload = { + "version": version, + "zipURL": zip_url, + "sha256": sha256, + "notes": notes, + "pubDate": pub_date, +} +with open(out_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") +PY + +echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/" +ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'" +rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}" +rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg" +rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}" +rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json" +ssh -o BatchMode=yes "${REMOTE_HOST}" \ + "chmod 644 \ + '${REMOTE_DIR}/${ZIP_NAME}' \ + '${REMOTE_DIR}/Redline.dmg' \ + '${REMOTE_DIR}/${DMG_NAME}' \ + '${REMOTE_DIR}/appcast.json'" + +echo "==> Verifying public appcast ${APPCAST_URL}" +APPCAST_BODY="" +ok=0 +attempt=1 +while [[ "${attempt}" -le 15 ]]; do + if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then + echo "${APPCAST_BODY}" + if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then + echo "OK: appcast contains ${VERSION}" + ok=1 + break + fi + echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2 + else + echo "appcast fetch failed (attempt ${attempt})" >&2 + fi + attempt=$((attempt + 1)) + sleep 2 +done +if [[ "${ok}" -ne 1 ]]; then + echo "Public appcast verification failed for ${APPCAST_URL}" >&2 + exit 1 +fi + +echo "==> Verifying public zip HEAD ${ZIP_URL}" +ok=0 +attempt=1 +HEAD_OUT="" +while [[ "${attempt}" -le 15 ]]; do + HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)" + echo "${HEAD_OUT}" + HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')" + CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)" + if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then + echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}" + ok=1 + break + fi + echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2 + attempt=$((attempt + 1)) + sleep 2 +done +if [[ "${ok}" -ne 1 ]]; then + echo "Public zip verification failed for ${ZIP_URL}" >&2 + exit 1 +fi + +echo +echo "Published v${VERSION}" +echo " appcast: ${APPCAST_URL}" +echo " zip: ${ZIP_URL}" +echo " sha256: ${SHA256}" +echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" +echo " dmg: ${PUBLIC_DIR}/Redline.dmg"