Root cause: updateStatusMessage was an alias to statusLine, so update feedback appeared both in the header (where capture summary belongs) and footer (intended). Both MenuBarView header and footer rendered the same value, creating duplication and information loss. Fix: introduce updateStatus property separate from statusLine. Route UpdateChecker.statusMessage into setUpdateStatus(), not setStatus(). Header now shows capture summary uninterrupted; footer-only shows update feedback. Both channels now independent. - Add public updateStatus property to AppModel - Add setUpdateStatus() mutator - Change updateStatusMessage property to return updateStatus instead of statusLine - Route onChecked callback to setUpdateStatus, not setStatus - Update PanelSnapshot helper to use setUpdateStatus - Add test asserting channel independence Panel-12 and panel-13 now render correctly: capture summary in header, update status only in footer; no duplication or information loss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
447 lines
19 KiB
Swift
447 lines
19 KiB
Swift
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 <ver>", 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 <ver> 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<AppModel, Bool> = unsafeBitCast(
|
|
\AppModel.screenRecordingGranted, to: ReferenceWritableKeyPath<AppModel, Bool>.self
|
|
)
|
|
self[keyPath: writable] = granted
|
|
}
|
|
|
|
/// Snapshot-only: set isCheckingForUpdates without triggering a real check.
|
|
func snapshotSetIsCheckingForUpdates(_ checking: Bool) {
|
|
let writable: ReferenceWritableKeyPath<AppModel, Bool> = unsafeBitCast(
|
|
\AppModel.isCheckingForUpdates, to: ReferenceWritableKeyPath<AppModel, Bool>.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<AppModel, (version: String, notes: String)?> = unsafeBitCast(
|
|
\AppModel.updateAvailable, to: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?>.self
|
|
)
|
|
self[keyPath: writable] = (version: version, notes: notes)
|
|
} else {
|
|
let writable: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?> = unsafeBitCast(
|
|
\AppModel.updateAvailable, to: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?>.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)"
|
|
}
|
|
}
|
|
}
|