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. try await addSampleCaptures(to: model) 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") ) // 07/08/09 — OneDrive-mode Settings + menu bar, on a SEPARATE isolated model so // this transport switch never bleeds into the AirDrop-mode panels above. try await captureOneDrivePanels(to: directory) } /// Three real PNGs appended to the given model's temp spool so SessionStrip /// thumbnails decode. Shared by panel 04 (AirDrop) and panel 09 (OneDrive). @MainActor private static func addSampleCaptures(to model: AppModel) async throws { 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()) } /// Panels 07-09: OneDrive transport, on its own isolated model/temp root so /// switching transport here never touches the AirDrop-mode model above, the real /// home directory, or UserDefaults.standard. The "resolved" and "not found" states /// are produced by calling the real OneDriveLocator functions against fake home /// trees built under this snapshot's own temp root — never a hand-typed path. @MainActor private static func captureOneDrivePanels(to directory: URL) async throws { let (model, root) = try makeIsolatedModel() defer { try? FileManager.default.removeItem(at: root) } model.setTransport(.oneDrive) // 07 — a resolved OneDrive folder, shaped like the real default // (…/Library/CloudStorage/OneDrive-MMDGROUP/Redline): a fake home tree with a // real OneDrive-MMDGROUP directory under it, resolved via the same pure // OneDriveLocator function production code uses — never a hand-typed path. let fakeHomeWithOneDrive = root.appendingPathComponent("fake-home-with-onedrive", isDirectory: true) let syncRoot = fakeHomeWithOneDrive .appendingPathComponent("Library/CloudStorage/OneDrive-MMDGROUP", isDirectory: true) try FileManager.default.createDirectory(at: syncRoot, withIntermediateDirectories: true) guard let resolvedFolder = OneDriveLocator.defaultRedlineFolder( home: fakeHomeWithOneDrive, fileManager: .default ) else { throw SnapshotError.oneDriveFixtureFailed("fake OneDrive-MMDGROUP root did not resolve") } model.setResolvedOneDriveFolder(resolvedFolder) try render( SettingsView().environment(model), to: directory.appendingPathComponent("panel-07-settings-onedrive.png") ) // 08 — no OneDrive folder found: a fake home with NO Library/CloudStorage at // all, and a throwaway UserDefaults suite (never .standard, never touched // before) so the stored-override check also legitimately finds nothing. let fakeHomeWithoutOneDrive = root.appendingPathComponent("fake-home-without-onedrive", isDirectory: true) try FileManager.default.createDirectory(at: fakeHomeWithoutOneDrive, withIntermediateDirectories: true) let isolatedDefaults = try makeIsolatedDefaultsSuite() defer { isolatedDefaults.defaults.removePersistentDomain(forName: isolatedDefaults.suiteName) } let missingFolder = OneDriveLocator.resolveOneDriveFolder( defaults: isolatedDefaults.defaults, home: fakeHomeWithoutOneDrive, fileManager: .default ) guard missingFolder == nil else { throw SnapshotError.oneDriveFixtureFailed("fake home without OneDrive unexpectedly resolved") } model.setResolvedOneDriveFolder(nil) try render( SettingsView().environment(model), to: directory.appendingPathComponent("panel-08-settings-onedrive-missing.png") ) // 09 — menu bar panel, 3 captures present, OneDrive mode ("Send to OneDrive"). model.snapshotSetScreenRecordingGranted(true) model.replaceRegion(sampleRegion()) try await addSampleCaptures(to: model) try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive") // 10 — update idle: 3 captures, no update available, footer "Redline ", row "Check for updates". let (updateModel, updateRoot) = try makeIsolatedModel() defer { try? FileManager.default.removeItem(at: updateRoot) } updateModel.snapshotSetScreenRecordingGranted(true) updateModel.replaceRegion(sampleRegion()) try await addSampleCaptures(to: updateModel) // No update set, updateChecker in idle state, no previous version updateModel.snapshotSetPreviousVersion(nil) try renderMenuBar(model: updateModel, to: directory, name: "10-update-idle") // 11 — update checking: same as 10 but isCheckingForUpdates = true. updateModel.snapshotSetIsCheckingForUpdates(true) try renderMenuBar(model: updateModel, to: directory, name: "11-update-checking") updateModel.snapshotSetIsCheckingForUpdates(false) // 12 — update up-to-date: footer status line reads "Redline is up to date, checked 10:42 Dubai". let upToDateMessage = "Redline \(updateModel.appVersion) is up to date, checked 10:42 Dubai" updateModel.snapshotSetUpdateStatusMessage(upToDateMessage) try renderMenuBar(model: updateModel, to: directory, name: "12-update-uptodate") // 13 — update staged: row "Update to 9.9.9" present, footer status "Update to 9.9.9 is ready". updateModel.snapshotSetUpdateAvailable(version: "9.9.9", notes: "Test release") updateModel.snapshotSetUpdateStatusMessage("Update to 9.9.9 is ready") try renderMenuBar(model: updateModel, to: directory, name: "13-update-staged") // 14 — update revert: row "Revert to 0.2.0" present. updateModel.snapshotSetUpdateAvailable(version: nil, notes: nil) // Clear the staged update updateModel.snapshotSetUpdateStatusMessage(nil) updateModel.snapshotSetPreviousVersion("0.2.0") try renderMenuBar(model: updateModel, to: directory, name: "14-update-revert") } @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) } /// A throwaway UserDefaults suite — never `.standard` — for the panel-08 fixture, /// the same isolation pattern ShotdeckCoreTests uses for TransportSettings/ /// OneDriveLocator tests. private struct IsolatedDefaultsSuite { let suiteName: String let defaults: UserDefaults } private static func makeIsolatedDefaultsSuite() throws -> IsolatedDefaultsSuite { let suiteName = "shotdeck-panel-snapshot-\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { throw SnapshotError.oneDriveFixtureFailed("could not create isolated UserDefaults suite") } defaults.removePersistentDomain(forName: suiteName) return IsolatedDefaultsSuite(suiteName: suiteName, defaults: defaults) } @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 } /// Snapshot-only: set isCheckingForUpdates without triggering a real check. func snapshotSetIsCheckingForUpdates(_ checking: Bool) { let writable: ReferenceWritableKeyPath = unsafeBitCast( \AppModel.isCheckingForUpdates, to: ReferenceWritableKeyPath.self ) self[keyPath: writable] = checking } /// Snapshot-only: set updateStatusMessage for panel display. func snapshotSetUpdateStatusMessage(_ message: String?) { setUpdateStatus(message) } /// Snapshot-only: set updateAvailable without triggering a real download. func snapshotSetUpdateAvailable(version: String?, notes: String?) { if let version = version, let notes = notes { let writable: ReferenceWritableKeyPath = unsafeBitCast( \AppModel.updateAvailable, to: ReferenceWritableKeyPath.self ) self[keyPath: writable] = (version: version, notes: notes) } else { let writable: ReferenceWritableKeyPath = unsafeBitCast( \AppModel.updateAvailable, to: ReferenceWritableKeyPath.self ) self[keyPath: writable] = nil } } /// Snapshot-only: set a fake previousVersion for the revert panel. func snapshotSetPreviousVersion(_ version: String?) { updateChecker.snapshotPreviousVersionOverride = version updateChecker.snapshotUsesPreviousVersionOverride = true } } private enum SnapshotError: Error, CustomStringConvertible { case renderFailed(String) case encodeFailed(String) case pngGenerationFailed case pdfWriteFailed(String) case oneDriveFixtureFailed(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)" case .oneDriveFixtureFailed(let detail): return "OneDrive snapshot fixture failed: \(detail)" } } }