WP-0: SwiftPM scaffold, shared model, signed app bundle script
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
Packages/
|
||||
*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
.netrc
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.flowmaster.shotdeck</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version: 6.0
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Shotdeck",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "ShotdeckCore", targets: ["ShotdeckCore"]),
|
||||
.executable(name: "Shotdeck", targets: ["Shotdeck"]),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ShotdeckCore",
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "Shotdeck",
|
||||
dependencies: ["ShotdeckCore"],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ShotdeckCoreTests",
|
||||
dependencies: ["ShotdeckCore"],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -1,3 +1,22 @@
|
||||
# shotdeck
|
||||
# Shotdeck
|
||||
|
||||
macOS menu-bar app: hotkey region capture into a review PDF with PASS/FAIL boxes, AirDrop send, annotation-return tracking
|
||||
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.
|
||||
|
||||
## Hotkeys
|
||||
|
||||
- **⌥⇧1** — pick a new region, then capture it.
|
||||
- **⌥⇧2** — capture the remembered region instantly (no picker).
|
||||
|
||||
## Screen Recording permission
|
||||
|
||||
On first launch macOS asks once for Screen Recording. Grant it at **System Settings > Privacy & Security > Screen Recording**. You never have to do this again as long as the app is not re-signed with a different identity.
|
||||
|
||||
The grant is bound to the bundle identifier `ai.flowmaster.shotdeck` plus the code signature. Changing either one forces a fresh prompt.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
swift build && swift test
|
||||
./scripts/build-app.sh # signed .app for daily use
|
||||
open .build/Shotdeck.app
|
||||
```
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
// WP-4 replaces this body with the real menu-bar UI.
|
||||
let application = NSApplication.shared
|
||||
application.setActivationPolicy(.accessory)
|
||||
application.run()
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// One screenshot plus everything needed to place it on a PDF page.
|
||||
public struct Capture: Codable, Sendable, Identifiable, Equatable {
|
||||
public let id: UUID
|
||||
/// 1-based position within its session. Stable; never renumbered on delete.
|
||||
public let sequence: Int
|
||||
/// File name only (e.g. "001-A1B2C3D4.png"), never a path.
|
||||
/// The directory is always the owning session's directory.
|
||||
public let fileName: String
|
||||
/// Pixel dimensions of the PNG on disk.
|
||||
public let pixelWidth: Int
|
||||
public let pixelHeight: Int
|
||||
/// Backing scale the capture was taken at (2.0 on Retina). Needed so the PDF
|
||||
/// composer lays the image out at its true point size, not its pixel size.
|
||||
public let scale: CGFloat
|
||||
/// When the screenshot was taken. Always stored as an absolute instant;
|
||||
/// rendered in Asia/Dubai wherever a human sees it.
|
||||
public let capturedAt: Date
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
sequence: Int,
|
||||
fileName: String,
|
||||
pixelWidth: Int,
|
||||
pixelHeight: Int,
|
||||
scale: CGFloat,
|
||||
capturedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.sequence = sequence
|
||||
self.fileName = fileName
|
||||
self.pixelWidth = pixelWidth
|
||||
self.pixelHeight = pixelHeight
|
||||
self.scale = scale
|
||||
self.capturedAt = capturedAt
|
||||
}
|
||||
|
||||
/// True when the image is wider than it is tall. Drives page orientation.
|
||||
public var isLandscape: Bool {
|
||||
pixelWidth > pixelHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
public enum SessionState: String, Codable, Sendable {
|
||||
case open // accepting captures
|
||||
case archived // its PDF has been built and sent; kept forever, never deleted
|
||||
}
|
||||
|
||||
/// The manifest persisted as session.json alongside the PNGs.
|
||||
public struct CaptureSession: Codable, Sendable, Identifiable, Equatable {
|
||||
public let id: UUID
|
||||
public let createdAt: Date
|
||||
public private(set) var state: SessionState
|
||||
public private(set) var captures: [Capture]
|
||||
/// Set when the PDF is built, so a re-send reuses the same file.
|
||||
public private(set) var pdfFileName: String?
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
createdAt: Date,
|
||||
state: SessionState,
|
||||
captures: [Capture],
|
||||
pdfFileName: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.createdAt = createdAt
|
||||
self.state = state
|
||||
self.captures = captures
|
||||
self.pdfFileName = pdfFileName
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
captures.isEmpty
|
||||
}
|
||||
|
||||
/// max(sequence)+1, or 1 when empty.
|
||||
public var nextSequence: Int {
|
||||
(captures.map(\.sequence).max() ?? 0) + 1
|
||||
}
|
||||
|
||||
/// Value-semantic: returns a new session; does not mutate `self`.
|
||||
public func appending(_ capture: Capture) -> CaptureSession {
|
||||
CaptureSession(
|
||||
id: id,
|
||||
createdAt: createdAt,
|
||||
state: state,
|
||||
captures: captures + [capture],
|
||||
pdfFileName: pdfFileName
|
||||
)
|
||||
}
|
||||
|
||||
/// Value-semantic: returns a new session; does not mutate `self`.
|
||||
/// Sequence numbers of remaining captures are left unchanged.
|
||||
public func removing(captureID: UUID) -> CaptureSession {
|
||||
CaptureSession(
|
||||
id: id,
|
||||
createdAt: createdAt,
|
||||
state: state,
|
||||
captures: captures.filter { $0.id != captureID },
|
||||
pdfFileName: pdfFileName
|
||||
)
|
||||
}
|
||||
|
||||
/// Value-semantic: returns a new session; does not mutate `self`.
|
||||
public func markArchived(pdfFileName: String) -> CaptureSession {
|
||||
CaptureSession(
|
||||
id: id,
|
||||
createdAt: createdAt,
|
||||
state: .archived,
|
||||
captures: captures,
|
||||
pdfFileName: pdfFileName
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
public enum ShotdeckError: Error, LocalizedError, Sendable {
|
||||
case screenRecordingNotGranted
|
||||
case noRegionRemembered
|
||||
case displayNoLongerConnected(displayID: UInt32)
|
||||
case captureFailed(underlying: String)
|
||||
case spoolWriteFailed(path: String, underlying: String)
|
||||
case manifestCorrupt(path: String)
|
||||
case pdfCompositionFailed(reason: String)
|
||||
case airDropUnavailable
|
||||
case noCommentedReturns
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .screenRecordingNotGranted:
|
||||
return "Screen Recording is turned off. Grant it in System Settings to capture."
|
||||
case .noRegionRemembered:
|
||||
return "No capture region is set. Press ⌥⇧1 to pick one."
|
||||
case .displayNoLongerConnected:
|
||||
return "The display used for capture is no longer connected."
|
||||
case .captureFailed(let underlying):
|
||||
return "The screenshot could not be taken. \(underlying)"
|
||||
case .spoolWriteFailed(_, let underlying):
|
||||
return "The screenshot could not be saved. \(underlying)"
|
||||
case .manifestCorrupt:
|
||||
return "This session's file is damaged and cannot be opened."
|
||||
case .pdfCompositionFailed(let reason):
|
||||
return "The PDF could not be built. \(reason)"
|
||||
case .airDropUnavailable:
|
||||
return "AirDrop is not available right now."
|
||||
case .noCommentedReturns:
|
||||
return "None of the returned PDFs have comments on them."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// The only type in the app that knows the on-disk directory layout.
|
||||
public struct AppSupportPaths: Sendable {
|
||||
public let root: URL
|
||||
public let spool: URL
|
||||
public let archive: URL
|
||||
public let outbox: URL
|
||||
public let watchFolder: URL
|
||||
|
||||
/// Production paths.
|
||||
public static func standard() throws -> AppSupportPaths {
|
||||
let fileManager = FileManager.default
|
||||
let appSupportParent = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let desktop = try fileManager.url(
|
||||
for: .desktopDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let downloads = try fileManager.url(
|
||||
for: .downloadsDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let root = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true)
|
||||
return try AppSupportPaths(root: root, outbox: desktop, watchFolder: downloads)
|
||||
}
|
||||
|
||||
/// Test paths rooted anywhere. Every directory is created if missing.
|
||||
public init(root: URL, outbox: URL, watchFolder: URL) throws {
|
||||
self.root = root
|
||||
self.spool = root.appendingPathComponent("spool", isDirectory: true)
|
||||
self.archive = root.appendingPathComponent("archive", isDirectory: true)
|
||||
self.outbox = outbox
|
||||
self.watchFolder = watchFolder
|
||||
|
||||
try Self.createDirectory(self.root)
|
||||
try Self.createDirectory(self.spool)
|
||||
try Self.createDirectory(self.archive)
|
||||
try Self.createDirectory(self.outbox)
|
||||
try Self.createDirectory(self.watchFolder)
|
||||
}
|
||||
|
||||
public func sessionDirectory(_ id: UUID) -> URL {
|
||||
spool.appendingPathComponent(id.uuidString, isDirectory: true)
|
||||
}
|
||||
|
||||
public func archiveDirectory(_ id: UUID) -> URL {
|
||||
archive.appendingPathComponent(id.uuidString, isDirectory: true)
|
||||
}
|
||||
|
||||
private static func createDirectory(_ url: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public enum DubaiTime {
|
||||
private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'")
|
||||
private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss")
|
||||
|
||||
public static func stamp(_ date: Date) -> String {
|
||||
stampFormatter.string(from: date)
|
||||
}
|
||||
|
||||
public static func fileStamp(_ date: Date) -> String {
|
||||
fileStampFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// DateFormatter is not Sendable. This holder is the only shared mutable state
|
||||
/// around a formatter: every read is serialized by the lock, so concurrent
|
||||
/// calls (one per PDF page) cannot race.
|
||||
private final class LockedDateFormatter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let formatter: DateFormatter
|
||||
|
||||
init(dateFormat: String) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_GB")
|
||||
guard let dubai = TimeZone(identifier: "Asia/Dubai") else {
|
||||
preconditionFailure("The Asia/Dubai time zone is missing from this system.")
|
||||
}
|
||||
formatter.timeZone = dubai
|
||||
formatter.dateFormat = dateFormat
|
||||
self.formatter = formatter
|
||||
}
|
||||
|
||||
func string(from date: Date) -> String {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import ShotdeckCore
|
||||
|
||||
@Test
|
||||
func appSupportPathsCreatesEveryNamedDirectory() throws {
|
||||
let temporaryRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("shotdeck-paths-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: temporaryRoot) }
|
||||
|
||||
let root = temporaryRoot.appendingPathComponent("root", isDirectory: true)
|
||||
let outbox = temporaryRoot.appendingPathComponent("outbox", isDirectory: true)
|
||||
let watchFolder = temporaryRoot.appendingPathComponent("watch", isDirectory: true)
|
||||
|
||||
let paths = try AppSupportPaths(root: root, outbox: outbox, watchFolder: watchFolder)
|
||||
|
||||
#expect(directoryExists(paths.root))
|
||||
#expect(directoryExists(paths.spool))
|
||||
#expect(directoryExists(paths.archive))
|
||||
#expect(directoryExists(paths.outbox))
|
||||
#expect(directoryExists(paths.watchFolder))
|
||||
|
||||
#expect(paths.spool.path == root.appendingPathComponent("spool", isDirectory: true).path)
|
||||
#expect(paths.archive.path == root.appendingPathComponent("archive", isDirectory: true).path)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sessionAndArchiveDirectoriesAreDistinctUnderTheRightParents() throws {
|
||||
let temporaryRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("shotdeck-dirs-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: temporaryRoot) }
|
||||
|
||||
let paths = try AppSupportPaths(
|
||||
root: temporaryRoot.appendingPathComponent("root", isDirectory: true),
|
||||
outbox: temporaryRoot.appendingPathComponent("outbox", isDirectory: true),
|
||||
watchFolder: temporaryRoot.appendingPathComponent("watch", isDirectory: true)
|
||||
)
|
||||
|
||||
let sessionID = UUID()
|
||||
let sessionDirectory = paths.sessionDirectory(sessionID)
|
||||
let archiveDirectory = paths.archiveDirectory(sessionID)
|
||||
|
||||
#expect(sessionDirectory.path != archiveDirectory.path)
|
||||
#expect(sessionDirectory.deletingLastPathComponent().path == paths.spool.path)
|
||||
#expect(archiveDirectory.deletingLastPathComponent().path == paths.archive.path)
|
||||
#expect(sessionDirectory.lastPathComponent == sessionID.uuidString)
|
||||
#expect(archiveDirectory.lastPathComponent == sessionID.uuidString)
|
||||
}
|
||||
|
||||
@Test
|
||||
func dubaiTimeStampRendersAKnownInstantInAsiaDubai() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
let dubai = try #require(TimeZone(identifier: "Asia/Dubai"))
|
||||
calendar.timeZone = dubai
|
||||
|
||||
let date = try #require(
|
||||
calendar.date(from: DateComponents(
|
||||
year: 2026,
|
||||
month: 8,
|
||||
day: 30,
|
||||
hour: 13,
|
||||
minute: 42,
|
||||
second: 5
|
||||
))
|
||||
)
|
||||
|
||||
#expect(DubaiTime.stamp(date) == "30 Aug 2026, 13:42 Dubai")
|
||||
#expect(DubaiTime.fileStamp(date) == "20260830-134205")
|
||||
}
|
||||
|
||||
private func directoryExists(_ url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
||||
return exists && isDirectory.boolValue
|
||||
}
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# macOS keys the Screen Recording permission to the bundle identifier plus the
|
||||
# code signature. The identity below and --identifier ai.flowmaster.shotdeck must
|
||||
# never change: altering either one makes the existing grant invalid and forces
|
||||
# the user to approve Screen Recording again by hand.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
|
||||
BUNDLE_ID="ai.flowmaster.shotdeck"
|
||||
APP_BUNDLE="${ROOT}/.build/Shotdeck.app"
|
||||
|
||||
SKIP_SIGN=0
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--skip-sign)
|
||||
SKIP_SIGN=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: ${arg}" >&2
|
||||
echo "Usage: $0 [--skip-sign]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Building Shotdeck (release)"
|
||||
swift build -c release --product Shotdeck
|
||||
|
||||
BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck"
|
||||
if [[ ! -x "${BIN_PATH}" ]]; then
|
||||
echo "Release binary not found at ${BIN_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Assembling ${APP_BUNDLE}"
|
||||
rm -rf "${APP_BUNDLE}"
|
||||
mkdir -p "${APP_BUNDLE}/Contents/MacOS"
|
||||
mkdir -p "${APP_BUNDLE}/Contents/Resources"
|
||||
cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
|
||||
chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
|
||||
cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist"
|
||||
|
||||
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
|
||||
echo
|
||||
echo "************************************************************************"
|
||||
echo "WARNING: --skip-sign was used. This app is UNSIGNED."
|
||||
echo "The resulting app will trigger a fresh Screen Recording prompt and must"
|
||||
echo "not be handed to the user."
|
||||
echo "************************************************************************"
|
||||
echo
|
||||
else
|
||||
if ! security find-identity -v -p codesigning | grep -Fq "${IDENTITY}"; then
|
||||
echo "The codesigning identity '${IDENTITY}' is not in this shell's keychain search list." >&2
|
||||
echo "The login keychain is not reachable from this shell." >&2
|
||||
echo "Run this script from a normal login session so the app can be signed." >&2
|
||||
echo "Refusing to produce an unsigned app." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Signing ${APP_BUNDLE}"
|
||||
codesign --force --options runtime \
|
||||
--sign "${IDENTITY}" \
|
||||
--identifier "${BUNDLE_ID}" \
|
||||
"${APP_BUNDLE}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "App path: ${APP_BUNDLE}"
|
||||
echo "Launch with: open ${APP_BUNDLE}"
|
||||
Reference in New Issue
Block a user