Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52e68a9d5 | ||
|
|
3c55be174a | ||
|
|
d05bd735b5 | ||
|
|
74606e5046 | ||
|
|
517a4e4fdc | ||
|
|
6c0b6e3068 | ||
|
|
f2088bbed8 | ||
|
|
31013802d7 |
+7
-1
@@ -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>
|
||||||
|
|||||||
@@ -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.
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -91,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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+8
-8
@@ -4,9 +4,9 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
APP_BUNDLE="${ROOT}/.build/Shotdeck.app"
|
APP_BUNDLE="${ROOT}/.build/Redline.app"
|
||||||
STAGING="${ROOT}/.build/dmg-staging"
|
STAGING="${ROOT}/.build/dmg-staging"
|
||||||
DMG="${ROOT}/.build/Shotdeck.dmg"
|
DMG="${ROOT}/.build/Redline.dmg"
|
||||||
MOUNT_POINT="${ROOT}/.build/dmg-mnt"
|
MOUNT_POINT="${ROOT}/.build/dmg-mnt"
|
||||||
|
|
||||||
SKIP_SIGN=0
|
SKIP_SIGN=0
|
||||||
@@ -23,7 +23,7 @@ for arg in "$@"; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> Building Shotdeck.app"
|
echo "==> Building Redline.app"
|
||||||
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
|
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
|
||||||
./scripts/build-app.sh --skip-sign
|
./scripts/build-app.sh --skip-sign
|
||||||
else
|
else
|
||||||
@@ -38,12 +38,12 @@ fi
|
|||||||
echo "==> Staging DMG contents"
|
echo "==> Staging DMG contents"
|
||||||
rm -rf "${STAGING}"
|
rm -rf "${STAGING}"
|
||||||
mkdir -p "${STAGING}"
|
mkdir -p "${STAGING}"
|
||||||
ditto "${APP_BUNDLE}" "${STAGING}/Shotdeck.app"
|
ditto "${APP_BUNDLE}" "${STAGING}/Redline.app"
|
||||||
ln -s /Applications "${STAGING}/Applications"
|
ln -s /Applications "${STAGING}/Applications"
|
||||||
|
|
||||||
echo "==> Creating ${DMG}"
|
echo "==> Creating ${DMG}"
|
||||||
mkdir -p "$(dirname "${DMG}")"
|
mkdir -p "$(dirname "${DMG}")"
|
||||||
hdiutil create -volname "Shotdeck" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}"
|
hdiutil create -volname "Redline" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}"
|
||||||
|
|
||||||
MOUNTED=0
|
MOUNTED=0
|
||||||
detach_dmg() {
|
detach_dmg() {
|
||||||
@@ -67,8 +67,8 @@ MOUNTED=1
|
|||||||
echo "==> Mount contents"
|
echo "==> Mount contents"
|
||||||
ls -la "${MOUNT_POINT}"
|
ls -la "${MOUNT_POINT}"
|
||||||
|
|
||||||
if [[ ! -d "${MOUNT_POINT}/Shotdeck.app" ]]; then
|
if [[ ! -d "${MOUNT_POINT}/Redline.app" ]]; then
|
||||||
echo "Verification failed: Shotdeck.app missing from mounted DMG" >&2
|
echo "Verification failed: Redline.app missing from mounted DMG" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then
|
if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then
|
||||||
@@ -81,7 +81,7 @@ if [[ "$(readlink "${MOUNT_POINT}/Applications")" != "/Applications" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> codesign --verify --deep"
|
echo "==> codesign --verify --deep"
|
||||||
codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Shotdeck.app"
|
codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Redline.app"
|
||||||
|
|
||||||
echo "==> Detaching ${MOUNT_POINT}"
|
echo "==> Detaching ${MOUNT_POINT}"
|
||||||
hdiutil detach "${MOUNT_POINT}"
|
hdiutil detach "${MOUNT_POINT}"
|
||||||
|
|||||||
@@ -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 (16…1024 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 n≈5, 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)")
|
||||||
Reference in New Issue
Block a user