Compare commits

..
5 changed files with 584 additions and 0 deletions
+295
View File
@@ -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<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)
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)"
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import AppKit
import SwiftUI
import ShotdeckCore
func newestReturnsForDisplay(_ returns: [ReturnedDocument], limit: Int = 8) -> [ReturnedDocument] {
Array(returns.sorted { $0.detectedAt > $1.detectedAt }.prefix(limit))
}
func returnMarkLabel(_ document: ReturnedDocument) -> String {
guard document.isCommented else { return "not marked" }
let count = document.annotatedPages.count
return "\(count) page\(count == 1 ? "" : "s") marked"
}
struct ReturnsList: View {
let returns: [ReturnedDocument]
var body: some View {
let visible = newestReturnsForDisplay(returns)
if visible.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 4) {
Text("Came back from your device")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(visible) { document in
Button {
NSWorkspace.shared.activateFileViewerSelecting([document.fileURL])
} label: {
HStack(spacing: 8) {
Text(document.fileURL.lastPathComponent)
.lineLimit(1)
Spacer(minLength: 8)
Text(returnMarkLabel(document))
.font(.caption)
.foregroundStyle(document.isCommented ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help(DubaiTime.stamp(document.detectedAt))
}
}
}
}
}
extension AppModel: ReturnsSectionProviding {
public func returnsSection() -> AnyView {
guard !allReturns.isEmpty else { return AnyView(EmptyView()) }
return AnyView(ReturnsList(returns: allReturns))
}
}
+138
View File
@@ -0,0 +1,138 @@
import AppKit
import SwiftUI
import ShotdeckCore
struct SettingsView: View {
@Environment(AppModel.self) private var model
private let labelWidth: CGFloat = 104
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
Text("Hotkey")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
}
GridRow(alignment: .firstTextBaseline) {
fieldLabel("Capture")
Text("⌥⇧2 — fixed in this version")
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minHeight: 22, alignment: .leading)
}
GridRow {
Text("Folders")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
.padding(.top, 6)
}
GridRow(alignment: .center) {
fieldLabel("Watch folder")
folderValue(path: model.watchFolderURL.path) {
model.chooseWatchFolder()
}
}
GridRow(alignment: .center) {
fieldLabel("Output folder")
folderValue(path: model.outboxURL.path) {
model.chooseOutboxFolder()
}
}
GridRow {
Button("Reveal spool folder") { model.openSpoolFolder() }
.gridCellColumns(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
.padding(16)
.frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading)
.controlSize(.small)
}
private func fieldLabel(_ title: String) -> some View {
Text(title)
.lineLimit(1)
.frame(width: labelWidth, alignment: .trailing)
.gridColumnAlignment(.trailing)
.frame(minHeight: 22, alignment: .trailing)
}
private func folderValue(path: String, choose: @escaping () -> Void) -> some View {
HStack(spacing: 8) {
Text(path)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { choose() }
}
.frame(minHeight: 22)
}
}
extension AppModel: SettingsWindowPresenting {
private static var settingsWindowController: NSWindowController?
public func presentSettingsWindow() {
if let existing = Self.settingsWindowController {
existing.window?.makeKeyAndOrderFront(nil)
NSApp.activate()
return
}
let hosting = NSHostingController(rootView: SettingsView().environment(self))
let window = NSWindow(contentViewController: hosting)
window.title = "Shotdeck Settings"
window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false
window.center()
let controller = NSWindowController(window: window)
Self.settingsWindowController = controller
controller.showWindow(nil)
NSApp.activate()
}
func chooseOutboxFolder() {
guard let url = chooseDirectory(startingAt: outboxURL) else { return }
FolderSettings.setOutbox(url)
setFolderURLs(outbox: url, watch: watchFolderURL)
setStatus("Output folder set to \(url.lastPathComponent).")
}
func chooseWatchFolder() {
guard let url = chooseDirectory(startingAt: watchFolderURL) else { return }
FolderSettings.setWatchFolder(url)
setFolderURLs(outbox: outboxURL, watch: url)
Task {
do {
try await watcher.updateWatchFolder(url)
setStatus("Watch folder set to \(url.lastPathComponent).")
} catch {
setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
)
}
}
}
private func chooseDirectory(startingAt directory: URL) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.prompt = "Choose"
panel.directoryURL = directory
guard panel.runModal() == .OK else { return nil }
return panel.url
}
}
+3
View File
@@ -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 {
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
APP_BUNDLE="${ROOT}/.build/Shotdeck.app"
STAGING="${ROOT}/.build/dmg-staging"
DMG="${ROOT}/.build/Shotdeck.dmg"
MOUNT_POINT="${ROOT}/.build/dmg-mnt"
SKIP_SIGN=0
for arg in "$@"; do
case "${arg}" in
--skip-sign)
SKIP_SIGN=1
;;
*)
echo "Unknown argument: ${arg}" >&2
echo "Usage: $0 [--skip-sign]" >&2
exit 1
;;
esac
done
echo "==> Building Shotdeck.app"
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
./scripts/build-app.sh --skip-sign
else
./scripts/build-app.sh
fi
if [[ ! -d "${APP_BUNDLE}" ]]; then
echo "App bundle not found at ${APP_BUNDLE}" >&2
exit 1
fi
echo "==> Staging DMG contents"
rm -rf "${STAGING}"
mkdir -p "${STAGING}"
ditto "${APP_BUNDLE}" "${STAGING}/Shotdeck.app"
ln -s /Applications "${STAGING}/Applications"
echo "==> Creating ${DMG}"
mkdir -p "$(dirname "${DMG}")"
hdiutil create -volname "Shotdeck" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}"
MOUNTED=0
detach_dmg() {
if [[ "${MOUNTED}" -eq 1 ]]; then
hdiutil detach "${MOUNT_POINT}" || hdiutil detach "${MOUNT_POINT}" -force || true
MOUNTED=0
fi
}
trap detach_dmg EXIT
if [[ -d "${MOUNT_POINT}" ]] && /sbin/mount | grep -F -q "${MOUNT_POINT}"; then
hdiutil detach "${MOUNT_POINT}" || hdiutil detach "${MOUNT_POINT}" -force
fi
rm -rf "${MOUNT_POINT}"
mkdir -p "${MOUNT_POINT}"
echo "==> Verifying ${DMG}"
hdiutil attach "${DMG}" -nobrowse -readonly -mountpoint "${MOUNT_POINT}"
MOUNTED=1
echo "==> Mount contents"
ls -la "${MOUNT_POINT}"
if [[ ! -d "${MOUNT_POINT}/Shotdeck.app" ]]; then
echo "Verification failed: Shotdeck.app missing from mounted DMG" >&2
exit 1
fi
if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then
echo "Verification failed: Applications symlink missing from mounted DMG" >&2
exit 1
fi
if [[ "$(readlink "${MOUNT_POINT}/Applications")" != "/Applications" ]]; then
echo "Verification failed: Applications does not point at /Applications" >&2
exit 1
fi
echo "==> codesign --verify --deep"
codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Shotdeck.app"
echo "==> Detaching ${MOUNT_POINT}"
hdiutil detach "${MOUNT_POINT}"
MOUNTED=0
trap - EXIT
SHA256="$(shasum -a 256 "${DMG}" | awk '{print $1}')"
echo
echo "DMG path: ${DMG}"
echo "SHA256: ${SHA256}"