diff --git a/Sources/Shotdeck/PanelSnapshot.swift b/Sources/Shotdeck/PanelSnapshot.swift new file mode 100644 index 0000000..e6580e3 --- /dev/null +++ b/Sources/Shotdeck/PanelSnapshot.swift @@ -0,0 +1,295 @@ +import AppKit +import CoreGraphics +import Darwin +import Foundation +import ImageIO +import PDFKit +import SwiftUI +import ShotdeckCore + +/// Headless offscreen renderer for `MenuBarView` / `SettingsView`. +/// Driven by `SHOTDECK_SNAPSHOT_DIR`; never touches the real Application Support spool. +enum PanelSnapshot { + private static let panelWidth: CGFloat = 340 + + /// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the + /// env var is unset; otherwise writes the six panel PNGs, prints each path, and `exit`s. + @MainActor + static func runIfRequested() { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"], + !raw.isEmpty + else { return } + + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + + Task { @MainActor in + do { + try await captureAll(to: URL(fileURLWithPath: raw, isDirectory: true)) + exit(0) + } catch { + fputs("PanelSnapshot failed: \(error)\n", stderr) + exit(1) + } + } + + app.run() + exit(0) + } + + @MainActor + private static func captureAll(to directory: URL) async throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let (model, root) = try makeIsolatedModel() + defer { try? FileManager.default.removeItem(at: root) } + + let region = sampleRegion() + + // 01 — Screen Recording missing (no public mutator; snapshot-only seam). + model.snapshotSetScreenRecordingGranted(false) + model.replaceRegion(nil) + try renderMenuBar(model: model, to: directory, name: "01-no-permission") + + // 02 — granted, no region picked. + model.snapshotSetScreenRecordingGranted(true) + model.replaceRegion(nil) + try renderMenuBar(model: model, to: directory, name: "02-no-region") + + // 03 — region set, empty session. + model.replaceRegion(region) + try renderMenuBar(model: model, to: directory, name: "03-empty-session") + + // 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode. + let swatches: [(CGFloat, CGFloat, CGFloat)] = [ + (0.85, 0.22, 0.18), + (0.18, 0.62, 0.32), + (0.16, 0.38, 0.82), + ] + for (red, green, blue) in swatches { + let png = try makePNGData(width: 192, height: 108, red: red, green: green, blue: blue) + _ = try await model.spool.append( + pngData: png, + pixelWidth: 192, + pixelHeight: 108, + scale: 2.0, + capturedAt: Date() + ) + } + model.replaceSession(try await model.spool.currentSession()) + try renderMenuBar(model: model, to: directory, name: "04-captures-present") + + // 05 — two inspected PDFs in the temp ledger, one marked / one not. + try await seedReturns(model: model) + try renderMenuBar(model: model, to: directory, name: "05-returns-present") + + // 06 — SettingsView against the same isolated model. + try render( + SettingsView().environment(model), + to: directory.appendingPathComponent("panel-06-settings.png") + ) + } + + @MainActor + private static func renderMenuBar(model: AppModel, to directory: URL, name: String) throws { + try render( + MenuBarView().environment(model), + to: directory.appendingPathComponent("panel-\(name).png") + ) + } + + @MainActor + private static func render(_ view: some View, to url: URL) throws { + let wrapped = view + .frame(width: panelWidth, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .background(Color(nsColor: .windowBackgroundColor)) + + let hosting = NSHostingView(rootView: wrapped) + hosting.wantsLayer = true + hosting.appearance = NSAppearance(named: .aqua) + + let window = NSWindow( + contentRect: NSRect(x: -10_000, y: -10_000, width: panelWidth, height: 64), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.backgroundColor = .windowBackgroundColor + window.isOpaque = true + window.alphaValue = 0 + window.contentView = hosting + window.orderBack(nil) + + hosting.layoutSubtreeIfNeeded() + var size = hosting.fittingSize + if size.height < 1 { + size.height = hosting.intrinsicContentSize.height + } + if size.height < 1 { size.height = 240 } + size.width = panelWidth + size.height = ceil(size.height) + + hosting.setFrameSize(size) + window.setContentSize(size) + hosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + + let bounds = hosting.bounds + guard let rep = hosting.bitmapImageRepForCachingDisplay(in: bounds) else { + throw SnapshotError.renderFailed(url.lastPathComponent) + } + hosting.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + throw SnapshotError.encodeFailed(url.lastPathComponent) + } + try png.write(to: url) + print(url.path) + fflush(stdout) + + window.contentView = nil + window.close() + } + + @MainActor + private static func makeIsolatedModel() throws -> (model: AppModel, root: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-panel-snapshot-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root, + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let ledger = try ReturnLedger(paths: paths) + let model = AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder) + return (model, root) + } + + @MainActor + private static func seedReturns(model: AppModel) async throws { + let watch = model.paths.watchFolder + let unmarkedURL = watch.appendingPathComponent("Shotdeck-20260901-120000.pdf") + let markedURL = watch.appendingPathComponent("Shotdeck-20260901-120100.pdf") + try writeShotdeckPDF(to: unmarkedURL, marked: false) + try writeShotdeckPDF(to: markedURL, marked: true) + + let unmarked = try AnnotationInspector.inspect(fileURL: unmarkedURL) + let marked = try AnnotationInspector.inspect(fileURL: markedURL) + try await model.ledger.record(unmarked) + try await model.ledger.record(marked) + model.setReturns( + all: try await model.ledger.all(), + commented: try await model.ledger.commented() + ) + } + + private static func sampleRegion() -> CaptureRegion { + CaptureRegion( + displayID: CGMainDisplayID(), + rect: CGRect(x: 120, y: 80, width: 800, height: 600), + capturedScale: 2.0 + ) + } + + private static func makePNGData( + width: Int, + height: Int, + red: CGFloat, + green: CGFloat, + blue: CGFloat + ) throws -> Data { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw SnapshotError.pngGenerationFailed + } + context.setFillColor(red: red, green: green, blue: blue, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + throw SnapshotError.pngGenerationFailed + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + buffer, + "public.png" as CFString, + 1, + nil + ) else { + throw SnapshotError.pngGenerationFailed + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw SnapshotError.pngGenerationFailed + } + return buffer as Data + } + + private static func writeShotdeckPDF(to url: URL, marked: Bool) throws { + let document = PDFDocument() + let page = PDFPage() + page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox) + document.insert(page, at: 0) + document.documentAttributes = [ + PDFDocumentAttribute.creatorAttribute: "Shotdeck", + PDFDocumentAttribute.subjectAttribute: UUID().uuidString, + ] + if marked { + let annotation = PDFAnnotation( + bounds: CGRect(x: 72, y: 400, width: 220, height: 36), + forType: .highlight, + withProperties: nil + ) + page.addAnnotation(annotation) + } + guard document.write(to: url) else { + throw SnapshotError.pdfWriteFailed(url.lastPathComponent) + } + } +} + +extension AppModel { + /// `screenRecordingGranted` is `public private(set)` with no seam mutator. + /// Snapshot-only: the KeyPath setter exists at runtime; compile-time access is file-private. + func snapshotSetScreenRecordingGranted(_ granted: Bool) { + // private(set) types this as KeyPath; the setter still exists on the @Observable storage. + let writable: ReferenceWritableKeyPath = unsafeBitCast( + \AppModel.screenRecordingGranted, to: ReferenceWritableKeyPath.self + ) + self[keyPath: writable] = granted + } +} + +private enum SnapshotError: Error, CustomStringConvertible { + case renderFailed(String) + case encodeFailed(String) + case pngGenerationFailed + case pdfWriteFailed(String) + + var description: String { + switch self { + case .renderFailed(let name): return "bitmapImageRepForCachingDisplay failed for \(name)" + case .encodeFailed(let name): return "PNG encode failed for \(name)" + case .pngGenerationFailed: return "CoreGraphics PNG generation failed" + case .pdfWriteFailed(let name): return "could not write \(name)" + } + } +} diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 60de9bc..21ce466 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -4,6 +4,9 @@ import ShotdeckCore // SwiftPM treats a file named main.swift as top-level code, which forbids `@main`. // App.main() is the equivalent entry point. +if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { + MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } +} ShotdeckApp.main() struct ShotdeckApp: App {