Redline v0.2.0 — complete app (integration branch → main) #22
@@ -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