Compare commits

..
Author SHA1 Message Date
kua-agent 8338216f23 release: v0.2.0 2026-09-02 09:18:21 +04:00
kua-agent f0e9b41d90 Merge pull request 'fix: archive only after AirDrop completes; send-time PDF names; Reveal last PDF' (#21) from fix/send-truth-20260902 into feat/shotdeck-20260830 2026-09-02 05:18:11 +00:00
kua-agent 36071aed84 Merge remote-tracking branch 'origin/feat/shotdeck-20260830' into fix/send-truth-20260902
# Conflicts:
#	Sources/Shotdeck/PickerSelfTest.swift
2026-09-02 09:17:09 +04:00
kua-agent 5e61cd735c fix: archive only after AirDrop completes; PDF named by send time; Reveal last PDF 2026-09-02 09:12:08 +04:00
kua-agent 461f4e4d75 Merge pull request 'release tooling: publish-update.sh' (#20) from feat/publish-script-20260902 into feat/shotdeck-20260830 2026-09-02 05:10:31 +00:00
kua-agent 4e7c575455 Merge pull request 'feature: built-in auto-update (appcast + sha256 + staged install), 0.2.0' (#19) from feat/auto-update-20260902 into feat/shotdeck-20260830 2026-09-02 05:10:11 +00:00
kua-agent c01653e9aa feature: built-in auto-update (appcast + sha256 + staged install), version 0.2.0 2026-09-02 09:08:42 +04:00
kua-agent 7284568489 release tooling: publish-update.sh — bump, build, sign, zip, appcast, upload, verify 2026-09-02 09:02:45 +04:00
kua-agent 8d66e49e07 Merge pull request 'feature: user-selectable capture hotkey (recorder in Settings)' (#18) from feat/hotkey-config-20260901 into feat/shotdeck-20260830 2026-09-01 18:34:14 +00:00
kua-agent e253a966ab Merge pull request 'icon: programmatic Redline app icon' (#17) from feat/app-icon-20260901 into feat/shotdeck-20260830 2026-09-01 18:34:02 +00:00
kua-agent fa3e7b0a4a feature: user-selectable capture hotkey with recorder in Settings 2026-09-01 22:33:13 +04:00
kua-agent c52e68a9d5 icon: programmatic Redline app icon (icns + generator script) 2026-09-01 22:33:10 +04:00
kua-agent 3c55be174a Merge pull request 'rename: product name → Redline (identity preserved, legacy PDFs recognized)' (#16) from feat/rename-redline-20260901 into feat/shotdeck-20260830 2026-09-01 18:25:47 +00:00
kua-agent d05bd735b5 rename: user-facing product name Shotdeck -> Redline; legacy PDFs still recognized 2026-09-01 22:25:15 +04:00
kua-agent 74606e5046 Merge pull request 'fix: load persisted capture region at launch' (#15) from fix/region-persist-20260901 into feat/shotdeck-20260830 2026-09-01 18:20:54 +00:00
kua-agentandClaude Fable 5 517a4e4fdc fix: load persisted capture region at launch; REGION-PERSIST selftest phase
Ben-reported: picker opened on every activation. AppModel.init set region = nil and never
read UserDefaults back; saving worked, every launch forgot it. init now loads via
loadPersistedRegion() (decode + isStillValid). Selftest phase 2 writes a known region,
reloads through the same path, asserts the rect, restores the user's stored value.
Coordinator ran it: PICKER-SELFTEST PASS + REGION-PERSIST PASS, 90/90 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:20:40 +04:00
kua-agent 6c0b6e3068 Merge pull request 'fix: picker first-mouse acceptance + event-based drag coords + in-process selftest' (#14) from fix/picker-first-mouse-20260901 into feat/shotdeck-20260830 2026-09-01 17:47:09 +00:00
kua-agentandClaude Fable 5 f2088bbed8 fix: picker first-mouse acceptance + event-based drag coords; in-process picker selftest
Root cause: RegionPickerView lacked acceptsFirstMouse — as an LSUIElement accessory app
Shotdeck is never active when the hotkey fires, so the user's first click on the overlay
was refused and the drag never started. Also plumbs the monitored event's location through
the controller (hardware-cursor reads made the chain untestable). Adds PickerSelfTest
(SHOTDECK_PICKER_SELFTEST): posts synthetic mouse events through the app's own queue,
asserts the exact CaptureRegion, saves a mid-drag overlay bitmap. Coordinator ran it:
PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0); overlay bitmap shows dim+punch+chip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 21:46:54 +04:00
kua-agent 31013802d7 Merge pull request 'fix: settings form alignment — grouped form, aligned labels' (#13) from fix/settings-form-alignment-20260901 into feat/shotdeck-20260830 2026-09-01 09:13:09 +00:00
kua-agent e1de0ad519 fix: settings form alignment — grouped form, aligned labels 2026-09-01 13:11:25 +04:00
kua-agent bb6c1cd0b4 Merge pull request 'test harness: offscreen panel snapshots (SHOTDECK_SNAPSHOT_DIR)' (#12) from feat/panel-snapshot-20260901 into feat/shotdeck-20260830 2026-09-01 09:06:36 +00:00
kua-agent 9278f703e0 Merge pull request 'installer: make-dmg.sh — signed DMG with /Applications symlink' (#11) from feat/dmg-installer-20260901 into feat/shotdeck-20260830 2026-09-01 09:06:28 +00:00
kua-agent 3b269102a0 test harness: offscreen panel snapshots via SHOTDECK_SNAPSHOT_DIR 2026-09-01 13:01:45 +04:00
kua-agent c54471ee2e installer: make-dmg.sh — signed app DMG with /Applications symlink 2026-09-01 12:55:22 +04:00
kua-agent 79fa9ee1da Merge pull request 'WP-4c: returns list + settings' (#10) from wp4c/returns-settings-20260831 into feat/shotdeck-20260830 2026-08-31 12:18:15 +00:00
kua-agent 672f8d495f Merge pull request 'WP-4b: Send pipeline + AirDrop sheet' (#9) from wp4b/send-airdrop-20260831 into feat/shotdeck-20260830 2026-08-31 12:18:09 +00:00
kua-agent 66657ac532 WP-4c: returns list + settings per SPEC-A2b
Add ReturnsList (newest-first, max 8, click-to-Finder, hidden when empty)
and SettingsView (hotkey row, folder Choose… via FolderSettings + AppModel
seam mutators, async updateWatchFolder(url) only).
2026-08-31 16:07:41 +04:00
27 changed files with 2697 additions and 101 deletions
+9 -7
View File
@@ -2,22 +2,24 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleExecutable</key>
<string>Shotdeck</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>ai.flowmaster.shotdeck</string> <string>ai.flowmaster.shotdeck</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Shotdeck</string> <string>Redline</string>
<key>CFBundleExecutable</key>
<string>Shotdeck</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.0</string> <string>0.2.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>2</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>14.0</string> <string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string> <string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string>
</dict> </dict>
+2 -2
View File
@@ -1,4 +1,4 @@
# Shotdeck # Redline
A macOS menu-bar app that captures a remembered screen region, builds a one-screenshot-per-page PDF, AirDrops it to an iPad for markup, then watches for the annotated file to come back. A macOS menu-bar app that captures a remembered screen region, builds a one-screenshot-per-page PDF, AirDrops it to an iPad for markup, then watches for the annotated file to come back.
@@ -18,5 +18,5 @@ The grant is bound to the bundle identifier `ai.flowmaster.shotdeck` plus the co
```bash ```bash
swift build && swift test swift build && swift test
./scripts/build-app.sh # signed .app for daily use ./scripts/build-app.sh # signed .app for daily use
open .build/Shotdeck.app open .build/Redline.app
``` ```
Binary file not shown.
+105 -8
View File
@@ -1,5 +1,4 @@
import AppKit import AppKit
import Carbon.HIToolbox
import Foundation import Foundation
import Observation import Observation
import SwiftUI import SwiftUI
@@ -37,6 +36,13 @@ public final class AppModel {
public private(set) var outboxURL: URL public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
public private(set) var watchFolderURL: URL public private(set) var watchFolderURL: URL
/// Absolute URL of the PDF composed this run, if any. Used by "Reveal last PDF".
public private(set) var lastComposedPDFURL: URL?
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
private(set) var captureHotkey: HotkeyPreference
var hotkeyDisplayString: String { captureHotkey.displayString }
/// Staged update offered in the menu. Set only after checksum + payload validation.
public private(set) var updateAvailable: (version: String, notes: String)?
let paths: AppSupportPaths let paths: AppSupportPaths
let spool: SpoolStore let spool: SpoolStore
@@ -46,6 +52,7 @@ public final class AppModel {
let picker: RegionPickerController let picker: RegionPickerController
let ledger: ReturnLedger let ledger: ReturnLedger
let watcher: ReturnWatcher let watcher: ReturnWatcher
let updateChecker: UpdateChecker
public init( public init(
paths: AppSupportPaths, paths: AppSupportPaths,
@@ -72,7 +79,7 @@ public final class AppModel {
captures: [], captures: [],
pdfFileName: nil pdfFileName: nil
) )
self.region = nil self.region = Self.loadPersistedRegion()
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths never .standard(). // Seeded from FolderSettings.resolve() via resolvedAppSupportPaths never .standard().
let folders = FolderSettings.resolve() let folders = FolderSettings.resolve()
@@ -80,6 +87,15 @@ public final class AppModel {
self.watchFolderURL = folders.watch self.watchFolderURL = folders.watch
self.outboxDisplayName = folders.outbox.lastPathComponent self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent
self.captureHotkey = HotkeyPreference.load()
self.updateChecker = UpdateChecker()
self.updateChecker.onChecked = { [weak self] in
guard let self else { return }
self.updateAvailable = self.updateChecker.availableUpdate
if let message = self.updateChecker.statusMessage {
self.setStatus(message)
}
}
} }
// MARK: Seam mutators the only way a WP-4b/4c extension changes state. // MARK: Seam mutators the only way a WP-4b/4c extension changes state.
@@ -102,6 +118,42 @@ public final class AppModel {
watchFolderURL = watch watchFolderURL = watch
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
} }
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
/// True when a last-composed PDF path is known this run, or the newest
/// `Redline-*.pdf` in the outbox exists on disk.
var canRevealLastPDF: Bool { revealablePDFURL() != nil }
public func revealLastPDF() {
guard let url = revealablePDFURL() else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
func revealablePDFURL() -> URL? {
if let last = lastComposedPDFURL, FileManager.default.fileExists(atPath: last.path) {
return last
}
return newestOutboxRedlinePDF()
}
func newestOutboxRedlinePDF() -> URL? {
let fm = FileManager.default
let items = (try? fm.contentsOfDirectory(
at: outboxURL,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
)) ?? []
let matches = items.filter {
$0.lastPathComponent.hasPrefix("Redline-") && $0.pathExtension.lowercased() == "pdf"
}
return matches.max { a, b in
let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate) ?? .distantPast
let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate) ?? .distantPast
return da < db
}
}
public var iconState: MenuIconState { public var iconState: MenuIconState {
if !screenRecordingGranted { return .recordingMissing } if !screenRecordingGranted { return .recordingMissing }
@@ -146,16 +198,53 @@ public final class AppModel {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.") setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.")
} }
let registered = hotkeys.register( let pref = HotkeyPreference.load()
captureHotkey = pref
if !bindCaptureHotkey(pref) {
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
}
let skipSchedule =
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
if !skipSchedule {
updateChecker.startSchedule()
}
}
/// Installs the staged update over `/Applications/Redline.app` and relaunches.
/// Does nothing unless the user clicked the menu row.
public func installUpdate() {
updateChecker.installStaged()
}
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
func reRegisterHotkey() {
let previous = captureHotkey
let next = HotkeyPreference.load()
hotkeys.unregister(id: "capture")
if bindCaptureHotkey(next) {
captureHotkey = next
return
}
setStatus("That combination is taken — pick another.")
previous.save()
if bindCaptureHotkey(previous) {
captureHotkey = previous
}
}
@discardableResult
private func bindCaptureHotkey(_ pref: HotkeyPreference) -> Bool {
hotkeys.register(
id: "capture", id: "capture",
keyCode: UInt32(kVK_ANSI_2), keyCode: pref.keyCode,
modifiers: UInt32(optionKey | shiftKey) modifiers: pref.modifiers
) { [weak self] in ) { [weak self] in
Task { await self?.captureNow() } Task { await self?.captureNow() }
} }
if !registered {
setStatus("⌥⇧2 is already used by another app — capture only works from the menu.")
}
} }
public func captureNow() async { public func captureNow() async {
@@ -237,6 +326,14 @@ public final class AppModel {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
static func loadPersistedRegion() -> CaptureRegion? {
guard let data = UserDefaults.standard.data(forKey: CaptureRegion.defaultsKey),
let decoded = try? JSONDecoder().decode(CaptureRegion.self, from: data),
decoded.isStillValid
else { return nil }
return decoded
}
private func persistRegion(_ picked: CaptureRegion) { private func persistRegion(_ picked: CaptureRegion) {
replaceRegion(picked) replaceRegion(picked)
if let encoded = try? JSONEncoder().encode(picked) { if let encoded = try? JSONEncoder().encode(picked) {
+151
View File
@@ -0,0 +1,151 @@
import AppKit
import Carbon.HIToolbox
import Foundation
/// User-chosen capture hotkey. `modifiers` are Carbon bits (`cmdKey`, `optionKey`,
/// `shiftKey`, `controlKey`), matching `HotkeyCenter.register`.
struct HotkeyPreference: Codable, Equatable, Sendable {
var keyCode: UInt32
var modifiers: UInt32
static let defaultsKey = "ai.flowmaster.shotdeck.hotkey"
/// 2 kVK_ANSI_2 (19) with optionKey|shiftKey.
static let `default` = HotkeyPreference(
keyCode: 19,
modifiers: UInt32(optionKey) | UInt32(shiftKey)
)
static func load(defaults: UserDefaults = .standard) -> HotkeyPreference {
guard let data = defaults.data(forKey: defaultsKey),
let decoded = try? JSONDecoder().decode(HotkeyPreference.self, from: data),
decoded.modifiers != 0
else { return .default }
return decoded
}
func save(defaults: UserDefaults = .standard) {
guard let data = try? JSONEncoder().encode(self) else { return }
defaults.set(data, forKey: Self.defaultsKey)
}
/// in that order, then a name for common keycodes.
var displayString: String {
var s = ""
if modifiers & UInt32(cmdKey) != 0 { s += "" }
if modifiers & UInt32(optionKey) != 0 { s += "" }
if modifiers & UInt32(shiftKey) != 0 { s += "" }
if modifiers & UInt32(controlKey) != 0 { s += "" }
s += Self.keyName(for: keyCode)
return s
}
/// `nil` when the event is modifier-only or has no flags.
static func fromKeyEvent(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> HotkeyPreference? {
switch Int(keyCode) {
case kVK_Shift, kVK_RightShift,
kVK_Command, kVK_RightCommand,
kVK_Option, kVK_RightOption,
kVK_Control, kVK_RightControl,
kVK_CapsLock, kVK_Function:
return nil
default:
break
}
var carbon: UInt32 = 0
if modifierFlags.contains(.command) { carbon |= UInt32(cmdKey) }
if modifierFlags.contains(.option) { carbon |= UInt32(optionKey) }
if modifierFlags.contains(.shift) { carbon |= UInt32(shiftKey) }
if modifierFlags.contains(.control) { carbon |= UInt32(controlKey) }
guard carbon != 0 else { return nil }
return HotkeyPreference(keyCode: UInt32(keyCode), modifiers: carbon)
}
private static func keyName(for keyCode: UInt32) -> String {
switch Int(keyCode) {
case kVK_ANSI_A: return "A"
case kVK_ANSI_B: return "B"
case kVK_ANSI_C: return "C"
case kVK_ANSI_D: return "D"
case kVK_ANSI_E: return "E"
case kVK_ANSI_F: return "F"
case kVK_ANSI_G: return "G"
case kVK_ANSI_H: return "H"
case kVK_ANSI_I: return "I"
case kVK_ANSI_J: return "J"
case kVK_ANSI_K: return "K"
case kVK_ANSI_L: return "L"
case kVK_ANSI_M: return "M"
case kVK_ANSI_N: return "N"
case kVK_ANSI_O: return "O"
case kVK_ANSI_P: return "P"
case kVK_ANSI_Q: return "Q"
case kVK_ANSI_R: return "R"
case kVK_ANSI_S: return "S"
case kVK_ANSI_T: return "T"
case kVK_ANSI_U: return "U"
case kVK_ANSI_V: return "V"
case kVK_ANSI_W: return "W"
case kVK_ANSI_X: return "X"
case kVK_ANSI_Y: return "Y"
case kVK_ANSI_Z: return "Z"
case kVK_ANSI_0: return "0"
case kVK_ANSI_1: return "1"
case kVK_ANSI_2: return "2"
case kVK_ANSI_3: return "3"
case kVK_ANSI_4: return "4"
case kVK_ANSI_5: return "5"
case kVK_ANSI_6: return "6"
case kVK_ANSI_7: return "7"
case kVK_ANSI_8: return "8"
case kVK_ANSI_9: return "9"
case kVK_ANSI_Equal: return "="
case kVK_ANSI_Minus: return "-"
case kVK_ANSI_RightBracket: return "]"
case kVK_ANSI_LeftBracket: return "["
case kVK_ANSI_Quote: return "'"
case kVK_ANSI_Semicolon: return ";"
case kVK_ANSI_Backslash: return "\\"
case kVK_ANSI_Comma: return ","
case kVK_ANSI_Slash: return "/"
case kVK_ANSI_Period: return "."
case kVK_ANSI_Grave: return "`"
case kVK_Space: return "Space"
case kVK_Return: return "Return"
case kVK_Tab: return "Tab"
case kVK_Delete: return "Delete"
case kVK_ForwardDelete: return "Fwd Delete"
case kVK_Escape: return "Esc"
case kVK_Home: return "Home"
case kVK_End: return "End"
case kVK_PageUp: return "Page Up"
case kVK_PageDown: return "Page Down"
case kVK_Help: return "Help"
case kVK_LeftArrow: return ""
case kVK_RightArrow: return ""
case kVK_DownArrow: return ""
case kVK_UpArrow: return ""
case kVK_F1: return "F1"
case kVK_F2: return "F2"
case kVK_F3: return "F3"
case kVK_F4: return "F4"
case kVK_F5: return "F5"
case kVK_F6: return "F6"
case kVK_F7: return "F7"
case kVK_F8: return "F8"
case kVK_F9: return "F9"
case kVK_F10: return "F10"
case kVK_F11: return "F11"
case kVK_F12: return "F12"
case kVK_F13: return "F13"
case kVK_F14: return "F14"
case kVK_F15: return "F15"
case kVK_F16: return "F16"
case kVK_F17: return "F17"
case kVK_F18: return "F18"
case kVK_F19: return "F19"
case kVK_F20: return "F20"
default: return "Key \(keyCode)"
}
}
}
+19 -3
View File
@@ -49,7 +49,7 @@ struct MenuBarView: View {
private var defaultStatusText: String { private var defaultStatusText: String {
guard let region = model.region else { guard let region = model.region else {
return "No region yet — press ⌥⇧2 to pick one." return "No region yet — press \(model.hotkeyDisplayString) to pick one."
} }
let w = Int(region.rect.width) let w = Int(region.rect.width)
let h = Int(region.rect.height) let h = Int(region.rect.height)
@@ -74,6 +74,15 @@ struct MenuBarView: View {
private var actionsList: some View { private var actionsList: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if let update = model.updateAvailable {
Button {
model.installUpdate()
} label: {
actionLabel("Update to \(update.version)")
.foregroundStyle(Color.accentColor)
}
}
Button { Button {
let anchor = NSApp.keyWindow?.contentView let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable { if let sender = model as? SendCapable {
@@ -86,10 +95,17 @@ struct MenuBarView: View {
} }
.disabled(model.session.isEmpty || model.isSending) .disabled(model.session.isEmpty || model.isSending)
Button {
model.revealLastPDF()
} label: {
actionLabel("Reveal last PDF")
}
.disabled(!model.canRevealLastPDF)
Button { Button {
Task { await model.captureNow() } Task { await model.captureNow() }
} label: { } label: {
actionLabel("Capture now", trailing: "⌥⇧2") actionLabel("Capture now", trailing: model.hotkeyDisplayString)
} }
.disabled(model.isCapturing) .disabled(model.isCapturing)
@@ -128,7 +144,7 @@ struct MenuBarView: View {
Button { Button {
NSApp.terminate(nil) NSApp.terminate(nil)
} label: { } label: {
actionLabel("Quit Shotdeck") actionLabel("Quit Redline")
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+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)"
}
}
}
+540
View File
@@ -0,0 +1,540 @@
import AppKit
import CoreGraphics
import Darwin
import Foundation
import ImageIO
import ShotdeckCore
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
/// Posts synthetic mouse events through `NSApp.postEvent` only never CGEventPost/taps.
@MainActor
enum PickerSelfTest {
private static let pointA = NSPoint(x: 200, y: 300)
private static let pointB = NSPoint(x: 600, y: 600)
private static let dragSteps = 6
/// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the
/// env var is unset; otherwise waits for launch, drives the production picker, and `exit`s.
static func runIfRequested() {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"],
!raw.isEmpty
else { return }
let output = URL(fileURLWithPath: raw, isDirectory: true)
// Hop onto a plain main-queue turn after NSApp starts. Nested run loops
// from a Swift Task do not drain NSApp's event queue.
DispatchQueue.main.async {
MainActor.assumeIsolated {
execute(outputDirectory: output)
}
}
}
private static func execute(outputDirectory: URL) {
do {
try FileManager.default.createDirectory(
at: outputDirectory,
withIntermediateDirectories: true
)
} catch {
fail(expected: expectedLabel(), got: "could not create output dir: \(error)")
}
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(pointA) })
?? NSScreen.screens.first
else {
fail(expected: expectedLabel(), got: "no NSScreen")
}
let appKitRect = CGRect(
x: min(pointA.x, pointB.x),
y: min(pointA.y, pointB.y),
width: abs(pointB.x - pointA.x),
height: abs(pointB.y - pointA.y)
).intersection(screen.frame)
let expected = CaptureRegion.fromAppKit(rect: appKitRect, on: screen)
let picker = RegionPickerController()
var result: CaptureRegion??
picker.pick { region in
result = .some(region)
}
guard let overlay = overlayWindow(containing: pointA) else {
fail(expected: format(expected.rect), got: "no overlay window after pick()")
}
overlay.makeKey()
var eventNumber = 1
func post(_ type: NSEvent.EventType, at screenPoint: NSPoint, clickCount: Int) {
let locationInWindow = overlay.convertPoint(fromScreen: screenPoint)
guard let event = NSEvent.mouseEvent(
with: type,
location: locationInWindow,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: overlay.windowNumber,
context: nil,
eventNumber: eventNumber,
clickCount: clickCount,
pressure: 1
) else {
fail(expected: format(expected.rect), got: "NSEvent.mouseEvent(\(type.rawValue)) returned nil")
}
eventNumber += 1
NSApp.postEvent(event, atStart: false)
// Local monitors run during NSApp.sendEvent (production dispatch),
// not during nextEvent dequeue. Drive that same path here.
NSApp.sendEvent(event)
}
post(.leftMouseDown, at: pointA, clickCount: 1)
for step in 1...(dragSteps / 2) {
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
}
writeOverlayBitmap(overlay, to: outputDirectory.appendingPathComponent("overlay-middrag.png"))
for step in ((dragSteps / 2) + 1)...dragSteps {
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
}
post(.leftMouseUp, at: pointB, clickCount: 1)
guard let wrapped = result else {
fail(expected: format(expected.rect), got: "completion never fired")
}
guard let got = wrapped else {
fail(expected: format(expected.rect), got: "nil")
}
if got.rect != expected.rect {
fail(expected: format(expected.rect), got: format(got.rect))
}
print("PICKER-SELFTEST PASS rect=\(format(got.rect))")
fflush(stdout)
runRegionPersistPhase()
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
// exit(0) here runSendTruthPhase prints its own PASS/FAIL, then
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
runSendTruthPhase()
}
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
/// `AppModel.loadPersistedRegion()` (the same path init uses), then restores whatever
/// value was stored before so a real picked region is untouched.
private static func runRegionPersistPhase() {
let defaults = UserDefaults.standard
let previous = defaults.data(forKey: CaptureRegion.defaultsKey)
let known = CaptureRegion(
displayID: CGMainDisplayID(),
rect: CGRect(x: 10, y: 10, width: 100, height: 100),
capturedScale: 2.0
)
var failure: String?
if let encoded = try? JSONEncoder().encode(known) {
defaults.set(encoded, forKey: CaptureRegion.defaultsKey)
if let loaded = AppModel.loadPersistedRegion() {
if loaded.rect != known.rect {
failure = "expected=\(format(known.rect)) got=\(format(loaded.rect))"
}
} else {
failure = "loadPersistedRegion returned nil"
}
} else {
failure = "could not encode CaptureRegion"
}
if let previous {
defaults.set(previous, forKey: CaptureRegion.defaultsKey)
} else {
defaults.removeObject(forKey: CaptureRegion.defaultsKey)
}
if let failure {
print("REGION-PERSIST FAIL \(failure)")
fflush(stdout)
exit(1)
}
print("REGION-PERSIST PASS")
fflush(stdout)
}
/// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet.
/// Fail path must leave the session open in the temp spool; success path archives
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
/// function is called from inside `execute()` a nested run-loop wait would never
/// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting.
private static func runSendTruthPhase() {
Task { @MainActor in
do {
try await executeSendTruth()
print("SEND-TRUTH PASS")
fflush(stdout)
if !startUpdateSelfTestIfRequested() {
exit(0)
}
} catch {
print("SEND-TRUTH FAIL \(error)")
fflush(stdout)
exit(1)
}
}
}
private static func executeSendTruth() async throws {
let fm = FileManager.default
let root = fm.temporaryDirectory
.appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: root) }
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)
let png = try makeTinyPNGData()
_ = try await model.spool.append(
pngData: png,
pixelWidth: 64,
pixelHeight: 48,
scale: 1,
capturedAt: Date()
)
model.replaceSession(try await model.spool.currentSession())
let openID = model.session.id
guard !model.session.isEmpty else {
sendTruthFail("seeded session was empty")
}
let pending = try await model.composePDFForSend()
guard fm.fileExists(atPath: pending.fileURL.path) else {
sendTruthFail("PDF was not written")
}
model.handleDidFailToShareItems(fileName: pending.fileName)
let still = try await model.spool.currentSession()
guard still.id == openID, !still.isEmpty, still.state == .open else {
sendTruthFail("fail path archived or replaced the session")
}
let spoolDir = paths.sessionDirectory(openID)
guard fm.fileExists(atPath: spoolDir.path) else {
sendTruthFail("fail path: session missing from temp spool")
}
guard let status = model.statusLine, status.contains("nothing was sent") else {
sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")")
}
await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount)
let fresh = try await model.spool.currentSession()
guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else {
sendTruthFail("success path did not mint a fresh empty session")
}
let archived = try await model.spool.archivedSessions()
guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else {
sendTruthFail("success path did not archive the session")
}
let archiveDir = paths.archiveDirectory(openID)
guard fm.fileExists(atPath: archiveDir.path) else {
sendTruthFail("success path: archive dir missing")
}
guard !fm.fileExists(atPath: spoolDir.path) else {
sendTruthFail("success path: session still in spool")
}
}
private static func makeTinyPNGData() throws -> Data {
let width = 64
let height = 48
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 {
sendTruthFail("could not create PNG context")
}
context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let image = context.makeImage() else {
sendTruthFail("could not make CGImage")
}
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
buffer,
"public.png" as CFString,
1,
nil
) else {
sendTruthFail("could not create PNG destination")
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
sendTruthFail("could not finalize PNG")
}
return buffer as Data
}
private static func sendTruthFail(_ reason: String) -> Never {
print("SEND-TRUTH FAIL \(reason)")
fflush(stdout)
exit(1)
}
/// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via
/// `checkNow`, then `installStaged` into the env dir never `/Applications`.
/// Returns true when the async phase was scheduled (it calls `exit` itself).
@discardableResult
private static func startUpdateSelfTestIfRequested() -> Bool {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"],
!raw.isEmpty
else { return false }
let output = URL(fileURLWithPath: raw, isDirectory: true)
Task { @MainActor in
do {
try await runUpdateSelfTest(outputDirectory: output)
print("UPDATE-SELFTEST PASS version=99.0.0")
fflush(stdout)
exit(0)
} catch let error as UpdateSelfTestError {
updateFail(error.description)
} catch {
updateFail(String(describing: error))
}
}
return true
}
private static func runUpdateSelfTest(outputDirectory: URL) async throws {
let fm = FileManager.default
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
guard let sourceApp = ownAppBundleURL() else {
throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
}
let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
if fm.fileExists(atPath: payload.path) {
try fm.removeItem(at: payload)
}
try fm.createDirectory(at: payload, withIntermediateDirectories: true)
let fakeApp = payload.appendingPathComponent("Redline.app")
try fm.copyItem(at: sourceApp, to: fakeApp)
let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist")
let plistData = try Data(contentsOf: plistURL)
guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else {
throw UpdateSelfTestError.detail("could not parse copied Info.plist")
}
plist["CFBundleShortVersionString"] = "99.0.0"
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try rewritten.write(to: plistURL)
let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
if fm.fileExists(atPath: zipURL.path) {
try fm.removeItem(at: zipURL)
}
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
let zipData = try Data(contentsOf: zipURL)
let hex = UpdateChecker.sha256Hex(zipData)
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
let appcast: [String: String] = [
"version": "99.0.0",
"zipURL": zipURL.absoluteString,
"sha256": hex,
"notes": "UPDATE-SELFTEST",
]
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
try appcastData.write(to: appcastURL)
let defaults = UserDefaults.standard
let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
defer {
if let previous {
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
} else {
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
}
}
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
defer { try? fm.removeItem(at: isolatedRoot) }
await model.updateChecker.checkNow()
guard model.updateAvailable?.version == "99.0.0" else {
throw UpdateSelfTestError.detail(
"updateAvailable=\(model.updateAvailable?.version ?? "nil")"
)
}
guard let staged = model.updateChecker.stagedAppURL else {
throw UpdateSelfTestError.detail("staged payload missing")
}
guard staged.lastPathComponent == "Redline.app" else {
throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)")
}
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
guard fm.fileExists(atPath: stagedExe.path) else {
throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing")
}
let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
if fm.fileExists(atPath: targetRoot.path) {
try fm.removeItem(at: targetRoot)
}
let target = targetRoot.appendingPathComponent("Redline.app")
model.updateChecker.installStaged(to: target)
let installedPlist = target.appendingPathComponent("Contents/Info.plist")
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
let installedVersion = installed["CFBundleShortVersionString"] as? String
else {
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
}
guard installedVersion == "99.0.0" else {
throw UpdateSelfTestError.detail("installed version \(installedVersion)")
}
}
private static func ownAppBundleURL() -> URL? {
let bundle = Bundle.main.bundleURL
if bundle.pathExtension == "app" { return bundle }
let up3 = bundle
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
if up3.pathExtension == "app" { return up3 }
return nil
}
private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-update-selftest-\(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)
)
return (model, root)
}
private static func runDitto(arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.arguments = arguments
let err = Pipe()
process.standardError = err
process.standardOutput = Pipe()
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
throw UpdateSelfTestError.detail("ditto failed: \(message)")
}
}
private static func updateFail(_ detail: String) -> Never {
print("UPDATE-SELFTEST FAIL \(detail)")
fflush(stdout)
exit(1)
}
private static func interpolate(_ step: Int) -> NSPoint {
let t = CGFloat(step) / CGFloat(dragSteps)
return NSPoint(
x: pointA.x + (pointB.x - pointA.x) * t,
y: pointA.y + (pointB.y - pointA.y) * t
)
}
private static func overlayWindow(containing point: NSPoint) -> RegionPickerWindow? {
let overlays = NSApp.windows.compactMap { $0 as? RegionPickerWindow }
return overlays.first(where: { $0.coveringScreen.frame.contains(point) }) ?? overlays.first
}
private static func writeOverlayBitmap(_ overlay: RegionPickerWindow, to url: URL) {
guard let view = overlay.contentView else {
fail(expected: expectedLabel(), got: "overlay has no contentView")
}
overlay.layoutIfNeeded()
view.layoutSubtreeIfNeeded()
view.display()
let bounds = view.bounds
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
fail(expected: expectedLabel(), got: "bitmapImageRepForCachingDisplay failed")
}
view.cacheDisplay(in: bounds, to: rep)
guard let png = rep.representation(using: .png, properties: [:]) else {
fail(expected: expectedLabel(), got: "PNG encode failed")
}
do {
try png.write(to: url)
} catch {
fail(expected: expectedLabel(), got: "could not write \(url.path): \(error)")
}
print(url.path)
fflush(stdout)
}
private static func expectedLabel() -> String {
format(CGRect(
x: min(pointA.x, pointB.x),
y: min(pointA.y, pointB.y),
width: abs(pointB.x - pointA.x),
height: abs(pointB.y - pointA.y)
))
}
private static func format(_ rect: CGRect) -> String {
"(\(rect.origin.x), \(rect.origin.y), \(rect.width), \(rect.height))"
}
private static func fail(expected: String, got: String) -> Never {
print("PICKER-SELFTEST FAIL expected=\(expected) got=\(got)")
fflush(stdout)
exit(1)
}
}
private enum UpdateSelfTestError: Error, CustomStringConvertible {
case detail(String)
var description: String {
switch self {
case .detail(let s): return s
}
}
}
+41 -19
View File
@@ -48,20 +48,27 @@ public final class RegionPickerController {
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown] matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown]
) { [weak self] event in ) { [weak self] event in
guard let self else { return event } guard let self else { return event }
// NSEvent is not Sendable; lift the two Sendable fields the handler // NSEvent is not Sendable; lift Sendable fields the handler needs.
// needs. Mouse geometry is read from NSEvent.mouseLocation on the // Prefer the event's window-local point converted to global AppKit
// main thread inside the isolated methods, per spec. // coordinates; NSEvent.mouseLocation is only a fallback.
// keyCode is only valid on key events reading it on a mouse event raises.
let type = event.type let type = event.type
let keyCode = event.keyCode let keyCode: UInt16 = (type == .keyDown) ? event.keyCode : 0
let locationInWindow = event.locationInWindow
let windowNumber = event.windowNumber
let consume = MainActor.assumeIsolated { let consume = MainActor.assumeIsolated {
self.handle(type: type, keyCode: keyCode) let location = self.globalAppKitLocation(
locationInWindow: locationInWindow,
windowNumber: windowNumber
)
return self.handle(type: type, keyCode: keyCode, location: location)
} }
return consume ? nil : event return consume ? nil : event
} }
} }
/// Returns `true` when the event should be swallowed. /// Returns `true` when the event should be swallowed.
private func handle(type: NSEvent.EventType, keyCode: UInt16) -> Bool { private func handle(type: NSEvent.EventType, keyCode: UInt16, location: NSPoint?) -> Bool {
switch type { switch type {
case .keyDown: case .keyDown:
if keyCode == 53 { // kVK_Escape if keyCode == 53 { // kVK_Escape
@@ -70,21 +77,21 @@ public final class RegionPickerController {
} }
return false return false
case .leftMouseDown: case .leftMouseDown:
handleMouseDown() handleMouseDown(location: location)
return false return false
case .leftMouseDragged: case .leftMouseDragged:
handleMouseDragged() handleMouseDragged(location: location)
return false return false
case .leftMouseUp: case .leftMouseUp:
handleMouseUp() handleMouseUp(location: location)
return false return false
default: default:
return false return false
} }
} }
private func handleMouseDown() { private func handleMouseDown(location: NSPoint?) {
let point = NSEvent.mouseLocation let point = location ?? NSEvent.mouseLocation
dragStart = point dragStart = point
startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) }) startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) })
?? NSScreen.screens.first ?? NSScreen.screens.first
@@ -99,17 +106,17 @@ public final class RegionPickerController {
} }
} }
private func handleMouseDragged() { private func handleMouseDragged(location: NSPoint?) {
guard dragStart != nil, startScreen != nil else { return } guard dragStart != nil, startScreen != nil else { return }
applyLiveSelection() applyLiveSelection(current: location)
} }
private func handleMouseUp() { private func handleMouseUp(location: NSPoint?) {
guard let startScreen else { guard let startScreen else {
dragStart = nil dragStart = nil
return return
} }
guard let rect = currentClampedRect(), rect.width >= 8, rect.height >= 8 else { guard let rect = currentClampedRect(current: location), rect.width >= 8, rect.height >= 8 else {
// Mis-click: reset drag state, leave every window open and fully dimmed. // Mis-click: reset drag state, leave every window open and fully dimmed.
dragStart = nil dragStart = nil
self.startScreen = nil self.startScreen = nil
@@ -122,8 +129,8 @@ public final class RegionPickerController {
finish(region: region) finish(region: region)
} }
private func applyLiveSelection() { private func applyLiveSelection(current: NSPoint?) {
guard let startScreen, let rect = currentClampedRect() else { return } guard let startScreen, let rect = currentClampedRect(current: current) else { return }
let local = Self.localRect(from: rect, on: startScreen) let local = Self.localRect(from: rect, on: startScreen)
let text = "\(Int(rect.width)) x \(Int(rect.height))" let text = "\(Int(rect.width)) x \(Int(rect.height))"
window(for: startScreen)?.updateSelection(localRect: local, sizeText: text) window(for: startScreen)?.updateSelection(localRect: local, sizeText: text)
@@ -134,9 +141,9 @@ public final class RegionPickerController {
/// Normalize the drag (so bottom-right up-left is not misjudged) then clamp /// Normalize the drag (so bottom-right up-left is not misjudged) then clamp
/// to the screen the gesture started on never selects across displays. /// to the screen the gesture started on never selects across displays.
private func currentClampedRect() -> CGRect? { private func currentClampedRect(current: NSPoint?) -> CGRect? {
guard let dragStart, let startScreen else { return nil } guard let dragStart, let startScreen else { return nil }
let current = NSEvent.mouseLocation let current = current ?? NSEvent.mouseLocation
let normalized = CGRect( let normalized = CGRect(
x: min(dragStart.x, current.x), x: min(dragStart.x, current.x),
y: min(dragStart.y, current.y), y: min(dragStart.y, current.y),
@@ -146,6 +153,21 @@ public final class RegionPickerController {
return normalized.intersection(startScreen.frame) return normalized.intersection(startScreen.frame)
} }
/// Convert a monitored event's `locationInWindow` into global AppKit coordinates
/// (bottom-left origin, matching `NSEvent.mouseLocation` / `NSScreen.frame`).
private func globalAppKitLocation(locationInWindow: NSPoint, windowNumber: Int) -> NSPoint? {
if let window = windows.first(where: { $0.windowNumber == windowNumber }) {
return window.convertPoint(toScreen: locationInWindow)
}
if windowNumber != 0, let window = NSApp.window(withWindowNumber: windowNumber) {
return window.convertPoint(toScreen: locationInWindow)
}
if windowNumber == 0 {
return locationInWindow
}
return nil
}
private func window(for screen: NSScreen) -> RegionPickerWindow? { private func window(for screen: NSScreen) -> RegionPickerWindow? {
windows.first { $0.coveringScreen === screen } windows.first { $0.coveringScreen === screen }
} }
@@ -29,6 +29,7 @@ final class RegionPickerWindow: NSPanel {
becomesKeyOnlyIfNeeded = false becomesKeyOnlyIfNeeded = false
animationBehavior = .none animationBehavior = .none
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
acceptsMouseMovedEvents = true
contentView = pickerView contentView = pickerView
} }
@@ -53,6 +54,8 @@ private final class RegionPickerView: NSView {
override var isOpaque: Bool { false } override var isOpaque: Bool { false }
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
// Accessory-app trap: first click on an inactive overlay is first-mouse and is dropped unless accepted.
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect) super.draw(dirtyRect)
+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))
}
}
+92 -43
View File
@@ -3,12 +3,59 @@ import Darwin
import Foundation import Foundation
import ShotdeckCore import ShotdeckCore
/// Result of composing a send PDF. Kept so the self-test can drive the share
/// outcome without presenting a real AirDrop sheet.
struct ComposedSend: Sendable {
let fileName: String
let fileURL: URL
let pageCount: Int
}
extension AppModel: SendCapable { extension AppModel: SendCapable {
public func send(anchor: NSView?) async { public func send(anchor: NSView?) async {
guard !session.isEmpty, !isSending else { return } guard !session.isEmpty, !isSending else { return }
setSending(true) setSending(true)
defer { setSending(false) }
let pending: ComposedSend
do {
pending = try await composePDFForSend()
} catch {
// Never unlink the published PDF, and never unlink the temp file either:
// a rename failure would leave the complete document at the temp name.
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
setSending(false)
return
}
guard let anchor else {
handleDidFailToShareItems(fileName: pending.fileName)
setSending(false)
return
}
do {
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
guard let self else { return }
if success {
await self.handleDidShareItems(
fileName: pending.fileName,
pageCount: pending.pageCount
)
} else {
self.handleDidFailToShareItems(fileName: pending.fileName)
}
self.setSending(false)
}
} catch {
// canPerform false, no service, or no visible window: same as cancel.
handleDidFailToShareItems(fileName: pending.fileName)
setSending(false)
}
}
/// Writes the PDF to the outbox and records its path. Does not archive the session
/// and does not present AirDrop that happens only after the share completes.
func composePDFForSend() async throws -> ComposedSend {
let workingSession = session let workingSession = session
let composer = self.composer let composer = self.composer
// Live outbox (FolderSettings), not `paths.outbox` Settings changes take effect. // Live outbox (FolderSettings), not `paths.outbox` Settings changes take effect.
@@ -18,54 +65,56 @@ extension AppModel: SendCapable {
let finalURL = outboxDir.appendingPathComponent(fileName) let finalURL = outboxDir.appendingPathComponent(fileName)
// Same directory as the final target so the rename below is same-volume (atomic). // Same directory as the final target so the rename below is same-volume (atomic).
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf") let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
let title = "Shotdeck \(DubaiTime.stamp(workingSession.createdAt))" let title = "Redline \(DubaiTime.stamp(workingSession.createdAt))"
do { // D-13: build off the main actor. Only Sendable values cross into the
// D-13: build off the main actor. Only Sendable values cross into the // detached task never `anchor` (NSView is not Sendable).
// detached task never `anchor` (NSView is not Sendable). try await Task.detached(priority: .userInitiated) {
try await Task.detached(priority: .userInitiated) { _ = try composer.compose(
_ = try composer.compose( session: workingSession,
session: workingSession, imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) },
imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, title: title,
title: title, to: tempURL
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)))"
) )
// 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 AtomicFile.fsyncDirectory(at: outboxDir)
}.value
guard FileManager.default.fileExists(atPath: finalURL.path) else {
throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk")
}
rememberLastComposedPDF(finalURL)
return ComposedSend(
fileName: fileName,
fileURL: finalURL,
pageCount: workingSession.captures.count
)
}
/// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam.
func handleDidShareItems(fileName: String, pageCount: Int) async {
guard !session.isEmpty else { return }
do {
_ = try await spool.archiveCurrent(pdfFileName: fileName) _ = try await spool.archiveCurrent(pdfFileName: fileName)
replaceSession(try await spool.currentSession()) replaceSession(try await spool.currentSession())
let pageWord = pageCount == 1 ? "page" : "pages"
let pageWord = workingSession.captures.count == 1 ? "page" : "pages" setStatus("Sent — \(pageCount) \(pageWord).")
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 { } catch {
// Never unlink the published PDF, and never unlink `tempURL` either: setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.")
// a rename failure would leave the complete document at the temp name.
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
} }
} }
/// `NSSharingServiceDelegate.sharingService(_:didFailToShareItems:error:)` seam,
/// also used when `canPerform` is false or the user cancels. Does not archive.
func handleDidFailToShareItems(fileName: String) {
setStatus(
"AirDrop didn't complete — nothing was sent. Your captures are still here; the PDF is on your \(outboxDisplayName) as \(fileName)."
)
}
} }
+204
View File
@@ -0,0 +1,204 @@
import AppKit
import SwiftUI
import ShotdeckCore
struct SettingsView: View {
@Environment(AppModel.self) private var model
@State private var isRecordingHotkey = false
@State private var recorder = HotkeyRecorderBox()
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: .center) {
fieldLabel("Capture")
HStack(spacing: 8) {
Button {
armHotkeyRecorder()
} label: {
Text(isRecordingHotkey ? "Press keys…" : model.hotkeyDisplayString)
.foregroundStyle(isRecordingHotkey ? .secondary : .primary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(minHeight: 22)
}
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)
.onDisappear { disarmHotkeyRecorder() }
}
private func armHotkeyRecorder() {
guard !isRecordingHotkey else { return }
isRecordingHotkey = true
recorder.onKey = { keyCode, flags in
handleRecorderKey(keyCode: keyCode, flags: flags)
}
recorder.arm()
}
private func handleRecorderKey(keyCode: UInt16, flags: NSEvent.ModifierFlags) {
if keyCode == 53 { // kVK_Escape
disarmHotkeyRecorder()
return
}
guard let pref = HotkeyPreference.fromKeyEvent(keyCode: keyCode, modifierFlags: flags) else {
return
}
pref.save()
model.reRegisterHotkey()
disarmHotkeyRecorder()
}
private func disarmHotkeyRecorder() {
recorder.disarm()
recorder.onKey = nil
isRecordingHotkey = false
}
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)
}
}
/// Local keyDown monitor for the Settings capture-hotkey recorder. Callbacks hop onto the
/// main actor the same way `RegionPickerController` does local monitors fire on the
/// main run loop during `NSApp.sendEvent`.
@MainActor
private final class HotkeyRecorderBox {
var onKey: ((UInt16, NSEvent.ModifierFlags) -> Void)?
private var monitor: Any?
func arm() {
disarm()
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
let keyCode = event.keyCode
let rawFlags = event.modifierFlags.rawValue
MainActor.assumeIsolated {
self.onKey?(keyCode, NSEvent.ModifierFlags(rawValue: rawFlags))
}
return nil
}
}
func disarm() {
if let monitor {
NSEvent.removeMonitor(monitor)
}
monitor = nil
}
}
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 = "Redline 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
}
}
+37 -6
View File
@@ -7,7 +7,14 @@ enum Sharing {
/// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created, /// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created,
/// `canPerform` is false, or `view` is not in a visible window (a detached view /// `canPerform` is false, or `view` is not in a visible window (a detached view
/// never produces an on-screen sheet). /// never produces an on-screen sheet).
static func airDrop(fileURL: URL, from view: NSView) throws { ///
/// `onFinished` is invoked on the main actor when the sheet completes: `true` for
/// `didShareItems`, `false` for `didFailToShareItems` (including user cancel).
static func airDrop(
fileURL: URL,
from view: NSView,
onFinished: @escaping @MainActor @Sendable (Bool) async -> Void
) throws {
guard let service = NSSharingService(named: .sendViaAirDrop), guard let service = NSSharingService(named: .sendViaAirDrop),
service.canPerform(withItems: [fileURL]) else { service.canPerform(withItems: [fileURL]) else {
throw ShotdeckError.airDropUnavailable throw ShotdeckError.airDropUnavailable
@@ -21,7 +28,12 @@ enum Sharing {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
service.subject = fileURL.lastPathComponent service.subject = fileURL.lastPathComponent
let session = AirDropSession(service: service, window: window, view: view) let session = AirDropSession(
service: service,
window: window,
view: view,
onFinished: onFinished
)
AirDropSession.keepAlive(session) AirDropSession.keepAlive(session)
service.delegate = session service.delegate = session
service.perform(withItems: [fileURL]) service.perform(withItems: [fileURL])
@@ -29,7 +41,9 @@ enum Sharing {
} }
/// Retains the sharing service for the life of the picker and supplies the real /// 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. /// on-screen window as the sheet parent. `NSSharingService.delegate` is weak, so
/// `live` is the strong reference that keeps this object alive until the sheet
/// reports success or failure (including cancel).
@MainActor @MainActor
private final class AirDropSession: NSObject, NSSharingServiceDelegate { private final class AirDropSession: NSObject, NSSharingServiceDelegate {
static var live: [AirDropSession] = [] static var live: [AirDropSession] = []
@@ -37,11 +51,19 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
let service: NSSharingService let service: NSSharingService
let window: NSWindow let window: NSWindow
let view: NSView let view: NSView
let onFinished: @MainActor @Sendable (Bool) async -> Void
private var reported = false
init(service: NSSharingService, window: NSWindow, view: NSView) { init(
service: NSSharingService,
window: NSWindow,
view: NSView,
onFinished: @escaping @MainActor @Sendable (Bool) async -> Void
) {
self.service = service self.service = service
self.window = window self.window = window
self.view = view self.view = view
self.onFinished = onFinished
} }
static func keepAlive(_ session: AirDropSession) { static func keepAlive(_ session: AirDropSession) {
@@ -52,6 +74,15 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
Self.live.removeAll { $0 === self } Self.live.removeAll { $0 === self }
} }
private func report(_ success: Bool) {
guard !reported else { return }
reported = true
Task { @MainActor in
await self.onFinished(success)
self.drop()
}
}
func sharingService( func sharingService(
_ sharingService: NSSharingService, _ sharingService: NSSharingService,
sourceWindowForShareItems items: [Any], sourceWindowForShareItems items: [Any],
@@ -70,7 +101,7 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
} }
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
drop() report(true)
} }
func sharingService( func sharingService(
@@ -78,6 +109,6 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
didFailToShareItems items: [Any], didFailToShareItems items: [Any],
error: any Error error: any Error
) { ) {
drop() report(false)
} }
} }
+281
View File
@@ -0,0 +1,281 @@
import AppKit
import CryptoKit
import Foundation
/// Built-in updater. Checks an appcast, stages a verified payload, and installs
/// only when the user clicks the menu row never automatically.
@MainActor
final class UpdateChecker {
static let appcastURLDefaultsKey = "ai.flowmaster.shotdeck.appcastURL"
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
private(set) var availableUpdate: (version: String, notes: String)?
private(set) var stagedAppURL: URL?
private(set) var statusMessage: String?
var onChecked: (() -> Void)?
private let urlSession: URLSession
private var repeatingTimer: Timer?
private var firstCheckTask: Task<Void, Never>?
private var isChecking = false
private var stagingDirectory: URL?
init() {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 15
config.httpCookieAcceptPolicy = .never
config.httpShouldSetCookies = false
config.httpCookieStorage = nil
config.urlCache = nil
urlSession = URLSession(configuration: config)
}
/// First check 10 seconds after start, then every 6 hours. Stages only never installs.
func startSchedule() {
firstCheckTask?.cancel()
firstCheckTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { return }
await self?.checkNow()
}
repeatingTimer?.invalidate()
let timer = Timer(timeInterval: 6 * 60 * 60, repeats: true) { [weak self] _ in
Task { @MainActor in
await self?.checkNow()
}
}
RunLoop.main.add(timer, forMode: .common)
repeatingTimer = timer
}
func checkNow() async {
guard !isChecking else { return }
isChecking = true
defer { isChecking = false }
let appcast: Appcast
do {
appcast = try await fetchAppcast()
} catch {
statusMessage = "Could not check for updates."
onChecked?()
return
}
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
clearOffer()
statusMessage = nil
onChecked?()
return
}
do {
try await downloadAndStage(appcast)
availableUpdate = (version: appcast.version, notes: appcast.notes ?? "")
statusMessage = nil
} catch UpdateCheckError.checksumMismatch {
discardStaging()
availableUpdate = nil
statusMessage = "Update file failed the checksum — not installed."
} catch {
discardStaging()
availableUpdate = nil
statusMessage = "The update could not be prepared."
}
onChecked?()
}
/// Copies the staged app onto `target` with ditto (in place; never deletes the old app).
/// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test
/// can assert the installed Info.plist without killing the process.
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
guard let staged = stagedAppURL else {
statusMessage = "No update is staged."
onChecked?()
return
}
do {
try FileManager.default.createDirectory(
at: target.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path])
} catch {
statusMessage = "The update could not be installed."
onChecked?()
return
}
let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
if isSelfTest { return }
do {
try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
} catch {
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
onChecked?()
return
}
NSApp.terminate(nil)
}
static func resolvedAppcastURL() -> URL {
if let env = ProcessInfo.processInfo.environment["REDLINE_APPCAST_URL"],
!env.isEmpty,
let url = URL(string: env)
{
return url
}
if let stored = UserDefaults.standard.string(forKey: appcastURLDefaultsKey),
!stored.isEmpty,
let url = URL(string: stored)
{
return url
}
return defaultAppcastURL
}
static func currentVersion() -> String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}
static func isNewer(_ candidate: String, than current: String) -> Bool {
let a = semverParts(candidate)
let b = semverParts(current)
for i in 0..<3 {
if a[i] != b[i] { return a[i] > b[i] }
}
return false
}
static func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
// MARK: - Private
private struct Appcast: Decodable {
var version: String
var zipURL: URL
var sha256: String
var notes: String?
}
private enum UpdateCheckError: Error {
case checksumMismatch
case invalidPayload
case httpStatus(Int)
case processFailed(String)
}
private func fetchAppcast() async throws -> Appcast {
let data = try await fetchData(from: Self.resolvedAppcastURL())
return try JSONDecoder().decode(Appcast.self, from: data)
}
private func fetchData(from url: URL) async throws -> Data {
if url.isFileURL {
return try Data(contentsOf: url)
}
let (data, response) = try await urlSession.data(from: url)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw UpdateCheckError.httpStatus(http.statusCode)
}
return data
}
private func downloadAndStage(_ appcast: Appcast) async throws {
let zipData = try await fetchData(from: appcast.zipURL)
let expected = appcast.sha256.trimmingCharacters(in: .whitespacesAndNewlines)
let actual = Self.sha256Hex(zipData)
guard actual.caseInsensitiveCompare(expected) == .orderedSame else {
throw UpdateCheckError.checksumMismatch
}
discardStaging()
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-update-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
stagingDirectory = root
let zipURL = root.appendingPathComponent("update.zip")
try zipData.write(to: zipURL)
let extracted = root.appendingPathComponent("extracted", isDirectory: true)
try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true)
try Self.runProcess(
executable: "/usr/bin/ditto",
arguments: ["-x", "-k", zipURL.path, extracted.path]
)
guard let appURL = Self.findRedlineApp(in: extracted) else {
throw UpdateCheckError.invalidPayload
}
let executable = appURL.appendingPathComponent("Contents/MacOS/Shotdeck")
guard FileManager.default.fileExists(atPath: executable.path) else {
throw UpdateCheckError.invalidPayload
}
stagedAppURL = appURL
}
private func clearOffer() {
availableUpdate = nil
discardStaging()
}
private func discardStaging() {
if let stagingDirectory {
try? FileManager.default.removeItem(at: stagingDirectory)
}
stagingDirectory = nil
stagedAppURL = nil
}
private static func findRedlineApp(in directory: URL) -> URL? {
let fm = FileManager.default
let direct = directory.appendingPathComponent("Redline.app")
if fm.fileExists(atPath: direct.path) { return direct }
guard let enumerator = fm.enumerator(
at: directory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return nil }
while let item = enumerator.nextObject() as? URL {
if item.lastPathComponent == "Redline.app" {
return item
}
if item.pathExtension == "app" {
enumerator.skipDescendants()
}
}
return nil
}
private static func semverParts(_ string: String) -> [Int] {
let core = string.split(separator: "-").first.map(String.init) ?? string
var parts = core.split(separator: ".").prefix(3).map { Int($0) ?? 0 }
while parts.count < 3 { parts.append(0) }
return parts
}
private static func runProcess(executable: String, arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let err = Pipe()
process.standardError = err
process.standardOutput = Pipe()
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
throw UpdateCheckError.processFailed("\(executable) failed: \(message)")
}
}
}
+8 -2
View File
@@ -4,6 +4,12 @@ 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() }
}
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
}
ShotdeckApp.main() ShotdeckApp.main()
struct ShotdeckApp: App { struct ShotdeckApp: App {
@@ -21,7 +27,7 @@ struct ShotdeckApp: App {
Text(count).font(.system(size: 11, weight: .semibold)) Text(count).font(.system(size: 11, weight: .semibold))
} }
} }
.accessibilityLabel("Shotdeck") .accessibilityLabel("Redline")
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)
} }
@@ -54,7 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Safe: temp-dir creation for a path this process controls cannot legitimately fail. // Safe: temp-dir creation for a path this process controls cannot legitimately fail.
let fallback = try! AppSupportPaths(root: fallbackRoot, outbox: tmp, watchFolder: tmp) let fallback = try! AppSupportPaths(root: fallbackRoot, outbox: tmp, watchFolder: tmp)
let model = try! makeModel(paths: fallback) let model = try! makeModel(paths: fallback)
model.setStatus("Shotdeck could not access its storage folder. Captures will not persist.") model.setStatus("Redline could not access its storage folder. Captures will not persist.")
return model return model
} }
} }
@@ -16,7 +16,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
case .screenRecordingNotGranted: case .screenRecordingNotGranted:
return "Screen Recording is turned off. Grant it in System Settings to capture." return "Screen Recording is turned off. Grant it in System Settings to capture."
case .noRegionRemembered: case .noRegionRemembered:
return "No capture region is set. Choose 'Re-select area' from the Shotdeck menu." return "No capture region is set. Choose 'Re-select area' from the Redline menu."
case .displayNoLongerConnected: case .displayNoLongerConnected:
return "The display used for capture is no longer connected." return "The display used for capture is no longer connected."
case .captureFailed(let underlying): case .captureFailed(let underlying):
+3 -3
View File
@@ -70,8 +70,8 @@ public struct PDFComposer: Sendable {
} }
} }
public static func fileName(for session: CaptureSession) -> String { public static func fileName(for _: CaptureSession) -> String {
"Shotdeck-\(DubaiTime.fileStamp(session.createdAt)).pdf" "Redline-\(DubaiTime.fileStamp(Date())).pdf"
} }
private static func writePDF( private static func writePDF(
@@ -86,7 +86,7 @@ public struct PDFComposer: Sendable {
} }
let auxiliaryInfo: [String: Any] = [ let auxiliaryInfo: [String: Any] = [
kCGPDFContextCreator as String: "Shotdeck", kCGPDFContextCreator as String: "Redline",
kCGPDFContextTitle as String: title, kCGPDFContextTitle as String: title,
kCGPDFContextSubject as String: sessionID.uuidString.lowercased(), kCGPDFContextSubject as String: sessionID.uuidString.lowercased(),
] ]
@@ -93,16 +93,19 @@ public enum AnnotationInspector {
) )
} }
/// True when this PDF was produced by Shotdeck. Creator attribute is authoritative; /// True when this PDF was produced by this app. Creator attribute is authoritative;
/// the filename fallback applies ONLY when the creator attribute is absent. /// the filename fallback applies ONLY when the creator attribute is absent.
/// Accepts both the current product name ("Redline") and the legacy name ("Shotdeck")
/// so PDFs already on the iPad or in Downloads are still detected.
public static func isShotdeckDocument(_ document: PDFDocument) -> Bool { public static func isShotdeckDocument(_ document: PDFDocument) -> Bool {
if let creator = document.documentAttributes?[PDFDocumentAttribute.creatorAttribute] as? String { if let creator = document.documentAttributes?[PDFDocumentAttribute.creatorAttribute] as? String {
return creator == "Shotdeck" // present creator is authoritative, full stop // Present creator is authoritative, full stop.
return creator == "Redline" || creator == "Shotdeck"
} }
// Creator ABSENT (some apps rewrite metadata on save) -> filename fallback only here. // Creator ABSENT (some apps rewrite metadata on save) -> filename fallback only here.
guard let name = document.documentURL?.lastPathComponent else { return false } guard let name = document.documentURL?.lastPathComponent else { return false }
// .lastPathComponent on a file URL is already percent-decoded; do not use .absoluteString. // .lastPathComponent on a file URL is already percent-decoded; do not use .absoluteString.
return name.wholeMatch(of: /^Shotdeck-\d{8}-\d{6}( \d+)?\.pdf$/) != nil return name.wholeMatch(of: /^(Redline|Shotdeck)-\d{8}-\d{6}( \d+)?\.pdf$/) != nil
// Case-sensitive by construction (Swift Regex literals are case-sensitive by default). // Case-sensitive by construction (Swift Regex literals are case-sensitive by default).
// The optional "( \d+)?" is macOS's duplicate-name suffix AirDrop adds when a file of // The optional "( \d+)?" is macOS's duplicate-name suffix AirDrop adds when a file of
// the same name already exists in the watch folder the normal case for a return. // the same name already exists in the watch folder the normal case for a return.
@@ -60,7 +60,7 @@ public actor ReturnWatcher {
} }
/// Scans the watch folder once, immediately, without waiting for an event. Every /// Scans the watch folder once, immediately, without waiting for an event. Every
/// recognized, stable, openable Shotdeck PDF present is (re-)inspected and (re-)recorded /// recognized, stable, openable Redline/Shotdeck PDF present is (re-)inspected and (re-)recorded
/// into the ledger; returns exactly the documents processed in this call. /// into the ledger; returns exactly the documents processed in this call.
@discardableResult @discardableResult
public func scanNow() async throws -> [ReturnedDocument] { public func scanNow() async throws -> [ReturnedDocument] {
@@ -371,6 +371,72 @@ func i19b_presentNonShotdeckCreatorBeatsMatchingFilename() throws {
#expect(AnnotationInspector.isShotdeckDocument(reopened) == false) #expect(AnnotationInspector.isShotdeckDocument(reopened) == false)
} }
@Test("I-19c Present Redline creator is recognized")
func i19c_presentRedlineCreatorIsRecognized() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134219.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline")
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == true)
}
@Test("I-19d Present legacy Shotdeck creator is still recognized")
func i19d_presentLegacyShotdeckCreatorIsStillRecognized() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134220.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: "Shotdeck")
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == true)
}
@Test("I-19e Absent creator falls back to Redline filename")
func i19e_absentCreatorFallsBackToRedlineFilename() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134221.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: nil)
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == true)
}
@Test("I-19f Absent creator falls back to legacy Shotdeck filename")
func i19f_absentCreatorFallsBackToLegacyShotdeckFilename() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134222.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: nil)
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == true)
}
@Test("I-19g Absent creator falls back to Redline duplicate-name suffix")
func i19g_absentCreatorFallsBackToRedlineDuplicateNameSuffix() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134223 2.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: nil)
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == true)
}
@Test("I-19h Present non-product creator beats a matching Redline filename")
func i19h_presentNonProductCreatorBeatsMatchingRedlineFilename() throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134224.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: "Preview")
let reopened = try #require(PDFDocument(url: pdfURL))
#expect(AnnotationInspector.isShotdeckDocument(reopened) == false)
}
private func expectSinglePageMark( private func expectSinglePageMark(
_ subtype: PDFAnnotationSubtype, _ subtype: PDFAnnotationSubtype,
bounds: CGRect, bounds: CGRect,
+12 -1
View File
@@ -203,6 +203,17 @@ private func pixelWindow(
return (pixelX0, pixelY0, pixelX1, pixelY1) return (pixelX0, pixelY0, pixelX1, pixelY1)
} }
@Test("fileName uses compose time, not session.createdAt, and matches Redline-yyyyMMdd-HHmmss.pdf")
func fileNameUsesComposeTimeNotSessionCreatedAt() {
let old = Date(timeIntervalSince1970: 1_600_000_000) // 2020-09-13
let session = makeSession(captures: [], createdAt: old)
let name = PDFComposer.fileName(for: session)
#expect(name.wholeMatch(of: /^Redline-\d{8}-\d{6}\.pdf$/) != nil)
#expect(!name.contains(DubaiTime.fileStamp(old)))
let today = String(DubaiTime.fileStamp(Date()).prefix(8))
#expect(name.contains(today))
}
@Test("Three-page basic compose") @Test("Three-page basic compose")
func threePageBasicCompose() throws { func threePageBasicCompose() throws {
let directory = try makeScratchDirectory() let directory = try makeScratchDirectory()
@@ -499,7 +510,7 @@ func documentAttributesRoundTrip() throws {
) )
let document = try openDocument(output) let document = try openDocument(output)
let attributes = try #require(document.documentAttributes) let attributes = try #require(document.documentAttributes)
#expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Shotdeck") #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Redline")
#expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == "Ben review — 2026-08-30") #expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == "Ben review — 2026-08-30")
#expect(attributes[PDFDocumentAttribute.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001") #expect(attributes[PDFDocumentAttribute.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001")
#expect(document.pageCount == 1) #expect(document.pageCount == 1)
@@ -109,6 +109,65 @@ func w25_duplicateNameSuffixIsRecognizedByScanNow() async throws {
#expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path) #expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
} }
@Test("W-25b Redline duplicate-name suffix is the normal AirDrop return (scanNow)")
func w25b_redlineDuplicateNameSuffixIsRecognizedByScanNow() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134205 2.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: nil,
subject: "44444444-4444-4444-4444-444444444444",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let found = try await watcher.scanNow()
#expect(found.count == 1)
let doc = try #require(found.first)
#expect(doc.fileURL.lastPathComponent == "Redline-20260830-134205 2.pdf")
#expect(doc.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
#expect(doc.pageCount == 1)
#expect(doc.annotatedPages == [1])
#expect(doc.isCommented == true)
let commented = try await ledger.commented()
#expect(commented.count == 1)
#expect(commented[0].fileURL.lastPathComponent == pdfURL.lastPathComponent)
#expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
}
@Test("W-25c Redline creator is recognized by scanNow")
func w25c_redlineCreatorIsRecognizedByScanNow() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134230.pdf")
try makePDF(
at: pdfURL,
pageCount: 1,
creator: "Redline",
annotations: [(page: 0, annotation: makeAnnotation(
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
))]
)
let found = try await watcher.scanNow()
#expect(found.count == 1)
let doc = try #require(found.first)
#expect(doc.fileURL.lastPathComponent == "Redline-20260830-134230.pdf")
#expect(doc.isCommented == true)
}
@Test("W-26 Creator provenance negative at the scan level") @Test("W-26 Creator provenance negative at the scan level")
func w26_presentNonShotdeckCreatorIsIgnoredByScanNow() async throws { func w26_presentNonShotdeckCreatorIsIgnoredByScanNow() async throws {
let (paths, cleanup) = try makeCasePaths() let (paths, cleanup) = try makeCasePaths()
+3 -2
View File
@@ -11,7 +11,7 @@ cd "$ROOT"
IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
BUNDLE_ID="ai.flowmaster.shotdeck" BUNDLE_ID="ai.flowmaster.shotdeck"
APP_BUNDLE="${ROOT}/.build/Shotdeck.app" APP_BUNDLE="${ROOT}/.build/Redline.app"
SKIP_SIGN=0 SKIP_SIGN=0
for arg in "$@"; do for arg in "$@"; do
@@ -27,7 +27,7 @@ for arg in "$@"; do
esac esac
done done
echo "==> Building Shotdeck (release)" echo "==> Building Redline (release)"
swift build -c release --product Shotdeck swift build -c release --product Shotdeck
BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck" BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck"
@@ -43,6 +43,7 @@ mkdir -p "${APP_BUNDLE}/Contents/Resources"
cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck" cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck" chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist" cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist"
cp "${ROOT}/Resources/AppIcon.icns" "${APP_BUNDLE}/Contents/Resources/AppIcon.icns"
if [[ "${SKIP_SIGN}" -eq 1 ]]; then if [[ "${SKIP_SIGN}" -eq 1 ]]; then
echo echo
+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/Redline.app"
STAGING="${ROOT}/.build/dmg-staging"
DMG="${ROOT}/.build/Redline.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 Redline.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}/Redline.app"
ln -s /Applications "${STAGING}/Applications"
echo "==> Creating ${DMG}"
mkdir -p "$(dirname "${DMG}")"
hdiutil create -volname "Redline" -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}/Redline.app" ]]; then
echo "Verification failed: Redline.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}/Redline.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}"
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env swift
import Foundation
import CoreGraphics
import ImageIO
// Programmatic Redline app icon.
// Draws a 1024×1024 master, writes AppIcon.iconset (161024 incl. @2x),
// and compiles Resources/AppIcon.icns via iconutil.
//
// Usage:
// swift scripts/make-icon.swift
// swift scripts/make-icon.swift --preview /path/to/icon-512.png
private let masterSize = 1024
private enum Palette {
static let charcoalTop = CGColor(srgbRed: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 1)
static let charcoalBottom = CGColor(srgbRed: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 1) // #1C1C1E
static let white = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
static let redline = CGColor(srgbRed: 229.0 / 255.0, green: 72.0 / 255.0, blue: 63.0 / 255.0, alpha: 1) // #E5483F
}
private struct IconsetEntry {
let filename: String
let pixels: Int
}
private let iconsetEntries: [IconsetEntry] = [
IconsetEntry(filename: "icon_16x16.png", pixels: 16),
IconsetEntry(filename: "icon_16x16@2x.png", pixels: 32),
IconsetEntry(filename: "icon_32x32.png", pixels: 32),
IconsetEntry(filename: "icon_32x32@2x.png", pixels: 64),
IconsetEntry(filename: "icon_128x128.png", pixels: 128),
IconsetEntry(filename: "icon_128x128@2x.png", pixels: 256),
IconsetEntry(filename: "icon_256x256.png", pixels: 256),
IconsetEntry(filename: "icon_256x256@2x.png", pixels: 512),
IconsetEntry(filename: "icon_512x512.png", pixels: 512),
IconsetEntry(filename: "icon_512x512@2x.png", pixels: 1024),
]
// MARK: - Geometry
/// Apple-style continuous-corner rounded square (squircle-like).
/// Superellipse |x/a|^n + |y/b|^n = 1 with n5, inset 1px so antialiased
/// edge pixels are not clipped by the bitmap.
private func continuousRoundedSquare(size: CGFloat, exponent n: CGFloat = 5.0, segments: Int = 256) -> CGPath {
let inset: CGFloat = 1
let a = (size - inset * 2) / 2
let cx = size / 2
let cy = size / 2
let twoOverN = 2 / n
let path = CGMutablePath()
for i in 0...segments {
let theta = CGFloat(i) / CGFloat(segments) * 2 * .pi
let ct = cos(theta)
let st = sin(theta)
let x = cx + (ct < 0 ? -1 : 1) * pow(abs(ct), twoOverN) * a
let y = cy + (st < 0 ? -1 : 1) * pow(abs(st), twoOverN) * a
if i == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
path.closeSubpath()
return path
}
// MARK: - Drawing
private func drawMasterIcon(size: Int) -> CGImage {
let s = CGFloat(size)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
guard let ctx = CGContext(
data: nil,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo
) else {
fputs("error: failed to create \(size)×\(size) bitmap context\n", stderr)
exit(1)
}
ctx.setShouldAntialias(true)
ctx.setAllowsAntialiasing(true)
ctx.interpolationQuality = .high
// Flip to top-left origin so "top-to-bottom gradient" is literal.
ctx.translateBy(x: 0, y: s)
ctx.scaleBy(x: 1, y: -1)
ctx.clear(CGRect(x: 0, y: 0, width: s, height: s))
let squircle = continuousRoundedSquare(size: s)
ctx.saveGState()
ctx.addPath(squircle)
ctx.clip()
let gradient = CGGradient(
colorsSpace: colorSpace,
colors: [Palette.charcoalTop, Palette.charcoalBottom] as CFArray,
locations: [0, 1]
)!
ctx.drawLinearGradient(
gradient,
start: CGPoint(x: s / 2, y: 0),
end: CGPoint(x: s / 2, y: s),
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
)
ctx.restoreGState()
// Viewfinder corner brackets: thick L-strokes, rounded caps/joins, inset ~18%.
let inset = s * 0.18
let bracketWidth = s * 0.095
let arm = s * 0.155
let centerline = inset + bracketWidth / 2
ctx.saveGState()
ctx.addPath(squircle)
ctx.clip()
ctx.setStrokeColor(Palette.white)
ctx.setLineWidth(bracketWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
func strokeBracket(cornerX: CGFloat, cornerY: CGFloat, dirX: CGFloat, dirY: CGFloat) {
let path = CGMutablePath()
path.move(to: CGPoint(x: cornerX + dirX * arm, y: cornerY))
path.addLine(to: CGPoint(x: cornerX, y: cornerY))
path.addLine(to: CGPoint(x: cornerX, y: cornerY + dirY * arm))
ctx.addPath(path)
ctx.strokePath()
}
// Top-left, top-right, bottom-left, bottom-right.
strokeBracket(cornerX: centerline, cornerY: centerline, dirX: 1, dirY: 1)
strokeBracket(cornerX: s - centerline, cornerY: centerline, dirX: -1, dirY: 1)
strokeBracket(cornerX: centerline, cornerY: s - centerline, dirX: 1, dirY: -1)
strokeBracket(cornerX: s - centerline, cornerY: s - centerline, dirX: -1, dirY: -1)
// Bold redline slash over the frame, lower-left bracket area upper-right.
let slashWidth = s * 0.078
ctx.setStrokeColor(Palette.redline)
ctx.setLineWidth(slashWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
let slashInset = centerline + arm * 0.12
let slash = CGMutablePath()
slash.move(to: CGPoint(x: slashInset, y: s - slashInset))
slash.addLine(to: CGPoint(x: s - slashInset, y: slashInset))
ctx.addPath(slash)
ctx.strokePath()
ctx.restoreGState()
guard let image = ctx.makeImage() else {
fputs("error: failed to materialize master CGImage\n", stderr)
exit(1)
}
return image
}
private func scaledImage(_ image: CGImage, pixels: Int) -> CGImage {
if image.width == pixels && image.height == pixels {
return image
}
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
guard let ctx = CGContext(
data: nil,
width: pixels,
height: pixels,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo
) else {
fputs("error: failed to create \(pixels)×\(pixels) scale context\n", stderr)
exit(1)
}
ctx.interpolationQuality = .high
ctx.setShouldAntialias(true)
ctx.draw(image, in: CGRect(x: 0, y: 0, width: pixels, height: pixels))
guard let out = ctx.makeImage() else {
fputs("error: failed to scale image to \(pixels)px\n", stderr)
exit(1)
}
return out
}
private func writePNG(_ image: CGImage, to url: URL) {
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: url.path) {
try? FileManager.default.removeItem(at: url)
}
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
fputs("error: cannot create PNG destination at \(url.path)\n", stderr)
exit(1)
}
CGImageDestinationAddImage(dest, image, nil)
if !CGImageDestinationFinalize(dest) {
fputs("error: failed to write PNG \(url.path)\n", stderr)
exit(1)
}
}
// MARK: - Paths / CLI
private func repoRoot() -> URL {
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
let arg0 = URL(fileURLWithPath: CommandLine.arguments[0])
let scriptURL: URL
if arg0.path.hasPrefix("/") {
scriptURL = arg0.standardizedFileURL
} else {
scriptURL = cwd.appendingPathComponent(arg0.path).standardizedFileURL
}
let fromScript = scriptURL.deletingLastPathComponent().deletingLastPathComponent()
if FileManager.default.fileExists(atPath: fromScript.appendingPathComponent("Package.swift").path) {
return fromScript
}
if FileManager.default.fileExists(atPath: cwd.appendingPathComponent("Package.swift").path) {
return cwd
}
fputs("error: could not find repo root (Package.swift)\n", stderr)
exit(1)
}
private func parsePreviewPath() -> String? {
let args = CommandLine.arguments
var i = 1
var preview: String?
while i < args.count {
let arg = args[i]
if arg == "--preview" {
i += 1
guard i < args.count else {
fputs("error: --preview requires a path\n", stderr)
exit(1)
}
preview = args[i]
} else if arg.hasPrefix("--preview=") {
preview = String(arg.dropFirst("--preview=".count))
} else if arg == "--help" || arg == "-h" {
fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr)
exit(0)
} else {
fputs("error: unknown argument \(arg)\n", stderr)
fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr)
exit(1)
}
i += 1
}
return preview
}
private func runIconutil(iconset: URL, icns: URL) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
proc.arguments = ["-c", "icns", iconset.path, "-o", icns.path]
proc.standardOutput = FileHandle.standardOutput
proc.standardError = FileHandle.standardError
do {
try proc.run()
proc.waitUntilExit()
} catch {
fputs("error: failed to launch iconutil: \(error)\n", stderr)
exit(1)
}
if proc.terminationStatus != 0 {
fputs("error: iconutil exited \(proc.terminationStatus)\n", stderr)
exit(1)
}
}
// MARK: - Main
let root = repoRoot()
let resources = root.appendingPathComponent("Resources", isDirectory: true)
let icnsURL = resources.appendingPathComponent("AppIcon.icns")
let previewPath = parsePreviewPath()
let fm = FileManager.default
print("==> Drawing \(masterSize)×\(masterSize) master")
let master = drawMasterIcon(size: masterSize)
let iconset = fm.temporaryDirectory.appendingPathComponent("Redline-AppIcon-\(UUID().uuidString).iconset", isDirectory: true)
do {
try fm.createDirectory(at: iconset, withIntermediateDirectories: true)
try fm.createDirectory(at: resources, withIntermediateDirectories: true)
print("==> Writing AppIcon.iconset")
for entry in iconsetEntries {
let img = scaledImage(master, pixels: entry.pixels)
writePNG(img, to: iconset.appendingPathComponent(entry.filename))
}
if fm.fileExists(atPath: icnsURL.path) {
try fm.removeItem(at: icnsURL)
}
print("==> Compiling \(icnsURL.path)")
runIconutil(iconset: iconset, icns: icnsURL)
try? fm.removeItem(at: iconset)
} catch {
try? fm.removeItem(at: iconset)
fputs("error: \(error)\n", stderr)
exit(1)
}
if let previewPath {
let previewURL = URL(fileURLWithPath: previewPath)
print("==> Writing 512px preview \(previewURL.path)")
writePNG(scaledImage(master, pixels: 512), to: previewURL)
}
print("App icon: \(icnsURL.path)")
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail
# One-command Redline release: bump Info.plist, commit, signed build, zip,
# DMG, appcast, upload to mmd01, verify the public URLs.
# Hidden flag: --test — upload under .../redline/test/ and skip the git commit.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT}"
PLIST="${ROOT}/Info.plist"
PLISTBUDDY="/usr/libexec/PlistBuddy"
REMOTE_HOST="mmd01"
REMOTE_BASE="/opt/mmd-installer-content/cowork/redline"
PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline"
SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
usage() {
echo "Usage: $0 <version> [\"notes\"]" >&2
exit 1
}
TEST_MODE=0
VERSION=""
NOTES=""
NOTES_SET=0
for arg in "$@"; do
case "${arg}" in
--test)
TEST_MODE=1
;;
--help|-h)
usage
;;
--*)
echo "Unknown argument: ${arg}" >&2
usage
;;
*)
if [[ -z "${VERSION}" ]]; then
VERSION="${arg}"
elif [[ "${NOTES_SET}" -eq 0 ]]; then
NOTES="${arg}"
NOTES_SET=1
else
echo "Unexpected extra argument: ${arg}" >&2
usage
fi
;;
esac
done
if [[ -z "${VERSION}" ]]; then
usage
fi
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then
echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2
exit 1
fi
if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then
echo "Version contains illegal path characters: ${VERSION}" >&2
exit 1
fi
if [[ "${TEST_MODE}" -eq 1 ]]; then
REMOTE_DIR="${REMOTE_BASE}/test"
PUBLIC_DIR="${PUBLIC_BASE}/test"
else
REMOTE_DIR="${REMOTE_BASE}"
PUBLIC_DIR="${PUBLIC_BASE}"
fi
ZIP_NAME="Redline-${VERSION}.zip"
DMG_NAME="Redline-${VERSION}.dmg"
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
DMG_PATH="${ROOT}/.build/Redline.dmg"
APPCAST_PATH="${ROOT}/.build/appcast.json"
ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}"
APPCAST_URL="${PUBLIC_DIR}/appcast.json"
if [[ "${TEST_MODE}" -eq 1 ]]; then
echo "==> Publish Redline ${VERSION} (test)"
else
echo "==> Publish Redline ${VERSION}"
fi
echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/"
echo " public: ${PUBLIC_DIR}/"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Not inside a git work tree." >&2
exit 1
fi
# Tracked files must match HEAD. Untracked files are ignored so this script
# can be dry-run (--test) before it is itself committed.
if [[ -n "$(git status --porcelain -uno)" ]]; then
echo "git tree is not clean; commit or stash before publishing." >&2
git status --porcelain -uno >&2
exit 1
fi
if [[ ! -x "${PLISTBUDDY}" ]]; then
echo "PlistBuddy not found at ${PLISTBUDDY}" >&2
exit 1
fi
if [[ ! -f "${PLIST}" ]]; then
echo "Info.plist not found at ${PLIST}" >&2
exit 1
fi
restore_plist() {
git checkout -- "${PLIST}" >/dev/null 2>&1 || true
}
if [[ "${TEST_MODE}" -eq 1 ]]; then
trap restore_plist EXIT
fi
CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")"
if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then
echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2
exit 1
fi
NEW_BUILD=$((CURRENT_BUILD + 1))
echo "==> Bumping Info.plist"
echo " CFBundleShortVersionString -> ${VERSION}"
echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}"
"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}"
"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}"
if [[ "${TEST_MODE}" -eq 0 ]]; then
echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)"
git add "${PLIST}"
git commit -m "release: v${VERSION}"
else
echo "==> --test: skipping git commit of version bump"
fi
# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign.
# Re-run signed steps with the account's real home when the identity is missing.
signing_home() {
if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then
echo "${HOME}"
return
fi
local rh
rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
if [[ -n "${rh}" && -d "${rh}" ]]; then
echo "${rh}"
else
echo "${HOME}"
fi
}
run_signed() {
local sign_home
sign_home="$(signing_home)"
if [[ "${sign_home}" != "${HOME}" ]]; then
echo "==> Using HOME=${sign_home} so codesign can see the login keychain"
fi
HOME="${sign_home}" "$@"
}
echo "==> Building signed Redline.app"
run_signed ./scripts/build-app.sh
if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then
echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2
exit 1
fi
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
mkdir -p "${ROOT}/.build"
(
cd "${ROOT}/.build"
rm -f "${ZIP_NAME}"
ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
)
if [[ ! -s "${ZIP_PATH}" ]]; then
echo "Zip was not created at ${ZIP_PATH}" >&2
exit 1
fi
echo "==> Building manual installer DMG"
run_signed ./scripts/make-dmg.sh
if [[ ! -s "${DMG_PATH}" ]]; then
echo "DMG was not created at ${DMG_PATH}" >&2
exit 1
fi
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "==> Zip SHA256: ${SHA256}"
echo " Zip bytes: ${ZIP_BYTES}"
echo "==> Writing ${APPCAST_PATH}"
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY'
import json
import sys
version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:]
payload = {
"version": version,
"zipURL": zip_url,
"sha256": sha256,
"notes": notes,
"pubDate": pub_date,
}
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
fh.write("\n")
PY
echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/"
ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'"
rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}"
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg"
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}"
rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json"
ssh -o BatchMode=yes "${REMOTE_HOST}" \
"chmod 644 \
'${REMOTE_DIR}/${ZIP_NAME}' \
'${REMOTE_DIR}/Redline.dmg' \
'${REMOTE_DIR}/${DMG_NAME}' \
'${REMOTE_DIR}/appcast.json'"
echo "==> Verifying public appcast ${APPCAST_URL}"
APPCAST_BODY=""
ok=0
attempt=1
while [[ "${attempt}" -le 15 ]]; do
if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then
echo "${APPCAST_BODY}"
if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then
echo "OK: appcast contains ${VERSION}"
ok=1
break
fi
echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2
else
echo "appcast fetch failed (attempt ${attempt})" >&2
fi
attempt=$((attempt + 1))
sleep 2
done
if [[ "${ok}" -ne 1 ]]; then
echo "Public appcast verification failed for ${APPCAST_URL}" >&2
exit 1
fi
echo "==> Verifying public zip HEAD ${ZIP_URL}"
ok=0
attempt=1
HEAD_OUT=""
while [[ "${attempt}" -le 15 ]]; do
HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)"
echo "${HEAD_OUT}"
HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')"
CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)"
if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then
echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}"
ok=1
break
fi
echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2
attempt=$((attempt + 1))
sleep 2
done
if [[ "${ok}" -ne 1 ]]; then
echo "Public zip verification failed for ${ZIP_URL}" >&2
exit 1
fi
echo
echo "Published v${VERSION}"
echo " appcast: ${APPCAST_URL}"
echo " zip: ${ZIP_URL}"
echo " sha256: ${SHA256}"
echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}"
echo " dmg: ${PUBLIC_DIR}/Redline.dmg"