rename: user-facing product name Shotdeck -> Redline; legacy PDFs still recognized
This commit is contained in:
+5
-1
@@ -2,10 +2,14 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<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>
|
||||
<string>ai.flowmaster.shotdeck</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Shotdeck</string>
|
||||
<string>Redline</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundlePackageType</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.
|
||||
|
||||
@@ -18,5 +18,5 @@ The grant is bound to the bundle identifier `ai.flowmaster.shotdeck` plus the co
|
||||
```bash
|
||||
swift build && swift test
|
||||
./scripts/build-app.sh # signed .app for daily use
|
||||
open .build/Shotdeck.app
|
||||
open .build/Redline.app
|
||||
```
|
||||
|
||||
@@ -128,7 +128,7 @@ struct MenuBarView: View {
|
||||
Button {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
actionLabel("Quit Shotdeck")
|
||||
actionLabel("Quit Redline")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -18,7 +18,7 @@ extension AppModel: SendCapable {
|
||||
let finalURL = outboxDir.appendingPathComponent(fileName)
|
||||
// Same directory as the final target so the rename below is same-volume (atomic).
|
||||
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
|
||||
let title = "Shotdeck – \(DubaiTime.stamp(workingSession.createdAt))"
|
||||
let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))"
|
||||
|
||||
do {
|
||||
// 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 window = NSWindow(contentViewController: hosting)
|
||||
window.title = "Shotdeck Settings"
|
||||
window.title = "Redline Settings"
|
||||
window.styleMask = [.titled, .closable]
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
|
||||
@@ -27,7 +27,7 @@ struct ShotdeckApp: App {
|
||||
Text(count).font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Shotdeck")
|
||||
.accessibilityLabel("Redline")
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Safe: temp-dir creation for a path this process controls cannot legitimately fail.
|
||||
let fallback = try! AppSupportPaths(root: fallbackRoot, outbox: tmp, watchFolder: tmp)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
||||
case .screenRecordingNotGranted:
|
||||
return "Screen Recording is turned off. Grant it in System Settings to capture."
|
||||
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:
|
||||
return "The display used for capture is no longer connected."
|
||||
case .captureFailed(let underlying):
|
||||
|
||||
@@ -71,7 +71,7 @@ public struct PDFComposer: Sendable {
|
||||
}
|
||||
|
||||
public static func fileName(for session: CaptureSession) -> String {
|
||||
"Shotdeck-\(DubaiTime.fileStamp(session.createdAt)).pdf"
|
||||
"Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf"
|
||||
}
|
||||
|
||||
private static func writePDF(
|
||||
@@ -86,7 +86,7 @@ public struct PDFComposer: Sendable {
|
||||
}
|
||||
|
||||
let auxiliaryInfo: [String: Any] = [
|
||||
kCGPDFContextCreator as String: "Shotdeck",
|
||||
kCGPDFContextCreator as String: "Redline",
|
||||
kCGPDFContextTitle as String: title,
|
||||
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.
|
||||
/// 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 {
|
||||
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.
|
||||
guard let name = document.documentURL?.lastPathComponent else { return false }
|
||||
// .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).
|
||||
// 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.
|
||||
|
||||
@@ -60,7 +60,7 @@ public actor ReturnWatcher {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@discardableResult
|
||||
public func scanNow() async throws -> [ReturnedDocument] {
|
||||
|
||||
@@ -371,6 +371,72 @@ func i19b_presentNonShotdeckCreatorBeatsMatchingFilename() throws {
|
||||
#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(
|
||||
_ subtype: PDFAnnotationSubtype,
|
||||
bounds: CGRect,
|
||||
|
||||
@@ -499,7 +499,7 @@ func documentAttributesRoundTrip() throws {
|
||||
)
|
||||
let document = try openDocument(output)
|
||||
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.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001")
|
||||
#expect(document.pageCount == 1)
|
||||
|
||||
@@ -109,6 +109,65 @@ func w25_duplicateNameSuffixIsRecognizedByScanNow() async throws {
|
||||
#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")
|
||||
func w26_presentNonShotdeckCreatorIsIgnoredByScanNow() async throws {
|
||||
let (paths, cleanup) = try makeCasePaths()
|
||||
|
||||
@@ -11,7 +11,7 @@ cd "$ROOT"
|
||||
|
||||
IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
|
||||
BUNDLE_ID="ai.flowmaster.shotdeck"
|
||||
APP_BUNDLE="${ROOT}/.build/Shotdeck.app"
|
||||
APP_BUNDLE="${ROOT}/.build/Redline.app"
|
||||
|
||||
SKIP_SIGN=0
|
||||
for arg in "$@"; do
|
||||
@@ -27,7 +27,7 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Building Shotdeck (release)"
|
||||
echo "==> Building Redline (release)"
|
||||
swift build -c release --product Shotdeck
|
||||
|
||||
BIN_PATH="$(swift build -c release --product Shotdeck --show-bin-path)/Shotdeck"
|
||||
|
||||
+8
-8
@@ -4,9 +4,9 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
APP_BUNDLE="${ROOT}/.build/Shotdeck.app"
|
||||
APP_BUNDLE="${ROOT}/.build/Redline.app"
|
||||
STAGING="${ROOT}/.build/dmg-staging"
|
||||
DMG="${ROOT}/.build/Shotdeck.dmg"
|
||||
DMG="${ROOT}/.build/Redline.dmg"
|
||||
MOUNT_POINT="${ROOT}/.build/dmg-mnt"
|
||||
|
||||
SKIP_SIGN=0
|
||||
@@ -23,7 +23,7 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Building Shotdeck.app"
|
||||
echo "==> Building Redline.app"
|
||||
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
|
||||
./scripts/build-app.sh --skip-sign
|
||||
else
|
||||
@@ -38,12 +38,12 @@ fi
|
||||
echo "==> Staging DMG contents"
|
||||
rm -rf "${STAGING}"
|
||||
mkdir -p "${STAGING}"
|
||||
ditto "${APP_BUNDLE}" "${STAGING}/Shotdeck.app"
|
||||
ditto "${APP_BUNDLE}" "${STAGING}/Redline.app"
|
||||
ln -s /Applications "${STAGING}/Applications"
|
||||
|
||||
echo "==> Creating ${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
|
||||
detach_dmg() {
|
||||
@@ -67,8 +67,8 @@ MOUNTED=1
|
||||
echo "==> Mount contents"
|
||||
ls -la "${MOUNT_POINT}"
|
||||
|
||||
if [[ ! -d "${MOUNT_POINT}/Shotdeck.app" ]]; then
|
||||
echo "Verification failed: Shotdeck.app missing from mounted DMG" >&2
|
||||
if [[ ! -d "${MOUNT_POINT}/Redline.app" ]]; then
|
||||
echo "Verification failed: Redline.app missing from mounted DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then
|
||||
@@ -81,7 +81,7 @@ if [[ "$(readlink "${MOUNT_POINT}/Applications")" != "/Applications" ]]; then
|
||||
fi
|
||||
|
||||
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}"
|
||||
hdiutil detach "${MOUNT_POINT}"
|
||||
|
||||
Reference in New Issue
Block a user