Compare commits

...
Author SHA1 Message Date
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 c54471ee2e installer: make-dmg.sh — signed app DMG with /Applications symlink 2026-09-01 12:55:22 +04:00
21 changed files with 907 additions and 62 deletions
+7 -1
View File
@@ -2,10 +2,14 @@
<!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>
<!-- Identity invariants: CFBundleIdentifier stays ai.flowmaster.shotdeck and
CFBundleExecutable stays Shotdeck. Changing either one invalidates the
user's existing Screen Recording grant. CFBundleName is the user-facing
product name only. -->
<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> <key>CFBundleExecutable</key>
<string>Shotdeck</string> <string>Shotdeck</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
@@ -14,6 +18,8 @@
<string>0.1.0</string> <string>0.1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
+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.
+9 -1
View File
@@ -72,7 +72,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()
@@ -237,6 +237,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) {
+1 -1
View File
@@ -128,7 +128,7 @@ struct MenuBarView: View {
Button { Button {
NSApp.terminate(nil) NSApp.terminate(nil)
} label: { } label: {
actionLabel("Quit Shotdeck") actionLabel("Quit Redline")
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+215
View File
@@ -0,0 +1,215 @@
import AppKit
import Darwin
import Foundation
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()
exit(0)
}
/// 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)
}
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)
}
}
+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)
+1 -1
View File
@@ -18,7 +18,7 @@ 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 { 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
+65 -25
View File
@@ -5,39 +5,79 @@ import ShotdeckCore
struct SettingsView: View { struct SettingsView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
private let labelWidth: CGFloat = 104
var body: some View { var body: some View {
Form { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
Section("Hotkey") { GridRow {
LabeledContent("Capture", value: "⌥⇧2 — fixed in this version") Text("Hotkey")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
} }
Section("Folders") {
LabeledContent("Watch folder") { GridRow(alignment: .firstTextBaseline) {
HStack(spacing: 8) { fieldLabel("Capture")
Text(model.watchFolderURL.path) Text("⌥⇧2 — fixed in this version")
.lineLimit(1) .foregroundStyle(.secondary)
.truncationMode(.middle) .lineLimit(1)
.foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { model.chooseWatchFolder() } .frame(minHeight: 22, alignment: .leading)
} }
}
LabeledContent("Output folder") { GridRow {
HStack(spacing: 8) { Text("Folders")
Text(model.outboxURL.path) .font(.headline)
.lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading)
.truncationMode(.middle) .gridCellColumns(2)
.foregroundStyle(.secondary) .padding(.top, 6)
Button("Choose…") { model.chooseOutboxFolder() } }
}
GridRow(alignment: .center) {
fieldLabel("Watch folder")
folderValue(path: model.watchFolderURL.path) {
model.chooseWatchFolder()
} }
} }
Section {
GridRow(alignment: .center) {
fieldLabel("Output folder")
folderValue(path: model.outboxURL.path) {
model.chooseOutboxFolder()
}
}
GridRow {
Button("Reveal spool folder") { model.openSpoolFolder() } Button("Reveal spool folder") { model.openSpoolFolder() }
.gridCellColumns(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
} }
} }
.padding() .padding(16)
.frame(width: 360) .frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading)
.controlSize(.small) .controlSize(.small)
} }
private func fieldLabel(_ title: String) -> some View {
Text(title)
.lineLimit(1)
.frame(width: labelWidth, alignment: .trailing)
.gridColumnAlignment(.trailing)
.frame(minHeight: 22, alignment: .trailing)
}
private func folderValue(path: String, choose: @escaping () -> Void) -> some View {
HStack(spacing: 8) {
Text(path)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { choose() }
}
.frame(minHeight: 22)
}
} }
extension AppModel: SettingsWindowPresenting { extension AppModel: SettingsWindowPresenting {
@@ -51,7 +91,7 @@ extension AppModel: SettingsWindowPresenting {
} }
let hosting = NSHostingController(rootView: SettingsView().environment(self)) let hosting = NSHostingController(rootView: SettingsView().environment(self))
let window = NSWindow(contentViewController: hosting) let window = NSWindow(contentViewController: hosting)
window.title = "Shotdeck Settings" window.title = "Redline Settings"
window.styleMask = [.titled, .closable] window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.center() window.center()
+5 -2
View File
@@ -7,6 +7,9 @@ import ShotdeckCore
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } 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 {
@@ -24,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)
} }
@@ -57,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):
+2 -2
View File
@@ -71,7 +71,7 @@ public struct PDFComposer: Sendable {
} }
public static func fileName(for session: CaptureSession) -> String { public static func fileName(for session: CaptureSession) -> String {
"Shotdeck-\(DubaiTime.fileStamp(session.createdAt)).pdf" "Redline-\(DubaiTime.fileStamp(session.createdAt)).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,
@@ -499,7 +499,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)")