Files
Claude Fable 5 905a019823 fix(app-model): separate status channels — update messages no longer hide capture summary
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
2026-09-05 11:05:14 +04:00

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)"
}
}
}