Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1de0ad519 | ||
|
|
bb6c1cd0b4 | ||
|
|
9278f703e0 | ||
|
|
3b269102a0 | ||
|
|
c54471ee2e | ||
|
|
79fa9ee1da | ||
|
|
672f8d495f | ||
|
|
9201e74bfc | ||
|
|
66657ac532 | ||
|
|
9989333c24 |
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import AppKit
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
extension AppModel: SendCapable {
|
||||||
|
public func send(anchor: NSView?) async {
|
||||||
|
guard !session.isEmpty, !isSending else { return }
|
||||||
|
setSending(true)
|
||||||
|
defer { setSending(false) }
|
||||||
|
|
||||||
|
let workingSession = session
|
||||||
|
let composer = self.composer
|
||||||
|
// Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect.
|
||||||
|
let outboxDir = outboxURL
|
||||||
|
let sourceDir = paths.sessionDirectory(workingSession.id)
|
||||||
|
let fileName = PDFComposer.fileName(for: workingSession)
|
||||||
|
let finalURL = outboxDir.appendingPathComponent(fileName)
|
||||||
|
// Same directory as the final target so the rename below is same-volume (atomic).
|
||||||
|
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
|
||||||
|
let title = "Shotdeck – \(DubaiTime.stamp(workingSession.createdAt))"
|
||||||
|
|
||||||
|
do {
|
||||||
|
// D-13: build off the main actor. Only Sendable values cross into the
|
||||||
|
// detached task — never `anchor` (NSView is not Sendable).
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
_ = try composer.compose(
|
||||||
|
session: workingSession,
|
||||||
|
imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) },
|
||||||
|
title: title,
|
||||||
|
to: tempURL
|
||||||
|
)
|
||||||
|
// POSIX rename onto `finalURL` replaces any same-name file in one
|
||||||
|
// directory operation; there is never a window where the PDF is gone.
|
||||||
|
if Darwin.rename(tempURL.path, finalURL.path) != 0 {
|
||||||
|
throw ShotdeckError.pdfCompositionFailed(
|
||||||
|
reason: "could not publish the PDF: \(String(cString: strerror(errno)))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try AtomicFile.fsyncDirectory(at: outboxDir)
|
||||||
|
}.value
|
||||||
|
|
||||||
|
// File exists on disk now — archive only after that (D-13). A later AirDrop
|
||||||
|
// failure never deletes this file.
|
||||||
|
guard FileManager.default.fileExists(atPath: finalURL.path) else {
|
||||||
|
throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk")
|
||||||
|
}
|
||||||
|
_ = try await spool.archiveCurrent(pdfFileName: fileName)
|
||||||
|
replaceSession(try await spool.currentSession())
|
||||||
|
|
||||||
|
let pageWord = workingSession.captures.count == 1 ? "page" : "pages"
|
||||||
|
setStatus("Sent — \(workingSession.captures.count) \(pageWord).")
|
||||||
|
|
||||||
|
guard let anchor else {
|
||||||
|
setStatus("PDF saved to \(outboxDisplayName). Open the panel to AirDrop it.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try Sharing.airDrop(fileURL: finalURL, from: anchor)
|
||||||
|
} catch {
|
||||||
|
setStatus(
|
||||||
|
"AirDrop is not available right now — the PDF is on your \(outboxDisplayName)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Never unlink the published PDF, and never unlink `tempURL` either:
|
||||||
|
// a rename failure would leave the complete document at the temp name.
|
||||||
|
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import AppKit
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum Sharing {
|
||||||
|
/// Presents the AirDrop picker for `fileURL`, anchored to `view`.
|
||||||
|
/// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created,
|
||||||
|
/// `canPerform` is false, or `view` is not in a visible window (a detached view
|
||||||
|
/// never produces an on-screen sheet).
|
||||||
|
static func airDrop(fileURL: URL, from view: NSView) throws {
|
||||||
|
guard let service = NSSharingService(named: .sendViaAirDrop),
|
||||||
|
service.canPerform(withItems: [fileURL]) else {
|
||||||
|
throw ShotdeckError.airDropUnavailable
|
||||||
|
}
|
||||||
|
// Presenting from a detached NSView (no window) yields a sheet that never appears.
|
||||||
|
guard let window = view.window, window.isVisible else {
|
||||||
|
throw ShotdeckError.airDropUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
NSApp.activate()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
|
service.subject = fileURL.lastPathComponent
|
||||||
|
let session = AirDropSession(service: service, window: window, view: view)
|
||||||
|
AirDropSession.keepAlive(session)
|
||||||
|
service.delegate = session
|
||||||
|
service.perform(withItems: [fileURL])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retains the sharing service for the life of the picker and supplies the real
|
||||||
|
/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak.
|
||||||
|
@MainActor
|
||||||
|
private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||||
|
static var live: [AirDropSession] = []
|
||||||
|
|
||||||
|
let service: NSSharingService
|
||||||
|
let window: NSWindow
|
||||||
|
let view: NSView
|
||||||
|
|
||||||
|
init(service: NSSharingService, window: NSWindow, view: NSView) {
|
||||||
|
self.service = service
|
||||||
|
self.window = window
|
||||||
|
self.view = view
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keepAlive(_ session: AirDropSession) {
|
||||||
|
live.append(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drop() {
|
||||||
|
Self.live.removeAll { $0 === self }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(
|
||||||
|
_ sharingService: NSSharingService,
|
||||||
|
sourceWindowForShareItems items: [Any],
|
||||||
|
sharingContentScope: UnsafeMutablePointer<NSSharingService.SharingContentScope>
|
||||||
|
) -> NSWindow? {
|
||||||
|
sharingContentScope.pointee = .item
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(
|
||||||
|
_ sharingService: NSSharingService,
|
||||||
|
sourceFrameOnScreenForShareItem item: Any
|
||||||
|
) -> NSRect {
|
||||||
|
let inWindow = view.convert(view.bounds, to: nil)
|
||||||
|
return window.convertToScreen(inWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(
|
||||||
|
_ sharingService: NSSharingService,
|
||||||
|
didFailToShareItems items: [Any],
|
||||||
|
error: any Error
|
||||||
|
) {
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import ShotdeckCore
|
|||||||
|
|
||||||
// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`.
|
// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`.
|
||||||
// App.main() is the equivalent entry point.
|
// App.main() is the equivalent entry point.
|
||||||
|
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||||
|
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||||
|
}
|
||||||
ShotdeckApp.main()
|
ShotdeckApp.main()
|
||||||
|
|
||||||
struct ShotdeckApp: App {
|
struct ShotdeckApp: App {
|
||||||
|
|||||||
Executable
+95
@@ -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}"
|
||||||
Reference in New Issue
Block a user