SettingsView's OneDrive row called OneDriveLocator.resolveOneDriveFolder() directly with the real UserDefaults.standard and the real home directory, which made that row impossible to drive from a fake/isolated environment. Moved that resolution into AppModel as a tracked resolvedOneDriveFolder property (nil means "no OneDrive folder found"), refreshed at init, bootstrap, chooseTransport, chooseOneDriveFolder, and inside send()'s live folder check. SettingsView and chooseOneDriveFolder's picker-start path now read model.resolvedOneDriveFolder instead of calling OneDriveLocator directly — state flows through the model like everything else in this app. PanelSnapshot (SHOTDECK_SNAPSHOT_DIR) adds three panels on a SEPARATE isolated model so the transport switch never bleeds into the six existing AirDrop-mode panels: - panel-07-settings-onedrive.png: transport=oneDrive with a resolved folder, built by pointing OneDriveLocator.defaultRedlineFolder at a fake home tree (Library/CloudStorage/OneDrive-MMDGROUP under this snapshot's own temp root) so the displayed path is shaped like the real default without ever touching the real home. - panel-08-settings-onedrive-missing.png: a fake home with no Library/CloudStorage at all, resolved through a throwaway UserDefaults suite (never .standard) so the "no OneDrive folder found" state and its still-usable Choose... button are exercised for real. - panel-09-captures-present-onedrive.png: 3 captures + transport=oneDrive, confirming the menu row reads "Send to OneDrive". Extracted the 3-swatch capture seeding (panel 04) into addSampleCaptures(to:) so panel 09 reuses it instead of duplicating the loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
382 lines
16 KiB
Swift
382 lines
16 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")
|
|
}
|
|
|
|
@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
|
|
}
|
|
}
|
|
|
|
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)"
|
|
}
|
|
}
|
|
}
|