diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a383133 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.build/ +.swiftpm/ +Packages/ +*.xcodeproj +xcuserdata/ +DerivedData/ +.DS_Store +.netrc diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..eb05ed5 --- /dev/null +++ b/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleIdentifier + ai.flowmaster.shotdeck + CFBundleName + Shotdeck + CFBundleExecutable + Shotdeck + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSUIElement + + LSMinimumSystemVersion + 14.0 + NSHumanReadableCopyright + Copyright © 2026 Flowmaster FZC LLC. All rights reserved. + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e8361c4 --- /dev/null +++ b/Package.swift @@ -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)] + ), + ] +) diff --git a/README.md b/README.md index 3c0ab7f..9158292 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +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 +``` diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift new file mode 100644 index 0000000..0cba481 --- /dev/null +++ b/Sources/Shotdeck/main.swift @@ -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() diff --git a/Sources/ShotdeckCore/Model/Capture.swift b/Sources/ShotdeckCore/Model/Capture.swift new file mode 100644 index 0000000..15eea33 --- /dev/null +++ b/Sources/ShotdeckCore/Model/Capture.swift @@ -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 + } +} diff --git a/Sources/ShotdeckCore/Model/CaptureSession.swift b/Sources/ShotdeckCore/Model/CaptureSession.swift new file mode 100644 index 0000000..c12644e --- /dev/null +++ b/Sources/ShotdeckCore/Model/CaptureSession.swift @@ -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 + ) + } +} diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift new file mode 100644 index 0000000..77a19ee --- /dev/null +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -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." + } + } +} diff --git a/Sources/ShotdeckCore/Support/AppSupportPaths.swift b/Sources/ShotdeckCore/Support/AppSupportPaths.swift new file mode 100644 index 0000000..7f1dccf --- /dev/null +++ b/Sources/ShotdeckCore/Support/AppSupportPaths.swift @@ -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 + ) + } +} diff --git a/Sources/ShotdeckCore/Support/DubaiTime.swift b/Sources/ShotdeckCore/Support/DubaiTime.swift new file mode 100644 index 0000000..77313ac --- /dev/null +++ b/Sources/ShotdeckCore/Support/DubaiTime.swift @@ -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) + } +} diff --git a/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift b/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift new file mode 100644 index 0000000..3576430 --- /dev/null +++ b/Tests/ShotdeckCoreTests/AppSupportPathsTests.swift @@ -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 +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..b96899c --- /dev/null +++ b/scripts/build-app.sh @@ -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}"