From d05bd735b57a20356bb47e1e808a0ad83c695d69 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Tue, 1 Sep 2026 22:25:15 +0400 Subject: [PATCH] rename: user-facing product name Shotdeck -> Redline; legacy PDFs still recognized --- Info.plist | 6 +- README.md | 4 +- Sources/Shotdeck/MenuBarView.swift | 2 +- Sources/Shotdeck/SendController.swift | 2 +- Sources/Shotdeck/SettingsView.swift | 2 +- Sources/Shotdeck/main.swift | 4 +- .../ShotdeckCore/Model/ShotdeckError.swift | 2 +- Sources/ShotdeckCore/PDF/PDFComposer.swift | 4 +- .../Returns/AnnotationInspector.swift | 9 ++- .../ShotdeckCore/Returns/ReturnWatcher.swift | 2 +- .../AnnotationInspectorTests.swift | 66 +++++++++++++++++++ .../ShotdeckCoreTests/PDFComposerTests.swift | 2 +- .../ReturnWatcherTests.swift | 59 +++++++++++++++++ scripts/build-app.sh | 4 +- scripts/make-dmg.sh | 16 ++--- 15 files changed, 158 insertions(+), 26 deletions(-) diff --git a/Info.plist b/Info.plist index eb05ed5..62f3b8d 100644 --- a/Info.plist +++ b/Info.plist @@ -2,10 +2,14 @@ + CFBundleIdentifier ai.flowmaster.shotdeck CFBundleName - Shotdeck + Redline CFBundleExecutable Shotdeck CFBundlePackageType diff --git a/README.md b/README.md index 9158292..584de7b 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 5b1f1be..d0a16d9 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -128,7 +128,7 @@ struct MenuBarView: View { Button { NSApp.terminate(nil) } label: { - actionLabel("Quit Shotdeck") + actionLabel("Quit Redline") } } .buttonStyle(.plain) diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index 5934f05..59b7a87 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -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 diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index ca3ba35..c671be1 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -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() diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 81caac8..3b61e42 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -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 } } diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift index 84880c8..a188a64 100644 --- a/Sources/ShotdeckCore/Model/ShotdeckError.swift +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -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): diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift index 0e5e79c..0a9729c 100644 --- a/Sources/ShotdeckCore/PDF/PDFComposer.swift +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -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(), ] diff --git a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift index 7df2922..5b376e8 100644 --- a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift +++ b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift @@ -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. diff --git a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift index e19552f..829d3b8 100644 --- a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift +++ b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift @@ -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] { diff --git a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift index a7f84c0..8bf6b34 100644 --- a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift +++ b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift @@ -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, diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift index 2842daf..ef7d3d7 100644 --- a/Tests/ShotdeckCoreTests/PDFComposerTests.swift +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -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) diff --git a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift index 5b828b3..95bce13 100644 --- a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift +++ b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift @@ -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() diff --git a/scripts/build-app.sh b/scripts/build-app.sh index b96899c..1a226f9 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -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" diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh index 301e1cb..adc4196 100755 --- a/scripts/make-dmg.sh +++ b/scripts/make-dmg.sh @@ -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}" -- 2.54.0