-
+
@@ -95,12 +95,16 @@ Signed, notarized builds via [`Scripts/release.sh`](Scripts/release.sh):
```sh
DEVELOPMENT_TEAM=XXXXXXXXXX \
-APPLE_ID=you@example.com \
-APPLE_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
+NOTARYTOOL_PROFILE=parcel-release \
+UPDATE_APPCAST=1 \
./Scripts/release.sh
```
-Set `SKIP_NOTARIZE=1` for unsigned local Release builds. Tag `v*` triggers [`.github/workflows/release.yml`](.github/workflows/release.yml).
+Use `APPLE_ID` + `APPLE_APP_PASSWORD` instead of `NOTARYTOOL_PROFILE` if preferred. Run
+[`Scripts/verify-release-gates.sh`](Scripts/verify-release-gates.sh) before release and
+[`Scripts/verify-release.sh`](Scripts/verify-release.sh) after packaging. Set `SKIP_NOTARIZE=1`
+for unsigned local Release builds. See [docs/RELEASE_READINESS.md](docs/RELEASE_READINESS.md)
+for the full public ZIP runbook. Tag `v*` triggers [`.github/workflows/release.yml`](.github/workflows/release.yml).
@@ -116,7 +120,7 @@ Sources/Parcel/
Vision/ On-device OCR, faces, QR, translation
Upload/ Supabase Storage REST client
Hotkeys/ Carbon global hotkey + Preferences
-Website/ Next.js marketing site (parcel.parable.dev)
+Website/ Next.js marketing site (parcel-zeta-silk.vercel.app)
docs/ Architecture, parity, QA, integrations
Scripts/ release.sh, generate_icons.sh
Casks/ Homebrew cask (parcel.rb)
@@ -170,5 +174,5 @@ Parcel is released under the [MIT License](LICENSE) — free and open. Built by
diff --git a/Scripts/final-local-qa.sh b/Scripts/final-local-qa.sh
new file mode 100755
index 0000000..946616f
--- /dev/null
+++ b/Scripts/final-local-qa.sh
@@ -0,0 +1,180 @@
+#!/usr/bin/env bash
+# Run Parcel checks that do not require Developer ID credentials, notarization,
+# Screen Recording TCC for transient builds, Supabase credentials, extra displays, or VMs.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+STAMP="${STAMP:-$(date +%Y-%m-%d-%H%M%S)}"
+EVIDENCE_DIR="${EVIDENCE_DIR:-$ROOT/qa-evidence/local-qa-$STAMP}"
+DERIVED_DATA="${DERIVED_DATA:-/tmp/ParcelLocalQA-$STAMP}"
+
+mkdir -p "$EVIDENCE_DIR"/{build,privacy,recording,release,website}
+
+run() {
+ local name="$1"
+ shift
+ echo "==> $name"
+ "$@" >"$EVIDENCE_DIR/$name.log" 2>&1
+}
+
+run build/xcodegen xcodegen generate
+run build/debug-build xcodebuild -project Parcel.xcodeproj -scheme Parcel -configuration Debug -derivedDataPath "$DERIVED_DATA/debug" build
+run build/release-build xcodebuild -project Parcel.xcodeproj -scheme Parcel -configuration Release -derivedDataPath "$DERIVED_DATA/release" build
+run build/unit-tests xcodebuild test -project Parcel.xcodeproj -scheme ParcelUnit -configuration Debug -derivedDataPath "$DERIVED_DATA/unit"
+PARCEL_APP_PATH="$DERIVED_DATA/release/Build/Products/Release/Parcel.app" run build/smoke-check swift Scripts/smoke_check.swift
+run privacy/no-network-ai Scripts/verify-no-network-ai.sh
+run recording/smoke-recording-finalize swift Scripts/smoke-recording-finalize.swift
+run release/script-syntax bash -n \
+ Scripts/release.sh \
+ Scripts/verify-release.sh \
+ Scripts/verify-release-gates.sh \
+ Scripts/update-appcast.sh \
+ Scripts/test-update-appcast.sh \
+ Scripts/test-real-sparkle-signing.sh \
+ Scripts/verify-sparkle-key-consistency.sh \
+ Scripts/verify-no-network-ai.sh \
+ Scripts/verify-workflows.sh \
+ Scripts/verify-website-export-artifact.sh \
+ Scripts/write-release-gate-handoff.sh \
+ Scripts/verify-release-gate-evidence.sh \
+ Scripts/test-release-gate-evidence.sh \
+ Scripts/ship-status.sh \
+ Scripts/final-local-qa.sh
+run release/workflow-syntax Scripts/verify-workflows.sh
+run release/release-gate-evidence-self-test Scripts/test-release-gate-evidence.sh
+echo "==> release/release-preflight-hook"
+if RUN_RELEASE_PREFLIGHT=1 Scripts/release.sh >"$EVIDENCE_DIR/release/release-preflight-hook.log" 2>&1; then
+ echo "release_preflight_hook=pass" >"$EVIDENCE_DIR/release/release-preflight-hook.status"
+else
+ preflight_hook_status=$?
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$EVIDENCE_DIR/release/release-preflight-hook.log"; then
+ echo "release_preflight_hook=gated status=$preflight_hook_status" >"$EVIDENCE_DIR/release/release-preflight-hook.status"
+ else
+ cat "$EVIDENCE_DIR/release/release-preflight-hook.log" >&2
+ exit "$preflight_hook_status"
+ fi
+fi
+run release/appcast-update-dry-run Scripts/test-update-appcast.sh
+echo "==> release/sparkle-key-consistency"
+if Scripts/verify-sparkle-key-consistency.sh >"$EVIDENCE_DIR/release/sparkle-key-consistency.log" 2>&1; then
+ echo "sparkle_key_consistency=pass" >"$EVIDENCE_DIR/release/sparkle-key-consistency.status"
+else
+ sparkle_key_status=$?
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$EVIDENCE_DIR/release/sparkle-key-consistency.log"; then
+ echo "sparkle_key_consistency=gated status=$sparkle_key_status" >"$EVIDENCE_DIR/release/sparkle-key-consistency.status"
+ else
+ cat "$EVIDENCE_DIR/release/sparkle-key-consistency.log" >&2
+ exit "$sparkle_key_status"
+ fi
+fi
+echo "==> release/sparkle-signing-dry-run"
+if Scripts/test-real-sparkle-signing.sh >"$EVIDENCE_DIR/release/sparkle-signing-dry-run.log" 2>&1; then
+ echo "sparkle_signing_dry_run=pass" >"$EVIDENCE_DIR/release/sparkle-signing-dry-run.status"
+else
+ sparkle_status=$?
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$EVIDENCE_DIR/release/sparkle-signing-dry-run.log"; then
+ echo "sparkle_signing_dry_run=gated status=$sparkle_status" >"$EVIDENCE_DIR/release/sparkle-signing-dry-run.status"
+ else
+ cat "$EVIDENCE_DIR/release/sparkle-signing-dry-run.log" >&2
+ exit "$sparkle_status"
+ fi
+fi
+echo "==> release/gate-preflight"
+if Scripts/verify-release-gates.sh >"$EVIDENCE_DIR/release/gate-preflight.log" 2>&1; then
+ echo "gate_preflight=pass" >"$EVIDENCE_DIR/release/gate-preflight.status"
+else
+ gate_status=$?
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$EVIDENCE_DIR/release/gate-preflight.log"; then
+ echo "gate_preflight=gated status=$gate_status" >"$EVIDENCE_DIR/release/gate-preflight.status"
+ else
+ cat "$EVIDENCE_DIR/release/gate-preflight.log" >&2
+ exit "$gate_status"
+ fi
+fi
+echo "==> release/release-gate-handoff"
+handoff_dir="$EVIDENCE_DIR/release-gate-handoff"
+if EVIDENCE_DIR="$handoff_dir" Scripts/write-release-gate-handoff.sh >"$EVIDENCE_DIR/release/release-gate-handoff.log" 2>&1; then
+ echo "release_gate_handoff=pass" >"$EVIDENCE_DIR/release/release-gate-handoff.status"
+else
+ handoff_status=$?
+ cat "$EVIDENCE_DIR/release/release-gate-handoff.log" >&2
+ exit "$handoff_status"
+fi
+echo "==> release/release-gate-evidence"
+if Scripts/verify-release-gate-evidence.sh "$handoff_dir" >"$handoff_dir/release-gate-evidence-current.log" 2>&1; then
+ echo "release_gate_evidence=pass" >"$EVIDENCE_DIR/release/release-gate-evidence.status"
+else
+ evidence_status=$?
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$handoff_dir/release-gate-evidence-current.log"; then
+ echo "release_gate_evidence=gated status=$evidence_status" >"$EVIDENCE_DIR/release/release-gate-evidence.status"
+ else
+ cat "$handoff_dir/release-gate-evidence-current.log" >&2
+ exit "$evidence_status"
+ fi
+fi
+run website/lint npm --prefix Website run lint
+run website/build npm --prefix Website run build
+run website/export-artifact Scripts/verify-website-export-artifact.sh
+
+if Scripts/verify-release.sh Website/public/downloads/Parcel.zip >"$EVIDENCE_DIR/release/verify-release.log" 2>&1; then
+ echo "release_verify=pass" >"$EVIDENCE_DIR/summary.txt"
+else
+ status=$?
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$EVIDENCE_DIR/release/verify-release.log"; then
+ echo "release_verify=gated status=$status" >"$EVIDENCE_DIR/summary.txt"
+ else
+ echo "release_verify=fail status=$status" >"$EVIDENCE_DIR/summary.txt"
+ fi
+fi
+
+{
+ echo "evidence_dir=$EVIDENCE_DIR"
+ echo "derived_data=$DERIVED_DATA"
+ echo
+ tail -n 20 "$EVIDENCE_DIR/privacy/no-network-ai.log" || true
+ echo
+ echo "release_script_syntax=pass"
+ tail -n 20 "$EVIDENCE_DIR/release/script-syntax.log" || true
+ echo
+ echo "workflow_syntax=pass"
+ tail -n 20 "$EVIDENCE_DIR/release/workflow-syntax.log" || true
+ echo
+ echo "release_gate_evidence_self_test=pass"
+ tail -n 20 "$EVIDENCE_DIR/release/release-gate-evidence-self-test.log" || true
+ echo
+ cat "$EVIDENCE_DIR/release/release-preflight-hook.status" || true
+ tail -n 25 "$EVIDENCE_DIR/release/release-preflight-hook.log" || true
+ echo
+ tail -n 20 "$EVIDENCE_DIR/release/appcast-update-dry-run.log" || true
+ echo
+ cat "$EVIDENCE_DIR/release/sparkle-key-consistency.status" || true
+ tail -n 20 "$EVIDENCE_DIR/release/sparkle-key-consistency.log" || true
+ echo
+ cat "$EVIDENCE_DIR/release/sparkle-signing-dry-run.status" || true
+ tail -n 20 "$EVIDENCE_DIR/release/sparkle-signing-dry-run.log" || true
+ echo
+ cat "$EVIDENCE_DIR/release/gate-preflight.status" || true
+ tail -n 25 "$EVIDENCE_DIR/release/gate-preflight.log" || true
+ echo
+ cat "$EVIDENCE_DIR/release/release-gate-handoff.status" || true
+ tail -n 5 "$EVIDENCE_DIR/release/release-gate-handoff.log" || true
+ echo
+ cat "$EVIDENCE_DIR/release/release-gate-evidence.status" || true
+ tail -n 25 "$EVIDENCE_DIR/release-gate-handoff/release-gate-evidence-current.log" || true
+ echo
+ tail -n 20 "$EVIDENCE_DIR/build/unit-tests.log" || true
+ echo
+ tail -n 20 "$EVIDENCE_DIR/website/export-artifact.log" || true
+ echo
+ tail -n 20 "$EVIDENCE_DIR/release/verify-release.log" || true
+} >>"$EVIDENCE_DIR/summary.txt"
+
+Scripts/ship-status.sh "$EVIDENCE_DIR" >"$EVIDENCE_DIR/ship-status.txt"
+{
+ echo
+ cat "$EVIDENCE_DIR/ship-status.txt"
+} >>"$EVIDENCE_DIR/summary.txt"
+
+cat "$EVIDENCE_DIR/summary.txt"
diff --git a/Scripts/generate-sparkle-keys.sh b/Scripts/generate-sparkle-keys.sh
new file mode 100755
index 0000000..631961d
--- /dev/null
+++ b/Scripts/generate-sparkle-keys.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Generate (or print) the Sparkle EdDSA public key for Parcel and show how to wire Info.plist.
+# Private key stays in the login Keychain under account "parcel.parable.dev".
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+GEN="$ROOT/.derivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys"
+ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-parcel.parable.dev}"
+
+if [[ ! -x "$GEN" ]]; then
+ echo "Resolving Sparkle package so generate_keys is available…"
+ (cd "$ROOT" && xcodegen generate && xcodebuild -resolvePackageDependencies -project Parcel.xcodeproj -scheme Parcel -derivedDataPath "$ROOT/.derivedData")
+fi
+
+if [[ ! -x "$GEN" ]]; then
+ echo "generate_keys not found at $GEN" >&2
+ exit 1
+fi
+
+"$GEN" --account "$ACCOUNT"
+echo
+echo "Private key is in your Keychain (account: $ACCOUNT)."
+echo "Use that same machine (or import via generate_keys -f) when signing appcasts with sign_update."
diff --git a/Scripts/release.sh b/Scripts/release.sh
index be6470b..ceb6e73 100755
--- a/Scripts/release.sh
+++ b/Scripts/release.sh
@@ -3,12 +3,19 @@
#
# Required environment variables:
# DEVELOPMENT_TEAM — Apple Developer Team ID
-# APPLE_ID — Apple ID for notarytool (optional if skipping notarize)
-# APPLE_APP_PASSWORD — app-specific password (optional)
+# APPLE_ID — Apple ID for notarytool (optional when NOTARYTOOL_PROFILE is set)
+# APPLE_APP_PASSWORD — app-specific password (optional when NOTARYTOOL_PROFILE is set)
+# NOTARYTOOL_PROFILE — keychain profile for notarytool (optional alternative)
#
# Usage:
# DEVELOPMENT_TEAM=XXXXXXXXXX ./Scripts/release.sh
# SKIP_NOTARIZE=1 DEVELOPMENT_TEAM=XXXXXXXXXX ./Scripts/release.sh # local test build
+# UPDATE_APPCAST=1 DEVELOPMENT_TEAM=XXXXXXXXXX ... ./Scripts/release.sh
+# NOTARYTOOL_PROFILE=parcel-release DEVELOPMENT_TEAM=XXXXXXXXXX ./Scripts/release.sh
+#
+# Optional:
+# RUN_RELEASE_PREFLIGHT=0 — skip external release-gate preflight
+# VERIFY_RELEASE=0 — skip final Website/public/downloads/Parcel.zip verification
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -22,6 +29,29 @@ APP_PATH="$EXPORT_PATH/Parcel.app"
ZIP_PATH="$ROOT/build/Parcel.zip"
WEBSITE_ZIP="$ROOT/Website/public/downloads/Parcel.zip"
+if [[ -z "${RUN_RELEASE_PREFLIGHT+x}" ]]; then
+ if [[ "${SKIP_NOTARIZE:-}" == "1" ]]; then
+ RUN_RELEASE_PREFLIGHT=0
+ else
+ RUN_RELEASE_PREFLIGHT=1
+ fi
+fi
+
+if [[ -z "${VERIFY_RELEASE+x}" ]]; then
+ if [[ "${SKIP_NOTARIZE:-}" == "1" ]]; then
+ VERIFY_RELEASE=0
+ else
+ VERIFY_RELEASE=1
+ fi
+fi
+
+if [[ "$RUN_RELEASE_PREFLIGHT" == "1" ]]; then
+ echo "==> Verifying release gates"
+ "$ROOT/Scripts/verify-release-gates.sh"
+else
+ echo "==> Skipping release gate preflight (RUN_RELEASE_PREFLIGHT=0)"
+fi
+
if [[ -z "${DEVELOPMENT_TEAM:-}" ]]; then
echo "Set DEVELOPMENT_TEAM to your Apple Developer Team ID." >&2
exit 1
@@ -41,7 +71,8 @@ xcodebuild archive \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=macOS" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
- CODE_SIGN_STYLE=Automatic
+ CODE_SIGN_STYLE=Automatic \
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO
EXPORT_OPTS="$ROOT/build/ExportOptions.plist"
sed "s/\$(DEVELOPMENT_TEAM)/$DEVELOPMENT_TEAM/g" "$ROOT/Scripts/ExportOptions.plist" > "$EXPORT_OPTS"
@@ -53,24 +84,44 @@ xcodebuild -exportArchive \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_OPTS"
-echo "==> Verifying code signature"
+echo "==> Verifying code signature + entitlements"
codesign --verify --deep --strict "$APP_PATH"
+if codesign -d --entitlements :- "$APP_PATH" 2>/dev/null | grep -q "get-task-allow"; then
+ echo "ERROR: Release app still has get-task-allow — refusing to package." >&2
+ exit 1
+fi
+if ! codesign -d --entitlements :- "$APP_PATH" 2>/dev/null | grep -q "network.client"; then
+ echo "ERROR: Release app missing network.client (Sparkle/upload will fail in sandbox)." >&2
+ exit 1
+fi
+PUBKEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$APP_PATH/Contents/Info.plist" 2>/dev/null || true)"
+if [[ -z "$PUBKEY" || "$PUBKEY" == *REPLACE_WITH* ]]; then
+ echo "ERROR: SUPublicEDKey is missing or still a placeholder." >&2
+ exit 1
+fi
+echo " SUPublicEDKey OK ($PUBKEY)"
echo "==> Creating zip"
rm -f "$ZIP_PATH"
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
if [[ "${SKIP_NOTARIZE:-}" != "1" ]]; then
- if [[ -z "${APPLE_ID:-}" || -z "${APPLE_APP_PASSWORD:-}" ]]; then
- echo "Set APPLE_ID and APPLE_APP_PASSWORD to notarize, or SKIP_NOTARIZE=1." >&2
+ if [[ -n "${NOTARYTOOL_PROFILE:-}" ]]; then
+ echo "==> Submitting for notarization with keychain profile"
+ xcrun notarytool submit "$ZIP_PATH" \
+ --keychain-profile "$NOTARYTOOL_PROFILE" \
+ --wait
+ elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_APP_PASSWORD:-}" ]]; then
+ echo "==> Submitting for notarization with Apple ID credentials"
+ xcrun notarytool submit "$ZIP_PATH" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_PASSWORD" \
+ --team-id "$DEVELOPMENT_TEAM" \
+ --wait
+ else
+ echo "Set NOTARYTOOL_PROFILE or APPLE_ID and APPLE_APP_PASSWORD to notarize, or SKIP_NOTARIZE=1." >&2
exit 1
fi
- echo "==> Submitting for notarization"
- xcrun notarytool submit "$ZIP_PATH" \
- --apple-id "$APPLE_ID" \
- --password "$APPLE_APP_PASSWORD" \
- --team-id "$DEVELOPMENT_TEAM" \
- --wait
echo "==> Stapling ticket"
xcrun stapler staple "$APP_PATH"
rm -f "$ZIP_PATH"
@@ -79,6 +130,19 @@ fi
mkdir -p "$(dirname "$WEBSITE_ZIP")"
cp "$ZIP_PATH" "$WEBSITE_ZIP"
+
+if [[ "${UPDATE_APPCAST:-}" == "1" ]]; then
+ echo "==> Updating Sparkle appcast"
+ "$ROOT/Scripts/update-appcast.sh" "$WEBSITE_ZIP"
+fi
+
+if [[ "$VERIFY_RELEASE" == "1" ]]; then
+ echo "==> Verifying public website ZIP"
+ "$ROOT/Scripts/verify-release.sh" "$WEBSITE_ZIP"
+else
+ echo "==> Skipping final public ZIP verification (VERIFY_RELEASE=0)"
+fi
+
echo "==> Done: $ZIP_PATH"
echo " Website artifact: $WEBSITE_ZIP"
-echo "Next: update Website/public/appcast.xml sparkle:edSignature and deploy."
+echo "Next: deploy the website after final QA sign-off."
diff --git a/Scripts/ship-status.sh b/Scripts/ship-status.sh
new file mode 100755
index 0000000..cce16be
--- /dev/null
+++ b/Scripts/ship-status.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# Summarize Parcel release-readiness evidence into a compact status report.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+EVIDENCE_DIR="${1:-$ROOT/qa-evidence/final-ship-2026-08-12/local-qa-current}"
+AUDIT_PATH="${AUDIT_PATH:-$ROOT/qa-evidence/final-ship-2026-08-12/REQUIREMENTS_AUDIT.md}"
+
+if [[ ! -d "$EVIDENCE_DIR" ]]; then
+ echo "ERROR: evidence directory not found: $EVIDENCE_DIR" >&2
+ exit 1
+fi
+
+extract_counts() {
+ local path="$1"
+ if [[ -f "$path" ]]; then
+ sed -n 's/^--- \([0-9][0-9]*\) passed, \([0-9][0-9]*\) failed, \([0-9][0-9]*\) gated ---$/\1 \2 \3/p' "$path" | tail -n 1
+ fi
+}
+
+extract_plain_counts() {
+ local path="$1"
+ if [[ -f "$path" ]]; then
+ sed -n 's/^--- \([0-9][0-9]*\) passed, \([0-9][0-9]*\) failed ---$/\1 \2/p' "$path" | tail -n 1
+ fi
+}
+
+audit_counts() {
+ if [[ ! -f "$AUDIT_PATH" ]]; then
+ echo "0 0 0 0"
+ return
+ fi
+ awk -F'|' '
+ /^\| [^|]+ \| [A-Z\/]+ \|/ && $2 !~ /Requirement/ {
+ total += 1
+ status = $3
+ gsub(/[[:space:]]/, "", status)
+ if (status == "PASS") pass += 1
+ else if (status == "PASS/GATED") pass_gated += 1
+ else if (status == "GATED") gated += 1
+ }
+ END { print total + 0, pass + 0, pass_gated + 0, gated + 0 }
+ ' "$AUDIT_PATH"
+}
+
+percent() {
+ local numerator="$1"
+ local denominator="$2"
+ if (( denominator == 0 )); then
+ echo "0"
+ else
+ awk -v n="$numerator" -v d="$denominator" 'BEGIN { printf "%d", int(((n / d) * 100) + 0.5) }'
+ fi
+}
+
+read -r audit_total audit_pass audit_pass_gated audit_gated < <(audit_counts)
+local_done=$((audit_pass + audit_pass_gated))
+evidence_percent="$(percent "$local_done" "$audit_total")"
+public_percent="$(percent "$audit_pass" "$audit_total")"
+
+release_counts="$(extract_counts "$EVIDENCE_DIR/release/verify-release.log" || true)"
+gate_counts="$(extract_counts "$EVIDENCE_DIR/release/gate-preflight.log" || true)"
+sparkle_sign_counts="$(extract_counts "$EVIDENCE_DIR/release/sparkle-signing-dry-run.log" || true)"
+sparkle_key_counts="$(extract_counts "$EVIDENCE_DIR/release/sparkle-key-consistency.log" || true)"
+handoff_counts="$(extract_counts "$EVIDENCE_DIR/release-gate-handoff/release-gate-evidence-current.log" || true)"
+handoff_self_test_counts="$(extract_plain_counts "$EVIDENCE_DIR/release/release-gate-evidence-self-test.log" || true)"
+
+echo "# Parcel Ship Status"
+echo
+echo "Evidence: $EVIDENCE_DIR"
+echo
+echo "Requirement audit:"
+echo "- Total requirements: $audit_total"
+echo "- Fully passed: $audit_pass"
+echo "- Locally passed but externally gated: $audit_pass_gated"
+echo "- Externally gated: $audit_gated"
+echo "- Evidence-backed completion: ${evidence_percent}%"
+echo "- Public release unblocked: ${public_percent}%"
+echo
+
+if [[ -n "$release_counts" ]]; then
+ read -r passed failed gated <<<"$release_counts"
+ echo "Release artifact verifier: $passed passed, $failed failed, $gated gated"
+fi
+if [[ -n "$gate_counts" ]]; then
+ read -r passed failed gated <<<"$gate_counts"
+ echo "Release-machine preflight: $passed passed, $failed failed, $gated gated"
+fi
+if [[ -n "$sparkle_key_counts" ]]; then
+ read -r passed failed gated <<<"$sparkle_key_counts"
+ echo "Sparkle key consistency: $passed passed, $failed failed, $gated gated"
+fi
+if [[ -n "$sparkle_sign_counts" ]]; then
+ read -r passed failed gated <<<"$sparkle_sign_counts"
+ echo "Sparkle signing dry-run: $passed passed, $failed failed, $gated gated"
+fi
+if [[ -n "$handoff_counts" ]]; then
+ read -r passed failed gated <<<"$handoff_counts"
+ echo "Release gate evidence packet: $passed passed, $failed failed, $gated gated"
+fi
+if [[ -n "$handoff_self_test_counts" ]]; then
+ read -r passed failed <<<"$handoff_self_test_counts"
+ echo "Release gate evidence verifier self-test: $passed passed, $failed failed"
+fi
+
+echo
+echo "Remaining gates:"
+if [[ -f "$AUDIT_PATH" ]]; then
+ awk -F'|' '
+ /^\| [^|]+ \| (GATED|PASS\/GATED) \|/ {
+ item = $2
+ status = $3
+ detail = $4
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", item)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", status)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", detail)
+ print "- " item " (" status "): " detail
+ }
+ ' "$AUDIT_PATH"
+fi
diff --git a/Scripts/smoke-recording-finalize.swift b/Scripts/smoke-recording-finalize.swift
new file mode 100644
index 0000000..8a9a29d
--- /dev/null
+++ b/Scripts/smoke-recording-finalize.swift
@@ -0,0 +1,360 @@
+#!/usr/bin/env swift
+/**
+ Smoke-tests the recording writer finalize contract with deterministic synthetic media:
+ PixelBufferAdaptor video + deferred AAC audio -> playable MP4 with moov.
+ */
+import AVFoundation
+import CoreMedia
+import CoreVideo
+import Foundation
+
+enum SmokeSampleType {
+ case screen
+ case audio
+}
+
+final class SmokeWriter: @unchecked Sendable {
+ let queue = DispatchQueue(label: "smoke.recording")
+ private let writer: AVAssetWriter
+ private let videoInput: AVAssetWriterInput
+ private let adaptor: AVAssetWriterInputPixelBufferAdaptor
+ private var audioInput: AVAssetWriterInput?
+ private var sessionStarted = false
+ private var hasFinished = false
+ private var videoCount = 0
+ private var pendingVideo: [CMSampleBuffer] = []
+ private var pendingAudio: [CMSampleBuffer] = []
+ private var sawAudio = false
+
+ init(url: URL, size: CGSize) throws {
+ writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
+ videoInput = AVAssetWriterInput(
+ mediaType: .video,
+ outputSettings: [
+ AVVideoCodecKey: AVVideoCodecType.h264,
+ AVVideoWidthKey: Int(size.width),
+ AVVideoHeightKey: Int(size.height),
+ AVVideoCompressionPropertiesKey: [AVVideoAverageBitRateKey: 6_000_000],
+ ]
+ )
+ videoInput.expectsMediaDataInRealTime = true
+ adaptor = AVAssetWriterInputPixelBufferAdaptor(
+ assetWriterInput: videoInput,
+ sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA),
+ kCVPixelBufferWidthKey as String: Int(size.width),
+ kCVPixelBufferHeightKey as String: Int(size.height),
+ ]
+ )
+ guard writer.canAdd(videoInput) else { throw NSError(domain: "smoke", code: 1) }
+ writer.add(videoInput)
+ }
+
+ func receive(_ sampleBuffer: CMSampleBuffer, type: SmokeSampleType) {
+ guard sampleBuffer.isValid, CMSampleBufferDataIsReady(sampleBuffer) else { return }
+ queue.async { self.append(sampleBuffer, type: type) }
+ }
+
+ private func append(_ sampleBuffer: CMSampleBuffer, type: SmokeSampleType) {
+ guard !hasFinished else { return }
+ switch type {
+ case .screen:
+ if !sessionStarted {
+ pendingVideo.append(sampleBuffer)
+ _ = start(force: pendingVideo.count >= 12)
+ return
+ }
+ appendVideo(sampleBuffer)
+ case .audio:
+ if !sessionStarted {
+ sawAudio = true
+ pendingAudio.append(sampleBuffer)
+ _ = start(force: false)
+ return
+ }
+ appendAudio(sampleBuffer)
+ }
+ }
+
+ private func start(force: Bool) -> Bool {
+ guard !sessionStarted, let first = pendingVideo.first else { return false }
+ if !force && !sawAudio { return false }
+ if sawAudio {
+ let input = AVAssetWriterInput(
+ mediaType: .audio,
+ outputSettings: [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVSampleRateKey: 48_000,
+ AVNumberOfChannelsKey: 2,
+ AVEncoderBitRateKey: 128_000,
+ ]
+ )
+ input.expectsMediaDataInRealTime = true
+ if writer.canAdd(input) {
+ writer.add(input)
+ audioInput = input
+ }
+ }
+ guard writer.startWriting() else { return false }
+ writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(first))
+ sessionStarted = true
+ let videos = pendingVideo
+ let audios = pendingAudio
+ pendingVideo.removeAll()
+ pendingAudio.removeAll()
+ videos.forEach(appendVideo)
+ audios.forEach(appendAudio)
+ return true
+ }
+
+ private func appendVideo(_ sampleBuffer: CMSampleBuffer) {
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
+ writer.status == .writing,
+ videoInput.isReadyForMoreMediaData
+ else { return }
+ if adaptor.append(pixelBuffer, withPresentationTime: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) {
+ videoCount += 1
+ }
+ }
+
+ private func appendAudio(_ sampleBuffer: CMSampleBuffer) {
+ guard let audioInput, writer.status == .writing, audioInput.isReadyForMoreMediaData else { return }
+ _ = audioInput.append(sampleBuffer)
+ }
+
+ func finish() async throws {
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
+ queue.async {
+ self.hasFinished = true
+ if !self.sessionStarted { _ = self.start(force: true) }
+ guard self.sessionStarted, self.videoCount > 0 else {
+ self.writer.cancelWriting()
+ cont.resume(throwing: NSError(domain: "smoke", code: 2, userInfo: [
+ NSLocalizedDescriptionKey: "no video frames",
+ ]))
+ return
+ }
+ self.videoInput.markAsFinished()
+ self.audioInput?.markAsFinished()
+ self.writer.finishWriting {
+ if self.writer.status == .completed {
+ cont.resume()
+ } else {
+ cont.resume(throwing: self.writer.error ?? NSError(domain: "smoke", code: 3))
+ }
+ }
+ }
+ }
+ }
+}
+
+let out = URL(fileURLWithPath: "/tmp/parcel-smoke-recording-finalize.mp4")
+try? FileManager.default.removeItem(at: out)
+
+func makeVideoSampleBuffer(width: Int, height: Int, frame: Int) throws -> CMSampleBuffer {
+ var pixelBuffer: CVPixelBuffer?
+ let attributes: [String: Any] = [
+ kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA),
+ kCVPixelBufferWidthKey as String: width,
+ kCVPixelBufferHeightKey as String: height,
+ kCVPixelBufferCGImageCompatibilityKey as String: true,
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
+ ]
+ let pixelStatus = CVPixelBufferCreate(
+ kCFAllocatorDefault,
+ width,
+ height,
+ kCVPixelFormatType_32BGRA,
+ attributes as CFDictionary,
+ &pixelBuffer
+ )
+ guard pixelStatus == kCVReturnSuccess, let pixelBuffer else {
+ throw NSError(domain: "smoke", code: Int(pixelStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Could not create video pixel buffer.",
+ ])
+ }
+
+ CVPixelBufferLockBaseAddress(pixelBuffer, [])
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
+ guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
+ throw NSError(domain: "smoke", code: 4, userInfo: [
+ NSLocalizedDescriptionKey: "Could not lock video pixel buffer.",
+ ])
+ }
+ let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
+ let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
+ for y in 0.. CMSampleBuffer {
+ let sampleRate: Double = 48_000
+ let bytesPerSample = 2
+ let bytesPerFrame = Int(channels) * bytesPerSample
+ var asbd = AudioStreamBasicDescription(
+ mSampleRate: sampleRate,
+ mFormatID: kAudioFormatLinearPCM,
+ mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked,
+ mBytesPerPacket: UInt32(bytesPerFrame),
+ mFramesPerPacket: 1,
+ mBytesPerFrame: UInt32(bytesPerFrame),
+ mChannelsPerFrame: channels,
+ mBitsPerChannel: UInt32(bytesPerSample * 8),
+ mReserved: 0
+ )
+ var formatDescription: CMAudioFormatDescription?
+ let formatStatus = CMAudioFormatDescriptionCreate(
+ allocator: kCFAllocatorDefault,
+ asbd: &asbd,
+ layoutSize: 0,
+ layout: nil,
+ magicCookieSize: 0,
+ magicCookie: nil,
+ extensions: nil,
+ formatDescriptionOut: &formatDescription
+ )
+ guard formatStatus == noErr, let formatDescription else {
+ throw NSError(domain: "smoke", code: Int(formatStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Could not create audio format description.",
+ ])
+ }
+
+ let byteCount = frameCount * bytesPerFrame
+ var blockBuffer: CMBlockBuffer?
+ let blockStatus = CMBlockBufferCreateWithMemoryBlock(
+ allocator: kCFAllocatorDefault,
+ memoryBlock: nil,
+ blockLength: byteCount,
+ blockAllocator: kCFAllocatorDefault,
+ customBlockSource: nil,
+ offsetToData: 0,
+ dataLength: byteCount,
+ flags: 0,
+ blockBufferOut: &blockBuffer
+ )
+ guard blockStatus == noErr, let blockBuffer else {
+ throw NSError(domain: "smoke", code: Int(blockStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Could not create audio block buffer.",
+ ])
+ }
+ var silence = [UInt8](repeating: 0, count: byteCount)
+ let copyStatus = silence.withUnsafeBytes { rawBuffer in
+ CMBlockBufferReplaceDataBytes(
+ with: rawBuffer.baseAddress!,
+ blockBuffer: blockBuffer,
+ offsetIntoDestination: 0,
+ dataLength: byteCount
+ )
+ }
+ guard copyStatus == noErr else {
+ throw NSError(domain: "smoke", code: Int(copyStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Could not fill audio block buffer.",
+ ])
+ }
+
+ var timing = CMSampleTimingInfo(
+ duration: CMTime(value: 1, timescale: CMTimeScale(sampleRate)),
+ presentationTimeStamp: CMTime(value: CMTimeValue(startFrame), timescale: CMTimeScale(sampleRate)),
+ decodeTimeStamp: .invalid
+ )
+ var sampleSize = bytesPerFrame
+ var sampleBuffer: CMSampleBuffer?
+ let sampleStatus = CMSampleBufferCreateReady(
+ allocator: kCFAllocatorDefault,
+ dataBuffer: blockBuffer,
+ formatDescription: formatDescription,
+ sampleCount: frameCount,
+ sampleTimingEntryCount: 1,
+ sampleTimingArray: &timing,
+ sampleSizeEntryCount: 1,
+ sampleSizeArray: &sampleSize,
+ sampleBufferOut: &sampleBuffer
+ )
+ guard sampleStatus == noErr, let sampleBuffer else {
+ throw NSError(domain: "smoke", code: Int(sampleStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Could not create audio sample buffer.",
+ ])
+ }
+ return sampleBuffer
+}
+
+do {
+ let width = 320
+ let height = 180
+ let writer = try SmokeWriter(url: out, size: CGSize(width: width, height: height))
+ let audioChunkFrames = 4_800
+ for chunk in 0..<30 {
+ writer.receive(
+ try makeSilentAudioSampleBuffer(startFrame: chunk * audioChunkFrames, frameCount: audioChunkFrames),
+ type: .audio
+ )
+ for frameOffset in 0..<3 {
+ writer.receive(
+ try makeVideoSampleBuffer(width: width, height: height, frame: chunk * 3 + frameOffset),
+ type: .screen
+ )
+ }
+ }
+ writer.queue.sync {}
+ try await writer.finish()
+
+ let asset = AVURLAsset(url: out)
+ let videoTracks = try await asset.loadTracks(withMediaType: .video)
+ let audioTracks = try await asset.loadTracks(withMediaType: .audio)
+ let duration = try await asset.load(.duration)
+ let data = try Data(contentsOf: out)
+ let moov = data.range(of: Data([0x6d, 0x6f, 0x6f, 0x76]))?.lowerBound ?? -1
+ guard !videoTracks.isEmpty, !audioTracks.isEmpty, duration.seconds > 0.5, moov >= 0 else {
+ fputs(
+ "FAIL: duration=\(duration.seconds) video=\(videoTracks.count) audio=\(audioTracks.count) moov=\(moov)\n",
+ stderr
+ )
+ exit(1)
+ }
+ print(
+ "PASS: \(out.path) duration=\(String(format: "%.2f", duration.seconds))s "
+ + "video=\(videoTracks.count) audio=\(audioTracks.count) moov=\(moov) bytes=\(data.count)"
+ )
+} catch {
+ fputs("FAIL: \(error)\n", stderr)
+ exit(1)
+}
diff --git a/Scripts/smoke_check.swift b/Scripts/smoke_check.swift
index a6f66cc..cbd8499 100644
--- a/Scripts/smoke_check.swift
+++ b/Scripts/smoke_check.swift
@@ -14,7 +14,8 @@ enum Smoke {
else { failed += 1; print("FAIL: \(name)") }
}
- let appPath = ".derivedData/Build/Products/Debug/Parcel.app"
+ let appPath = ProcessInfo.processInfo.environment["PARCEL_APP_PATH"]
+ ?? ".derivedData/Build/Products/Debug/Parcel.app"
let infoPlist = "\(appPath)/Contents/Info.plist"
check("Parcel.app exists", FileManager.default.fileExists(atPath: appPath))
check("Info.plist exists", FileManager.default.fileExists(atPath: infoPlist))
@@ -29,13 +30,13 @@ enum Smoke {
check("Info.plist readable", false)
}
- let entitlements = "\(appPath)/Contents/embedded.provisionprofile"
// ad-hoc builds may not embed profile; check entitlements via codesign
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
task.arguments = ["-d", "--entitlements", ":-", appPath]
let pipe = Pipe()
task.standardOutput = pipe
+ task.standardError = pipe
try? task.run()
task.waitUntilExit()
let entData = pipe.fileHandleForReading.readDataToEndOfFile()
@@ -52,8 +53,8 @@ enum Smoke {
check("Sparkle framework embedded", FileManager.default.fileExists(atPath: "\(appPath)/Contents/Frameworks/Sparkle.framework"))
- let websiteDist = "Website/dist/index.html"
- check("Website built", FileManager.default.fileExists(atPath: websiteDist))
+ let websiteOutputs = ["Website/dist/index.html", "Website/out/index.html", "Website/.next/BUILD_ID"]
+ check("Website built", websiteOutputs.contains { FileManager.default.fileExists(atPath: $0) })
let appcast = "Website/public/appcast.xml"
check("Sparkle appcast exists", FileManager.default.fileExists(atPath: appcast))
diff --git a/Scripts/test-real-sparkle-signing.sh b/Scripts/test-real-sparkle-signing.sh
new file mode 100755
index 0000000..7ef0195
--- /dev/null
+++ b/Scripts/test-real-sparkle-signing.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env bash
+# Dry-run real Sparkle signing against temp copies of the website ZIP and appcast.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+SPARKLE_KEYCHAIN_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-parcel.parable.dev}"
+SPARKLE_SIGNING_TIMEOUT_SECONDS="${SPARKLE_SIGNING_TIMEOUT_SECONDS:-20}"
+
+passes=0
+failures=0
+gates=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+gate() { echo "GATE: $*"; gates=$((gates + 1)); }
+
+finish() {
+ echo
+ echo "--- $passes passed, $failures failed, $gates gated ---"
+ if (( failures > 0 )); then
+ exit 2
+ fi
+ if (( gates > 0 )); then
+ exit 1
+ fi
+}
+
+find_sign_update() {
+ if [[ -n "${SPARKLE_SIGN_UPDATE:-}" && -x "$SPARKLE_SIGN_UPDATE" ]]; then
+ echo "$SPARKLE_SIGN_UPDATE"
+ return 0
+ fi
+ if command -v sign_update >/dev/null 2>&1; then
+ command -v sign_update
+ return 0
+ fi
+ local candidates=(
+ "$ROOT/.derivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ "$ROOT/.derivedData-release/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ )
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "$candidate" ]]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+run_update_appcast_with_timeout() {
+ APPCAST_PATH="$appcast_path" Scripts/update-appcast.sh "$zip_path" >"$tmp_dir/update.log" 2>&1 &
+ local update_pid=$!
+ local elapsed=0
+
+ while kill -0 "$update_pid" >/dev/null 2>&1; do
+ if (( elapsed >= SPARKLE_SIGNING_TIMEOUT_SECONDS )); then
+ local children
+ children="$(pgrep -P "$update_pid" 2>/dev/null || true)"
+ local child
+ for child in $children; do
+ pkill -TERM -P "$child" 2>/dev/null || true
+ kill "$child" 2>/dev/null || true
+ done
+ kill "$update_pid" 2>/dev/null || true
+ wait "$update_pid" 2>/dev/null || true
+ return 124
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+ done
+
+ wait "$update_pid"
+}
+
+zip_source="$ROOT/Website/public/downloads/Parcel.zip"
+appcast_source="$ROOT/Website/public/appcast.xml"
+
+if [[ ! -f "$zip_source" ]]; then
+ fail "Website ZIP is missing: $zip_source"
+ finish
+fi
+if [[ ! -f "$appcast_source" ]]; then
+ fail "Appcast is missing: $appcast_source"
+ finish
+fi
+pass "Website ZIP and appcast exist"
+
+sign_update_path="$(find_sign_update || true)"
+if [[ -z "$sign_update_path" ]]; then
+ gate "Sparkle sign_update is unavailable"
+ finish
+fi
+pass "Sparkle sign_update available at $sign_update_path"
+
+tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/parcel-real-sparkle-test.XXXXXX")"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+zip_path="$tmp_dir/Parcel.zip"
+appcast_path="$tmp_dir/appcast.xml"
+cp "$zip_source" "$zip_path"
+cp "$appcast_source" "$appcast_path"
+original_hash="$(shasum -a 256 "$appcast_source" | awk '{print $1}')"
+
+metadata_dir="$tmp_dir/metadata"
+mkdir -p "$metadata_dir"
+if ditto -x -k "$zip_path" "$metadata_dir"; then
+ metadata_app_path="$(find "$metadata_dir" -maxdepth 1 -name '*.app' -print -quit)"
+ if [[ -n "$metadata_app_path" ]]; then
+ expected_short_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$metadata_app_path/Contents/Info.plist" 2>/dev/null || true)"
+ expected_build_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$metadata_app_path/Contents/Info.plist" 2>/dev/null || true)"
+ else
+ expected_short_version=""
+ expected_build_version=""
+ fi
+else
+ expected_short_version=""
+ expected_build_version=""
+fi
+
+if [[ -n "$expected_short_version" && -n "$expected_build_version" ]]; then
+ pass "Website ZIP app metadata is readable"
+else
+ fail "Website ZIP app metadata is not readable"
+ finish
+fi
+
+if run_update_appcast_with_timeout; then
+ pass "Real Sparkle signer updated temp appcast"
+else
+ signing_status=$?
+ if [[ "$signing_status" == "124" ]]; then
+ gate "Real Sparkle signing timed out waiting for Keychain access; approve the prompt or use SPARKLE_ED_PRIVATE_KEY/SPARKLE_ED_KEY_FILE"
+ else
+ gate "Real Sparkle signing did not complete; import the EdDSA private key or set SPARKLE_ED_PRIVATE_KEY/SPARKLE_ED_KEY_FILE"
+ fi
+ sed 's/^/ /' "$tmp_dir/update.log" || true
+ finish
+fi
+
+expected_length="$(stat -f %z "$zip_path")"
+actual_length="$(sed -n 's/.*length="\([^"]*\)".*/\1/p' "$appcast_path" | head -n 1)"
+actual_signature="$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' "$appcast_path" | head -n 1)"
+actual_short_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)"
+actual_build_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)"
+actual_title="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | sed -n '2p')"
+actual_pub_date="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)"
+current_hash="$(shasum -a 256 "$appcast_source" | awk '{print $1}')"
+
+if [[ "$actual_short_version" == "$expected_short_version" ]]; then
+ pass "Temp appcast short version matches ZIP app"
+else
+ fail "Temp appcast short version '$actual_short_version' did not match '$expected_short_version'"
+fi
+
+if [[ "$actual_build_version" == "$expected_build_version" ]]; then
+ pass "Temp appcast build version matches ZIP app"
+else
+ fail "Temp appcast build version '$actual_build_version' did not match '$expected_build_version'"
+fi
+
+if [[ "$actual_title" == "$expected_short_version" ]]; then
+ pass "Temp appcast item title matches ZIP app short version"
+else
+ fail "Temp appcast item title '$actual_title' did not match '$expected_short_version'"
+fi
+
+if date -j -f "%a, %d %b %Y %H:%M:%S %z" "$actual_pub_date" >/dev/null 2>&1; then
+ pass "Temp appcast pubDate is parseable"
+else
+ fail "Temp appcast pubDate is not parseable: '$actual_pub_date'"
+fi
+
+if [[ "$actual_length" == "$expected_length" ]]; then
+ pass "Temp appcast length matches ZIP"
+else
+ fail "Temp appcast length '$actual_length' did not match '$expected_length'"
+fi
+
+if [[ -n "$actual_signature" && "$actual_signature" != *REPLACE* ]]; then
+ pass "Temp appcast EdDSA signature is populated"
+ if "$sign_update_path" --account "$SPARKLE_KEYCHAIN_ACCOUNT" --verify "$zip_path" "$actual_signature" >/dev/null 2>&1; then
+ pass "Temp appcast EdDSA signature verifies against ZIP"
+ else
+ fail "Temp appcast EdDSA signature does not verify against ZIP"
+ fi
+else
+ fail "Temp appcast EdDSA signature is missing or placeholder"
+fi
+
+if [[ "$current_hash" == "$original_hash" ]]; then
+ pass "Real Website/public/appcast.xml unchanged"
+else
+ fail "Real Website/public/appcast.xml changed during real-signing dry-run"
+fi
+
+finish
diff --git a/Scripts/test-release-gate-evidence.sh b/Scripts/test-release-gate-evidence.sh
new file mode 100755
index 0000000..5327c42
--- /dev/null
+++ b/Scripts/test-release-gate-evidence.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+# Self-test the release-gate evidence verifier with synthetic complete and incomplete packets.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/parcel-gate-evidence-test.XXXXXX")"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+passes=0
+failures=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+
+write_manual_note() {
+ local path="$1"
+ local verified="$2"
+ cat >"$path" <"$dir/RELEASE_GATE_HANDOFF.md"
+ printf '1) Developer ID Application: Parcel Release (QFH99B6X5V)\n' >"$dir/release/developer-id-identity.txt"
+ printf '%s\n' '--- 18 passed, 0 failed, 0 gated ---' >"$dir/release/gate-preflight-final.log"
+ printf '==> Done: /tmp/Parcel.zip\n Website artifact: Website/public/downloads/Parcel.zip\n' >"$dir/release/release-final.log"
+ printf 'status: Accepted\n' >"$dir/release/notarization.log"
+ printf 'The staple and validate action worked!\n' >"$dir/release/stapler-validate.log"
+ printf 'Parcel.app: accepted\n' >"$dir/release/spctl-final.log"
+ printf '%s\n' '--- 28 passed, 0 failed, 0 gated ---' >"$dir/release/verify-release-final.log"
+ printf 'sparkle:edSignature=abc123\n' >"$dir/release/appcast-update-final.log"
+ printf 'Website build completed\n' >"$dir/website/build-final.log"
+ printf '%s\n' '--- 7 passed, 0 failed ---' >"$dir/website/export-artifact-final.log"
+
+ write_manual_note "$dir/ui/screen-recording-final.md" yes
+ write_manual_note "$dir/ui/computer-use-final.md" yes
+ write_manual_note "$dir/hardware/second-display-final.md" yes
+ write_manual_note "$dir/upload/supabase-live-final.md" yes
+ write_manual_note "$dir/macos13/fallback-final.md" yes
+}
+
+complete_dir="$tmp_dir/complete"
+write_complete_packet "$complete_dir"
+if Scripts/verify-release-gate-evidence.sh "$complete_dir" >"$tmp_dir/complete.log" 2>&1; then
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, 0 gated ---' "$tmp_dir/complete.log"; then
+ pass "complete synthetic packet passes"
+ else
+ fail "complete synthetic packet did not report 0 gated"
+ fi
+else
+ cat "$tmp_dir/complete.log" >&2
+ fail "complete synthetic packet verifier exited nonzero"
+fi
+
+missing_metadata_dir="$tmp_dir/missing-metadata"
+cp -R "$complete_dir" "$missing_metadata_dir"
+sed -i.bak 's/^Final ZIP SHA-256: final-hash$/Final ZIP SHA-256:/' "$missing_metadata_dir/ui/screen-recording-final.md"
+if Scripts/verify-release-gate-evidence.sh "$missing_metadata_dir" >"$tmp_dir/missing-metadata.log" 2>&1; then
+ fail "missing metadata packet unexpectedly passed"
+elif grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$tmp_dir/missing-metadata.log"; then
+ pass "missing manual metadata is gated, not failed"
+else
+ cat "$tmp_dir/missing-metadata.log" >&2
+ fail "missing metadata packet did not report gated status"
+fi
+
+failed_log_dir="$tmp_dir/failed-log"
+cp -R "$complete_dir" "$failed_log_dir"
+printf '%s\n' '--- 20 passed, 1 failed, 0 gated ---' >"$failed_log_dir/release/verify-release-final.log"
+if Scripts/verify-release-gate-evidence.sh "$failed_log_dir" >"$tmp_dir/failed-log.log" 2>&1; then
+ fail "failed release log packet unexpectedly passed"
+elif grep -Eq -- '--- [0-9]+ passed, [1-9][0-9]* failed, [0-9]+ gated ---' "$tmp_dir/failed-log.log"; then
+ pass "failed release log is treated as failure"
+else
+ cat "$tmp_dir/failed-log.log" >&2
+ fail "failed release log packet did not report failure status"
+fi
+
+echo
+echo "--- $passes passed, $failures failed ---"
+if (( failures > 0 )); then
+ exit 1
+fi
diff --git a/Scripts/test-update-appcast.sh b/Scripts/test-update-appcast.sh
new file mode 100755
index 0000000..36ea68b
--- /dev/null
+++ b/Scripts/test-update-appcast.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# Dry-run the appcast updater with a fake Sparkle signer against temporary files.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/parcel-appcast-test.XXXXXX")"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+zip_path="$tmp_dir/Parcel.zip"
+appcast_path="$tmp_dir/appcast.xml"
+sign_update_path="$tmp_dir/sign_update"
+original_hash="$(shasum -a 256 Website/public/appcast.xml | awk '{print $1}')"
+
+app_bundle="$tmp_dir/Parcel.app"
+mkdir -p "$app_bundle/Contents/MacOS"
+cat >"$app_bundle/Contents/Info.plist" <<'PLIST'
+
+
+
+
+ CFBundleExecutable
+ Parcel
+ CFBundleIdentifier
+ dev.parable.Parcel
+ CFBundleName
+ Parcel
+ CFBundleDisplayName
+ Parcel
+ CFBundleShortVersionString
+ 9.8.7
+ CFBundleVersion
+ 654
+
+
+PLIST
+printf '#!/usr/bin/env bash\n' >"$app_bundle/Contents/MacOS/Parcel"
+chmod +x "$app_bundle/Contents/MacOS/Parcel"
+ditto -c -k --keepParent "$app_bundle" "$zip_path"
+cp Website/public/appcast.xml "$appcast_path"
+
+cat >"$sign_update_path" <<'FAKE_SIGN_UPDATE'
+#!/usr/bin/env bash
+set -euo pipefail
+zip="${@: -1}"
+length="$(stat -f %z "$zip")"
+echo "sparkle:edSignature=\"FAKE_SIGNATURE_FOR_APPCAST_TEST\" length=\"$length\""
+FAKE_SIGN_UPDATE
+chmod +x "$sign_update_path"
+
+APPCAST_PATH="$appcast_path" SPARKLE_SIGN_UPDATE="$sign_update_path" Scripts/update-appcast.sh "$zip_path" >"$tmp_dir/update.log"
+
+expected_length="$(stat -f %z "$zip_path")"
+actual_length="$(sed -n 's/.*length="\([^"]*\)".*/\1/p' "$appcast_path" | head -n 1)"
+actual_signature="$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' "$appcast_path" | head -n 1)"
+actual_short_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)"
+actual_build_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)"
+actual_title="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | sed -n '2p')"
+actual_pub_date="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)"
+current_hash="$(shasum -a 256 Website/public/appcast.xml | awk '{print $1}')"
+
+if [[ "$actual_length" != "$expected_length" ]]; then
+ echo "FAIL: appcast length '$actual_length' did not match '$expected_length'" >&2
+ exit 1
+fi
+if [[ "$actual_short_version" != "9.8.7" ]]; then
+ echo "FAIL: appcast short version was '$actual_short_version'" >&2
+ exit 1
+fi
+if [[ "$actual_build_version" != "654" ]]; then
+ echo "FAIL: appcast build version was '$actual_build_version'" >&2
+ exit 1
+fi
+if [[ "$actual_title" != "9.8.7" ]]; then
+ echo "FAIL: appcast item title was '$actual_title'" >&2
+ exit 1
+fi
+if ! date -j -f "%a, %d %b %Y %H:%M:%S %z" "$actual_pub_date" >/dev/null 2>&1; then
+ echo "FAIL: appcast pubDate was not parseable: '$actual_pub_date'" >&2
+ exit 1
+fi
+if [[ "$actual_signature" != "FAKE_SIGNATURE_FOR_APPCAST_TEST" ]]; then
+ echo "FAIL: appcast signature was '$actual_signature'" >&2
+ exit 1
+fi
+if [[ "$current_hash" != "$original_hash" ]]; then
+ echo "FAIL: Website/public/appcast.xml changed during dry-run" >&2
+ exit 1
+fi
+
+bad_appcast_path="$tmp_dir/bad-appcast.xml"
+printf 'Broken\n' >"$bad_appcast_path"
+bad_before_hash="$(shasum -a 256 "$bad_appcast_path" | awk '{print $1}')"
+if APPCAST_PATH="$bad_appcast_path" SPARKLE_SIGN_UPDATE="$sign_update_path" Scripts/update-appcast.sh "$zip_path" >"$tmp_dir/bad-update.log" 2>&1; then
+ echo "FAIL: malformed appcast update unexpectedly succeeded" >&2
+ exit 1
+fi
+bad_after_hash="$(shasum -a 256 "$bad_appcast_path" | awk '{print $1}')"
+if [[ "$bad_after_hash" != "$bad_before_hash" ]]; then
+ echo "FAIL: malformed appcast source changed during failed update" >&2
+ exit 1
+fi
+
+echo "PASS: appcast updater writes app version/build/title/pubDate, ZIP length, and signature in a temp appcast"
+echo "PASS: malformed appcast is rejected without modifying source"
+echo "PASS: real Website/public/appcast.xml unchanged"
diff --git a/Scripts/update-appcast.sh b/Scripts/update-appcast.sh
new file mode 100755
index 0000000..2ab8f6b
--- /dev/null
+++ b/Scripts/update-appcast.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+# Update Website/public/appcast.xml with the final app metadata, ZIP length, and Sparkle EdDSA signature.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+ZIP_PATH="${1:-$ROOT/Website/public/downloads/Parcel.zip}"
+APPCAST_PATH="${APPCAST_PATH:-$ROOT/Website/public/appcast.xml}"
+SPARKLE_KEYCHAIN_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-parcel.parable.dev}"
+
+find_sign_update() {
+ if [[ -n "${SPARKLE_SIGN_UPDATE:-}" && -x "$SPARKLE_SIGN_UPDATE" ]]; then
+ echo "$SPARKLE_SIGN_UPDATE"
+ return 0
+ fi
+ if command -v sign_update >/dev/null 2>&1; then
+ command -v sign_update
+ return 0
+ fi
+ local candidates=(
+ "$ROOT/.derivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ "$ROOT/.derivedData-release/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ )
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "$candidate" ]]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+file_size() {
+ if stat -f %z "$1" >/dev/null 2>&1; then
+ stat -f %z "$1"
+ else
+ stat -c %s "$1"
+ fi
+}
+
+rfc_822_utc_now() {
+ LC_ALL=C TZ=UTC date -u "+%a, %d %b %Y %H:%M:%S +0000"
+}
+
+validate_pub_date() {
+ local value="$1"
+ if date -j -f "%a, %d %b %Y %H:%M:%S %z" "$value" >/dev/null 2>&1; then
+ return 0
+ fi
+ if date -d "$value" >/dev/null 2>&1; then
+ return 0
+ fi
+ return 1
+}
+
+if [[ ! -f "$ZIP_PATH" ]]; then
+ echo "ERROR: ZIP not found: $ZIP_PATH" >&2
+ exit 1
+fi
+if [[ ! -f "$APPCAST_PATH" ]]; then
+ echo "ERROR: appcast not found: $APPCAST_PATH" >&2
+ exit 1
+fi
+
+tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/parcel-appcast-update.XXXXXX")"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+if ! ditto -x -k "$ZIP_PATH" "$tmp_dir"; then
+ echo "ERROR: ZIP does not extract with ditto: $ZIP_PATH" >&2
+ exit 1
+fi
+
+app_path="$(find "$tmp_dir" -maxdepth 1 -name '*.app' -print -quit)"
+if [[ -z "$app_path" || ! -d "$app_path" ]]; then
+ echo "ERROR: ZIP does not contain a top-level app bundle: $ZIP_PATH" >&2
+ exit 1
+fi
+
+info_plist="$app_path/Contents/Info.plist"
+if [[ ! -f "$info_plist" ]]; then
+ echo "ERROR: app bundle is missing Info.plist: $app_path" >&2
+ exit 1
+fi
+
+short_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$info_plist" 2>/dev/null || true)"
+build_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$info_plist" 2>/dev/null || true)"
+if [[ -z "$short_version" || -z "$build_version" ]]; then
+ echo "ERROR: app Info.plist is missing CFBundleShortVersionString or CFBundleVersion" >&2
+ exit 1
+fi
+
+SIGN_UPDATE="$(find_sign_update || true)"
+if [[ -z "$SIGN_UPDATE" ]]; then
+ echo "ERROR: Sparkle sign_update not found. Set SPARKLE_SIGN_UPDATE=/path/to/sign_update." >&2
+ exit 1
+fi
+
+if [[ -n "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then
+ signature_output="$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | "$SIGN_UPDATE" --ed-key-file - "$ZIP_PATH")"
+elif [[ -n "${SPARKLE_ED_KEY_FILE:-}" ]]; then
+ signature_output="$("$SIGN_UPDATE" --ed-key-file "$SPARKLE_ED_KEY_FILE" "$ZIP_PATH")"
+else
+ signature_output="$("$SIGN_UPDATE" --account "$SPARKLE_KEYCHAIN_ACCOUNT" "$ZIP_PATH")"
+fi
+
+length="$(echo "$signature_output" | sed -n 's/.*length="\([^"]*\)".*/\1/p' | head -n 1)"
+signature="$(echo "$signature_output" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' | head -n 1)"
+actual_length="$(file_size "$ZIP_PATH")"
+pub_date="$(rfc_822_utc_now)"
+
+if [[ -z "$length" || -z "$signature" ]]; then
+ echo "ERROR: could not parse sign_update output:" >&2
+ echo "$signature_output" >&2
+ exit 1
+fi
+if [[ "$length" != "$actual_length" ]]; then
+ echo "ERROR: sign_update length '$length' does not match ZIP length '$actual_length'" >&2
+ exit 1
+fi
+
+updated_appcast="$tmp_dir/appcast.updated.xml"
+cp "$APPCAST_PATH" "$updated_appcast"
+
+SHORT_VERSION="$short_version" BUILD_VERSION="$build_version" PUB_DATE="$pub_date" LENGTH="$length" SIGNATURE="$signature" perl -0pi -e '
+ s{(\s*)[^<]*()}{$1$ENV{SHORT_VERSION}$2}s;
+ s{()[^<]*()}{$1$ENV{BUILD_VERSION}$2}s;
+ s{()[^<]*()}{$1$ENV{SHORT_VERSION}$2}s;
+ s{()[^<]*()}{$1$ENV{PUB_DATE}$2}s;
+ s/length="[^"]*"/length="$ENV{LENGTH}"/s;
+ s/sparkle:edSignature="[^"]*"/sparkle:edSignature="$ENV{SIGNATURE}"/s;
+' "$updated_appcast"
+
+if ! command -v xmllint >/dev/null 2>&1; then
+ echo "ERROR: xmllint not found; cannot validate updated appcast XML." >&2
+ exit 1
+fi
+if ! xmllint --noout "$updated_appcast" >/dev/null 2>&1; then
+ echo "ERROR: updated appcast XML is not well-formed; original appcast was left unchanged." >&2
+ exit 1
+fi
+
+updated_title="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$updated_appcast" | sed -n '2p')"
+updated_build_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$updated_appcast" | head -n 1)"
+updated_short_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$updated_appcast" | head -n 1)"
+updated_pub_date="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$updated_appcast" | head -n 1)"
+updated_length="$(sed -n 's/.*length="\([^"]*\)".*/\1/p' "$updated_appcast" | head -n 1)"
+updated_signature="$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' "$updated_appcast" | head -n 1)"
+
+if [[ "$updated_title" != "$short_version" ]]; then
+ echo "ERROR: updated item title '$updated_title' does not match app short version '$short_version'" >&2
+ exit 1
+fi
+if [[ "$updated_short_version" != "$short_version" ]]; then
+ echo "ERROR: updated short version '$updated_short_version' does not match app short version '$short_version'" >&2
+ exit 1
+fi
+if [[ "$updated_build_version" != "$build_version" ]]; then
+ echo "ERROR: updated build version '$updated_build_version' does not match app build '$build_version'" >&2
+ exit 1
+fi
+if [[ "$updated_length" != "$length" ]]; then
+ echo "ERROR: updated length '$updated_length' does not match signer length '$length'" >&2
+ exit 1
+fi
+if [[ "$updated_signature" != "$signature" ]]; then
+ echo "ERROR: updated signature does not match signer output" >&2
+ exit 1
+fi
+if ! validate_pub_date "$updated_pub_date"; then
+ echo "ERROR: updated pubDate '$updated_pub_date' is not parseable" >&2
+ exit 1
+fi
+
+mv "$updated_appcast" "$APPCAST_PATH"
+
+echo "Updated $APPCAST_PATH"
+echo " sparkle:shortVersionString=$short_version"
+echo " sparkle:version=$build_version"
+echo " pubDate=$pub_date"
+echo " length=$length"
+echo " sparkle:edSignature=$signature"
diff --git a/Scripts/verify-no-network-ai.sh b/Scripts/verify-no-network-ai.sh
new file mode 100755
index 0000000..33d53ae
--- /dev/null
+++ b/Scripts/verify-no-network-ai.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Verify Parcel's app source contains no cloud/network AI integrations.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+passes=0
+failures=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+
+echo "==> Parcel no-network-AI verification"
+
+forbidden_pattern='OpenAI|ChatGPT|Anthropic|Claude|Gemini|generativelanguage|Mistral|Cohere|Perplexity|Replicate|HuggingFace|api\.openai|bedrock-runtime|AzureOpenAI|Raycast AI'
+if command -v rg >/dev/null 2>&1; then
+ forbidden_matches="$(rg -n -i "$forbidden_pattern" Sources/Parcel project.yml || true)"
+else
+ forbidden_matches="$({
+ find Sources/Parcel -type f \( \
+ -name '*.swift' -o -name '*.plist' -o -name '*.entitlements' \
+ -o -name '*.xcprivacy' -o -name '*.json' \
+ \) -exec grep -EniH "$forbidden_pattern" {} +
+ grep -EniH "$forbidden_pattern" project.yml
+ } 2>/dev/null || true)"
+fi
+if [[ -n "$forbidden_matches" ]]; then
+ echo "$forbidden_matches"
+ fail "Cloud/network AI identifiers found in app source"
+else
+ pass "No cloud/network AI identifiers in app source"
+fi
+
+if command -v rg >/dev/null 2>&1; then
+ network_matches="$(rg -n 'URLSession|URLRequest|NSURLConnection|WKWebView' Sources/Parcel -g '*.swift' || true)"
+else
+ network_matches="$(find Sources/Parcel -type f -name '*.swift' -exec grep -EnH 'URLSession|URLRequest|NSURLConnection|WKWebView' {} + || true)"
+fi
+if [[ -z "$network_matches" ]]; then
+ pass "No runtime network client APIs in app source"
+elif echo "$network_matches" | awk -F: '$1 != "Sources/Parcel/Upload/UploadService.swift" { bad = 1 } END { exit bad ? 0 : 1 }'; then
+ echo "$network_matches"
+ fail "Runtime network APIs appear outside optional Supabase upload"
+else
+ echo "$network_matches"
+ pass "Runtime network APIs are limited to optional Supabase upload"
+fi
+
+if command -v rg >/dev/null 2>&1; then
+ framework_matches="$(rg -n 'import Vision|import NaturalLanguage|import Translation' Sources/Parcel/Vision Sources/Parcel/Capture/ScrollCapture.swift || true)"
+else
+ framework_matches="$(grep -REn 'import Vision|import NaturalLanguage|import Translation' Sources/Parcel/Vision Sources/Parcel/Capture/ScrollCapture.swift || true)"
+fi
+if [[ -n "$framework_matches" ]]; then
+ echo "$framework_matches"
+ pass "Vision/translation code uses Apple local frameworks"
+else
+ fail "Apple local Vision/translation framework imports not found"
+fi
+
+echo
+echo "--- $passes passed, $failures failed ---"
+if (( failures > 0 )); then
+ exit 1
+fi
diff --git a/Scripts/verify-release-gate-evidence.sh b/Scripts/verify-release-gate-evidence.sh
new file mode 100755
index 0000000..77eaf0b
--- /dev/null
+++ b/Scripts/verify-release-gate-evidence.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# Verify that a release-gate handoff folder contains enough evidence to close external gates.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+EVIDENCE_DIR="${1:-$ROOT/qa-evidence/final-ship-2026-08-12/release-gate-handoff-current}"
+EXPECTED_TEAM="${EXPECTED_DEVELOPMENT_TEAM:-QFH99B6X5V}"
+
+passes=0
+failures=0
+gates=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+gate() { echo "GATE: $*"; gates=$((gates + 1)); }
+
+require_file() {
+ local label="$1"
+ local relpath="$2"
+ local path="$EVIDENCE_DIR/$relpath"
+ if [[ -s "$path" ]]; then
+ pass "$label evidence exists ($relpath)"
+ else
+ gate "$label evidence is missing or empty ($relpath)"
+ fi
+}
+
+require_clean_summary() {
+ local label="$1"
+ local relpath="$2"
+ local path="$EVIDENCE_DIR/$relpath"
+ if [[ ! -s "$path" ]]; then
+ gate "$label log is missing or empty ($relpath)"
+ return
+ fi
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, 0 gated ---' "$path"; then
+ pass "$label reports 0 failed / 0 gated"
+ elif grep -Eq -- '--- [0-9]+ passed, [1-9][0-9]* failed,' "$path"; then
+ fail "$label reports failures ($relpath)"
+ elif grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$path"; then
+ gate "$label still reports gated items ($relpath)"
+ else
+ gate "$label does not contain a recognized pass/fail/gate summary ($relpath)"
+ fi
+}
+
+require_plain_success_summary() {
+ local label="$1"
+ local relpath="$2"
+ local path="$EVIDENCE_DIR/$relpath"
+ if [[ ! -s "$path" ]]; then
+ gate "$label log is missing or empty ($relpath)"
+ return
+ fi
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed ---' "$path"; then
+ pass "$label reports 0 failed"
+ elif grep -Eq -- '--- [0-9]+ passed, [1-9][0-9]* failed ---' "$path"; then
+ fail "$label reports failures ($relpath)"
+ else
+ gate "$label does not contain a recognized pass/fail summary ($relpath)"
+ fi
+}
+
+require_contains() {
+ local label="$1"
+ local relpath="$2"
+ local pattern="$3"
+ local path="$EVIDENCE_DIR/$relpath"
+ if [[ ! -s "$path" ]]; then
+ gate "$label evidence is missing or empty ($relpath)"
+ elif grep -Eiq -- "$pattern" "$path"; then
+ pass "$label evidence matches expected content"
+ else
+ gate "$label evidence does not match expected content ($relpath)"
+ fi
+}
+
+require_manual_verified() {
+ local label="$1"
+ local relpath="$2"
+ local path="$EVIDENCE_DIR/$relpath"
+ if [[ ! -s "$path" ]]; then
+ gate "$label manual proof is missing or empty ($relpath)"
+ elif grep -Eiq '^VERIFIED:[[:space:]]*yes[[:space:]]*$' "$path"; then
+ local missing=0
+ for field in 'macOS:' 'Parcel app path:' 'Final ZIP SHA-256:' 'Tester:' 'Date:'; do
+ if ! grep -Eiq "^$field[[:space:]]*[^[:space:]]" "$path"; then
+ missing=1
+ fi
+ done
+ if (( missing == 0 )); then
+ pass "$label manual proof is marked VERIFIED: yes with required metadata"
+ else
+ gate "$label manual proof is verified but missing required metadata fields ($relpath)"
+ fi
+ else
+ gate "$label manual proof must include a line exactly like 'VERIFIED: yes' ($relpath)"
+ fi
+}
+
+echo "==> Parcel release gate evidence verification"
+echo " evidence: $EVIDENCE_DIR"
+
+if [[ -d "$EVIDENCE_DIR" ]]; then
+ pass "Evidence directory exists"
+else
+ fail "Evidence directory is missing: $EVIDENCE_DIR"
+ echo
+ echo "--- $passes passed, $failures failed, $gates gated ---"
+ exit 2
+fi
+
+require_file "Handoff packet" "RELEASE_GATE_HANDOFF.md"
+require_contains "Developer ID identity" "release/developer-id-identity.txt" "Developer ID Application:.*\\($EXPECTED_TEAM\\)"
+require_clean_summary "Final release preflight" "release/gate-preflight-final.log"
+require_contains "Public release build" "release/release-final.log" "==> Done:|Website artifact:"
+require_contains "Notarization" "release/notarization.log" "accepted|status:[[:space:]]*Accepted"
+require_contains "Stapling validation" "release/stapler-validate.log" "worked|accepted|valid"
+require_contains "Gatekeeper validation" "release/spctl-final.log" "accepted"
+require_clean_summary "Final public ZIP verifier" "release/verify-release-final.log"
+require_contains "Final appcast signing" "release/appcast-update-final.log" "sparkle:edSignature=|sparkle:edSignature=\"[^\"]+\""
+require_file "Website final build" "website/build-final.log"
+require_plain_success_summary "Website export artifact verifier" "website/export-artifact-final.log"
+require_manual_verified "Screen Recording TCC" "ui/screen-recording-final.md"
+require_manual_verified "Computer Use UI" "ui/computer-use-final.md"
+require_manual_verified "Second-display QA" "hardware/second-display-final.md"
+require_manual_verified "Live Supabase QA" "upload/supabase-live-final.md"
+require_manual_verified "macOS 13 fallback QA" "macos13/fallback-final.md"
+
+echo
+echo "--- $passes passed, $failures failed, $gates gated ---"
+if (( failures > 0 )); then
+ exit 2
+fi
+if (( gates > 0 )); then
+ exit 1
+fi
diff --git a/Scripts/verify-release-gates.sh b/Scripts/verify-release-gates.sh
new file mode 100755
index 0000000..ac7e27e
--- /dev/null
+++ b/Scripts/verify-release-gates.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# Preflight the external credentials, tools, and hardware needed for a public Parcel release.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+EXPECTED_TEAM="${EXPECTED_DEVELOPMENT_TEAM:-QFH99B6X5V}"
+SPARKLE_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-parcel.parable.dev}"
+WEBSITE_ZIP="$ROOT/Website/public/downloads/Parcel.zip"
+APPCAST_PATH="${APPCAST_PATH:-$ROOT/Website/public/appcast.xml}"
+
+passes=0
+failures=0
+gates=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+gate() { echo "GATE: $*"; gates=$((gates + 1)); }
+
+find_sign_update() {
+ if [[ -n "${SPARKLE_SIGN_UPDATE:-}" && -x "$SPARKLE_SIGN_UPDATE" ]]; then
+ echo "$SPARKLE_SIGN_UPDATE"
+ return 0
+ fi
+ if command -v sign_update >/dev/null 2>&1; then
+ command -v sign_update
+ return 0
+ fi
+ local candidates=(
+ "$ROOT/.derivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ "$ROOT/.derivedData-release/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ )
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "$candidate" ]]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+echo "==> Parcel release gate preflight"
+echo " expected team: $EXPECTED_TEAM"
+
+for tool in xcodegen xcodebuild security codesign ditto; do
+ if command -v "$tool" >/dev/null 2>&1; then
+ pass "$tool available"
+ else
+ fail "$tool is missing from PATH"
+ fi
+done
+
+if xcrun -f notarytool >/dev/null 2>&1; then
+ pass "notarytool available"
+else
+ fail "notarytool is missing"
+fi
+
+if xcrun -f stapler >/dev/null 2>&1; then
+ pass "stapler available"
+else
+ fail "stapler is missing"
+fi
+
+identities="$(security find-identity -v -p codesigning 2>/dev/null || true)"
+developer_id_lines="$(echo "$identities" | grep 'Developer ID Application:' || true)"
+if [[ -n "$developer_id_lines" ]]; then
+ pass "Developer ID Application identity installed"
+ if echo "$developer_id_lines" | grep -q "($EXPECTED_TEAM)"; then
+ pass "Developer ID identity matches team $EXPECTED_TEAM"
+ else
+ gate "Developer ID identity does not match expected team $EXPECTED_TEAM"
+ fi
+else
+ gate "Developer ID Application identity is not installed"
+fi
+
+if [[ -n "${DEVELOPMENT_TEAM:-}" ]]; then
+ if [[ "$DEVELOPMENT_TEAM" == "$EXPECTED_TEAM" ]]; then
+ pass "DEVELOPMENT_TEAM is set to $EXPECTED_TEAM"
+ else
+ gate "DEVELOPMENT_TEAM is '$DEVELOPMENT_TEAM', expected '$EXPECTED_TEAM'"
+ fi
+else
+ gate "DEVELOPMENT_TEAM is not set"
+fi
+
+if [[ -n "${NOTARYTOOL_PROFILE:-}" ]]; then
+ pass "NOTARYTOOL_PROFILE is set"
+elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_APP_PASSWORD:-}" && -n "${DEVELOPMENT_TEAM:-}" ]]; then
+ pass "Apple ID notary credentials are present in the environment"
+else
+ gate "Notary credentials are missing; set NOTARYTOOL_PROFILE or APPLE_ID + APPLE_APP_PASSWORD + DEVELOPMENT_TEAM"
+fi
+
+if sign_update_path="$(find_sign_update)"; then
+ pass "Sparkle sign_update available at $sign_update_path"
+else
+ gate "Sparkle sign_update is not available; resolve packages or set SPARKLE_SIGN_UPDATE"
+fi
+
+if [[ -n "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then
+ pass "SPARKLE_ED_PRIVATE_KEY is present"
+elif [[ -n "${SPARKLE_ED_KEY_FILE:-}" && -f "${SPARKLE_ED_KEY_FILE:-}" ]]; then
+ pass "SPARKLE_ED_KEY_FILE exists"
+elif security find-generic-password -a "$SPARKLE_ACCOUNT" >/dev/null 2>&1; then
+ pass "Sparkle EdDSA key appears to exist in Keychain account '$SPARKLE_ACCOUNT'"
+else
+ gate "Sparkle EdDSA private key is unavailable; set SPARKLE_ED_PRIVATE_KEY, SPARKLE_ED_KEY_FILE, or import account '$SPARKLE_ACCOUNT'"
+fi
+
+if [[ -f "$WEBSITE_ZIP" && -w "$WEBSITE_ZIP" ]]; then
+ pass "Website ZIP exists and is writable"
+else
+ gate "Website ZIP is missing or not writable: $WEBSITE_ZIP"
+fi
+
+if [[ -f "$APPCAST_PATH" && -w "$APPCAST_PATH" ]]; then
+ pass "Appcast exists and is writable"
+else
+ gate "Appcast is missing or not writable: $APPCAST_PATH"
+fi
+
+if [[ "${PARCEL_SCREEN_RECORDING_VERIFIED:-}" == "1" ]]; then
+ pass "Screen Recording was manually verified for the exact release/test app path"
+else
+ gate "Screen Recording must be granted and manually verified for the exact release/test app path"
+fi
+
+if [[ "${PARCEL_SECOND_DISPLAY_VERIFIED:-}" == "1" ]]; then
+ pass "Second-display QA was manually verified"
+else
+ display_count="$(system_profiler SPDisplaysDataType 2>/dev/null | grep -c 'Resolution:' || true)"
+ if [[ "${display_count:-0}" -ge 2 ]]; then
+ pass "Second display detected ($display_count displays)"
+ else
+ gate "Second-display QA requires an external display or PARCEL_SECOND_DISPLAY_VERIFIED=1"
+ fi
+fi
+
+if [[ -n "${PARCEL_SUPABASE_URL:-}" && -n "${PARCEL_SUPABASE_ANON_KEY:-}" && -n "${PARCEL_SUPABASE_BUCKET:-}" ]]; then
+ pass "Supabase live-test credentials are present in the environment"
+else
+ gate "Live Supabase upload QA needs PARCEL_SUPABASE_URL, PARCEL_SUPABASE_ANON_KEY, and PARCEL_SUPABASE_BUCKET"
+fi
+
+if [[ "${PARCEL_MACOS13_VM_VERIFIED:-}" == "1" ]]; then
+ pass "macOS 13 fallback QA was manually verified"
+else
+ gate "macOS 13 fallback QA requires a VM or secondary macOS 13 machine"
+fi
+
+echo
+echo "--- $passes passed, $failures failed, $gates gated ---"
+if (( failures > 0 )); then
+ exit 2
+fi
+if (( gates > 0 )); then
+ exit 1
+fi
diff --git a/Scripts/verify-release.sh b/Scripts/verify-release.sh
new file mode 100755
index 0000000..9c8b58e
--- /dev/null
+++ b/Scripts/verify-release.sh
@@ -0,0 +1,278 @@
+#!/usr/bin/env bash
+# Verify the public Parcel ZIP, embedded app, and Sparkle appcast are release-ready.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+ZIP_PATH="${1:-$ROOT/Website/public/downloads/Parcel.zip}"
+APPCAST_PATH="${APPCAST_PATH:-$ROOT/Website/public/appcast.xml}"
+EXPECTED_DOWNLOAD_URL="${EXPECTED_DOWNLOAD_URL:-https://parcel.parable.dev/downloads/Parcel.zip}"
+EXPECTED_BUNDLE_ID="${EXPECTED_BUNDLE_ID:-dev.parable.Parcel}"
+EXPECTED_DISPLAY_NAME="${EXPECTED_DISPLAY_NAME:-Parcel}"
+EXPECTED_MINIMUM_SYSTEM_VERSION="${EXPECTED_MINIMUM_SYSTEM_VERSION:-13.0}"
+EXPECTED_FEED_URL="${EXPECTED_FEED_URL:-https://parcel.parable.dev/appcast.xml}"
+EXPECTED_APPCAST_TITLE="${EXPECTED_APPCAST_TITLE:-Parcel}"
+EXPECTED_APPCAST_OS="${EXPECTED_APPCAST_OS:-macos}"
+EXPECTED_APPCAST_TYPE="${EXPECTED_APPCAST_TYPE:-application/octet-stream}"
+SPARKLE_KEYCHAIN_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-parcel.parable.dev}"
+
+passes=0
+failures=0
+gates=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+gate() { echo "GATE: $*"; gates=$((gates + 1)); }
+
+find_sign_update() {
+ if [[ -n "${SPARKLE_SIGN_UPDATE:-}" && -x "$SPARKLE_SIGN_UPDATE" ]]; then
+ echo "$SPARKLE_SIGN_UPDATE"
+ return 0
+ fi
+ if command -v sign_update >/dev/null 2>&1; then
+ command -v sign_update
+ return 0
+ fi
+ local candidates=(
+ "$ROOT/.derivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ "$ROOT/.derivedData-release/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
+ )
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "$candidate" ]]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+validate_pub_date() {
+ local value="$1"
+ if date -j -f "%a, %d %b %Y %H:%M:%S %z" "$value" >/dev/null 2>&1; then
+ return 0
+ fi
+ if date -d "$value" >/dev/null 2>&1; then
+ return 0
+ fi
+ return 1
+}
+
+echo "==> Parcel release verification"
+echo " zip: $ZIP_PATH"
+echo " appcast: $APPCAST_PATH"
+
+if [[ ! -f "$ZIP_PATH" ]]; then
+ fail "ZIP is missing"
+else
+ pass "ZIP exists"
+fi
+
+tmp_dir=""
+app_path=""
+if [[ -f "$ZIP_PATH" ]]; then
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/parcel-release-verify.XXXXXX")"
+ trap '[[ -n "$tmp_dir" ]] && rm -rf "$tmp_dir"' EXIT
+ if ditto -x -k "$ZIP_PATH" "$tmp_dir"; then
+ pass "ZIP extracts with ditto"
+ app_path="$(find "$tmp_dir" -maxdepth 1 -name '*.app' -print -quit)"
+ if [[ -n "$app_path" && -d "$app_path" ]]; then
+ pass "ZIP contains an app bundle"
+ else
+ fail "ZIP does not contain a top-level app bundle"
+ fi
+ else
+ fail "ZIP does not extract with ditto"
+ fi
+fi
+
+if [[ -n "$app_path" ]]; then
+ app_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_build="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_display_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_bundle_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_minimum_system_version="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_lsui_element="$(/usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+ app_feed_url="$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$app_path/Contents/Info.plist" 2>/dev/null || true)"
+
+ if [[ "$app_bundle_id" == "$EXPECTED_BUNDLE_ID" ]]; then
+ pass "Bundle ID matches expected value ($EXPECTED_BUNDLE_ID)"
+ else
+ fail "Bundle ID '$app_bundle_id' does not match expected '$EXPECTED_BUNDLE_ID'"
+ fi
+
+ if [[ "${app_display_name:-$app_bundle_name}" == "$EXPECTED_DISPLAY_NAME" ]]; then
+ pass "Display name matches expected value ($EXPECTED_DISPLAY_NAME)"
+ else
+ fail "Display name '$app_display_name' / bundle name '$app_bundle_name' does not match expected '$EXPECTED_DISPLAY_NAME'"
+ fi
+
+ if [[ "$app_minimum_system_version" == "$EXPECTED_MINIMUM_SYSTEM_VERSION" ]]; then
+ pass "Minimum macOS version matches expected value ($EXPECTED_MINIMUM_SYSTEM_VERSION)"
+ else
+ fail "Minimum macOS version '$app_minimum_system_version' does not match expected '$EXPECTED_MINIMUM_SYSTEM_VERSION'"
+ fi
+
+ if [[ "$app_lsui_element" == "true" ]]; then
+ pass "App is configured as menu-bar-only (LSUIElement=true)"
+ else
+ fail "LSUIElement is '$app_lsui_element', expected true"
+ fi
+
+ if [[ "$app_feed_url" == "$EXPECTED_FEED_URL" ]]; then
+ pass "Sparkle feed URL matches expected value"
+ else
+ fail "Sparkle feed URL '$app_feed_url' does not match expected '$EXPECTED_FEED_URL'"
+ fi
+
+ if file "$app_path/Contents/MacOS/Parcel" | grep -q "x86_64" &&
+ file "$app_path/Contents/MacOS/Parcel" | grep -q "arm64"; then
+ pass "App binary is universal x86_64 + arm64"
+ else
+ fail "App binary is not universal x86_64 + arm64"
+ fi
+
+ if codesign --verify --deep --strict "$app_path" >/dev/null 2>&1; then
+ pass "Code signature verifies"
+ else
+ fail "Code signature verification failed"
+ fi
+
+ signature_details="$(codesign -dv --verbose=4 "$app_path" 2>&1 || true)"
+ if echo "$signature_details" | grep -q "Authority=Developer ID Application:"; then
+ pass "Signed with Developer ID Application"
+ elif echo "$signature_details" | grep -q "Authority=Apple Development:"; then
+ gate "Signed with Apple Development, not Developer ID Application"
+ elif echo "$signature_details" | grep -q "Signature=adhoc"; then
+ gate "Ad-hoc signed, not Developer ID Application"
+ else
+ gate "Developer ID Application signature not found"
+ fi
+
+ entitlements="$(codesign -d --entitlements :- "$app_path" 2>/dev/null || true)"
+ entitlements_compact="$(echo "$entitlements" | tr -d '\n\t ')"
+ if [[ "$entitlements_compact" == *"com.apple.security.app-sandbox"* ]]; then
+ pass "Sandbox entitlement present"
+ else
+ fail "Sandbox entitlement missing"
+ fi
+ if echo "$entitlements" | grep -q "get-task-allow"; then
+ fail "Release entitlements contain get-task-allow"
+ else
+ pass "No get-task-allow entitlement"
+ fi
+ if [[ "$entitlements_compact" == *"com.apple.security.files.user-selected.read-write"* ]]; then
+ pass "User-selected read/write entitlement present"
+ else
+ fail "User-selected read/write entitlement missing"
+ fi
+ if [[ "$entitlements_compact" == *"com.apple.security.network.client"* ]]; then
+ pass "Network client entitlement present"
+ else
+ fail "Network client entitlement missing"
+ fi
+
+ if spctl -a -vv "$app_path" >/dev/null 2>&1; then
+ pass "Gatekeeper accepts app"
+ else
+ gate "Gatekeeper rejects app"
+ fi
+
+ if xcrun stapler validate "$app_path" >/dev/null 2>&1; then
+ pass "Stapled notarization ticket validates"
+ else
+ gate "Stapled notarization ticket missing or invalid"
+ fi
+fi
+
+if [[ ! -f "$APPCAST_PATH" ]]; then
+ fail "Appcast is missing"
+else
+ pass "Appcast exists"
+ if xmllint --noout "$APPCAST_PATH" >/dev/null 2>&1; then
+ pass "Appcast XML is well-formed"
+ else
+ fail "Appcast XML is not well-formed"
+ fi
+ appcast_title="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_version="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_build="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_pub_date="$(sed -n 's/.*\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_os="$(sed -n 's/.*sparkle:os="\([^"]*\)".*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_type="$(sed -n 's/.*type="\([^"]*\)".*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_length="$(sed -n 's/.*length="\([^"]*\)".*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ appcast_signature="$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' "$APPCAST_PATH" | head -n 1)"
+ zip_length=""
+ if [[ -f "$ZIP_PATH" ]]; then
+ zip_length="$(stat -f %z "$ZIP_PATH")"
+ fi
+
+ if [[ "$appcast_title" == "$EXPECTED_APPCAST_TITLE" ]]; then
+ pass "Appcast title matches expected value ($EXPECTED_APPCAST_TITLE)"
+ else
+ fail "Appcast title '$appcast_title' does not match expected '$EXPECTED_APPCAST_TITLE'"
+ fi
+
+ if [[ -n "$appcast_pub_date" ]] && validate_pub_date "$appcast_pub_date"; then
+ pass "Appcast pubDate is parseable"
+ else
+ fail "Appcast pubDate '$appcast_pub_date' is missing or invalid"
+ fi
+
+ if [[ "$appcast_os" == "$EXPECTED_APPCAST_OS" ]]; then
+ pass "Appcast Sparkle OS matches expected value ($EXPECTED_APPCAST_OS)"
+ else
+ fail "Appcast Sparkle OS '$appcast_os' does not match expected '$EXPECTED_APPCAST_OS'"
+ fi
+
+ if [[ "$appcast_type" == "$EXPECTED_APPCAST_TYPE" ]]; then
+ pass "Appcast enclosure type matches expected value ($EXPECTED_APPCAST_TYPE)"
+ else
+ fail "Appcast enclosure type '$appcast_type' does not match expected '$EXPECTED_APPCAST_TYPE'"
+ fi
+
+ if [[ -n "${app_version:-}" && -n "$appcast_version" && "$app_version" == "$appcast_version" ]]; then
+ pass "Appcast short version matches app ($app_version)"
+ else
+ fail "Appcast short version '$appcast_version' does not match app '${app_version:-unknown}'"
+ fi
+
+ if [[ -n "${app_build:-}" && -n "$appcast_build" && "$app_build" == "$appcast_build" ]]; then
+ pass "Appcast build version matches app ($app_build)"
+ else
+ fail "Appcast build version '$appcast_build' does not match app '${app_build:-unknown}'"
+ fi
+
+ if grep -q "url=\"$EXPECTED_DOWNLOAD_URL\"" "$APPCAST_PATH"; then
+ pass "Appcast download URL matches expected URL"
+ else
+ gate "Appcast download URL does not match expected URL '$EXPECTED_DOWNLOAD_URL'"
+ fi
+
+ if [[ -n "$zip_length" && "$appcast_length" == "$zip_length" ]]; then
+ pass "Appcast enclosure length matches ZIP"
+ else
+ gate "Appcast enclosure length is '$appcast_length' but ZIP length is '${zip_length:-missing}'"
+ fi
+
+ if [[ -n "$appcast_signature" && "$appcast_signature" != *REPLACE* ]]; then
+ pass "Appcast EdDSA signature is populated"
+ if sign_update_path="$(find_sign_update)"; then
+ if "$sign_update_path" --account "$SPARKLE_KEYCHAIN_ACCOUNT" --verify "$ZIP_PATH" "$appcast_signature" >/dev/null 2>&1; then
+ pass "Appcast EdDSA signature verifies against ZIP"
+ else
+ fail "Appcast EdDSA signature does not verify against ZIP"
+ fi
+ else
+ gate "Sparkle sign_update not found, signature verification skipped"
+ fi
+ else
+ gate "Appcast EdDSA signature is missing or placeholder"
+ fi
+fi
+
+echo
+echo "--- $passes passed, $failures failed, $gates gated ---"
+if (( failures > 0 || gates > 0 )); then
+ exit 1
+fi
diff --git a/Scripts/verify-sparkle-key-consistency.sh b/Scripts/verify-sparkle-key-consistency.sh
new file mode 100755
index 0000000..95dbfe5
--- /dev/null
+++ b/Scripts/verify-sparkle-key-consistency.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+# Verify Parcel's embedded Sparkle public key matches the configured signing account.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+SPARKLE_KEYCHAIN_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-parcel.parable.dev}"
+SPARKLE_KEY_TIMEOUT_SECONDS="${SPARKLE_KEY_TIMEOUT_SECONDS:-10}"
+SOURCE_PLIST="$ROOT/Sources/Parcel/Resources/Info.plist"
+WEBSITE_ZIP="${WEBSITE_ZIP:-$ROOT/Website/public/downloads/Parcel.zip}"
+
+passes=0
+failures=0
+gates=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+gate() { echo "GATE: $*"; gates=$((gates + 1)); }
+
+finish() {
+ echo
+ echo "--- $passes passed, $failures failed, $gates gated ---"
+ if (( failures > 0 )); then
+ exit 2
+ fi
+ if (( gates > 0 )); then
+ exit 1
+ fi
+}
+
+find_generate_keys() {
+ if [[ -n "${SPARKLE_GENERATE_KEYS:-}" && -x "$SPARKLE_GENERATE_KEYS" ]]; then
+ echo "$SPARKLE_GENERATE_KEYS"
+ return 0
+ fi
+ if command -v generate_keys >/dev/null 2>&1; then
+ command -v generate_keys
+ return 0
+ fi
+ local candidates=(
+ "$ROOT/.derivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys"
+ "$ROOT/.derivedData-release/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys"
+ )
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "$candidate" ]]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+run_generate_keys_with_timeout() {
+ local output_file="$1"
+ "$generate_keys_path" --account "$SPARKLE_KEYCHAIN_ACCOUNT" -p >"$output_file" 2>&1 &
+ local key_pid=$!
+ local elapsed=0
+
+ while kill -0 "$key_pid" >/dev/null 2>&1; do
+ if (( elapsed >= SPARKLE_KEY_TIMEOUT_SECONDS )); then
+ pkill -TERM -P "$key_pid" 2>/dev/null || true
+ kill "$key_pid" 2>/dev/null || true
+ wait "$key_pid" 2>/dev/null || true
+ return 124
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+ done
+
+ wait "$key_pid"
+}
+
+plist_key() {
+ /usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$1" 2>/dev/null || true
+}
+
+if [[ ! -f "$SOURCE_PLIST" ]]; then
+ fail "Source Info.plist is missing"
+ finish
+fi
+
+source_key="$(plist_key "$SOURCE_PLIST")"
+if [[ -n "$source_key" && "$source_key" != *REPLACE* ]]; then
+ pass "Source SUPublicEDKey is populated"
+else
+ fail "Source SUPublicEDKey is missing or placeholder"
+fi
+
+generate_keys_path="$(find_generate_keys || true)"
+if [[ -z "$generate_keys_path" ]]; then
+ gate "Sparkle generate_keys is unavailable; resolve packages or set SPARKLE_GENERATE_KEYS"
+else
+ pass "Sparkle generate_keys available at $generate_keys_path"
+ tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/parcel-sparkle-key.XXXXXX")"
+ trap 'rm -rf "$tmp_dir"' EXIT
+ key_output="$tmp_dir/generate_keys.log"
+
+ if run_generate_keys_with_timeout "$key_output"; then
+ keychain_key="$(tail -n 1 "$key_output" | tr -d '[:space:]')"
+ if [[ -n "$keychain_key" && "$keychain_key" != *REPLACE* ]]; then
+ pass "Keychain Sparkle public key is readable for account '$SPARKLE_KEYCHAIN_ACCOUNT'"
+ if [[ -n "$source_key" && "$source_key" == "$keychain_key" ]]; then
+ pass "Source SUPublicEDKey matches Sparkle Keychain account"
+ else
+ fail "Source SUPublicEDKey does not match Sparkle Keychain account"
+ fi
+ else
+ gate "Sparkle Keychain public key output was empty"
+ sed 's/^/ /' "$key_output" || true
+ fi
+ else
+ key_status=$?
+ if [[ "$key_status" == "124" ]]; then
+ gate "Reading Sparkle public key timed out waiting for Keychain access"
+ else
+ gate "Could not read Sparkle public key for account '$SPARKLE_KEYCHAIN_ACCOUNT'"
+ fi
+ sed 's/^/ /' "$key_output" || true
+ fi
+fi
+
+if [[ -f "$WEBSITE_ZIP" ]]; then
+ zip_tmp="$(mktemp -d "${TMPDIR:-/tmp}/parcel-sparkle-zip.XXXXXX")"
+ trap 'rm -rf "$zip_tmp" ${tmp_dir:-}' EXIT
+ if ditto -x -k "$WEBSITE_ZIP" "$zip_tmp" >/dev/null 2>&1; then
+ zip_app="$(find "$zip_tmp" -maxdepth 1 -name '*.app' -print -quit)"
+ if [[ -n "$zip_app" && -f "$zip_app/Contents/Info.plist" ]]; then
+ zip_key="$(plist_key "$zip_app/Contents/Info.plist")"
+ if [[ -n "$source_key" && "$zip_key" == "$source_key" ]]; then
+ pass "Website ZIP app SUPublicEDKey matches source"
+ else
+ fail "Website ZIP app SUPublicEDKey does not match source"
+ fi
+ else
+ fail "Website ZIP does not contain a top-level app with Info.plist"
+ fi
+ else
+ fail "Website ZIP could not be extracted"
+ fi
+else
+ gate "Website ZIP is missing; ZIP key comparison skipped"
+fi
+
+finish
diff --git a/Scripts/verify-website-export-artifact.sh b/Scripts/verify-website-export-artifact.sh
new file mode 100755
index 0000000..ed9844b
--- /dev/null
+++ b/Scripts/verify-website-export-artifact.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+# Verify the static website export contains the current public ZIP and Sparkle appcast.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+PUBLIC_DIR="$ROOT/Website/public"
+OUT_DIR="$ROOT/Website/out"
+
+passes=0
+failures=0
+
+pass() { echo "PASS: $*"; passes=$((passes + 1)); }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+
+file_size() {
+ if stat -f %z "$1" >/dev/null 2>&1; then
+ stat -f %z "$1"
+ else
+ stat -c %s "$1"
+ fi
+}
+
+sha256_digest() {
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$1" | awk '{ print $1 }'
+ elif command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{ print $1 }'
+ else
+ echo "sha256-unavailable"
+ fi
+}
+
+echo "==> Parcel website export artifact verification"
+echo " public: $PUBLIC_DIR"
+echo " out: $OUT_DIR"
+
+if [[ -d "$OUT_DIR" ]]; then
+ pass "Website/out exists"
+else
+ fail "Website/out is missing; run npm --prefix Website run build first"
+fi
+
+for relative in downloads/Parcel.zip appcast.xml; do
+ public_file="$PUBLIC_DIR/$relative"
+ out_file="$OUT_DIR/$relative"
+
+ if [[ -f "$public_file" ]]; then
+ pass "Public asset exists: $relative"
+ else
+ fail "Public asset missing: $relative"
+ continue
+ fi
+
+ if [[ -f "$out_file" ]]; then
+ pass "Exported asset exists: $relative"
+ else
+ fail "Exported asset missing: $relative"
+ continue
+ fi
+
+ if cmp -s "$public_file" "$out_file"; then
+ size="$(file_size "$out_file")"
+ digest="$(sha256_digest "$out_file")"
+ pass "Exported asset matches public source: $relative ($size bytes, sha256 $digest)"
+ else
+ fail "Exported asset differs from public source: $relative"
+ fi
+done
+
+echo
+echo "--- $passes passed, $failures failed ---"
+if (( failures > 0 )); then
+ exit 1
+fi
diff --git a/Scripts/verify-workflows.sh b/Scripts/verify-workflows.sh
new file mode 100755
index 0000000..7306880
--- /dev/null
+++ b/Scripts/verify-workflows.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# Validate GitHub workflow YAML and embedded shell run blocks.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if (( $# == 0 )); then
+ set -- .github/workflows/build.yml .github/workflows/release.yml
+fi
+
+if command -v actionlint >/dev/null 2>&1; then
+ actionlint "$@"
+ echo "ACTIONLINT OK"
+else
+ echo "SKIP: actionlint not available"
+fi
+
+ruby -ryaml -rtempfile - "$@" <<'RUBY'
+ARGV.each do |workflow|
+ data = YAML.load_file(workflow)
+ puts "YAML OK: #{workflow}"
+
+ data.fetch("jobs").each do |job_name, job|
+ Array(job["steps"]).each_with_index do |step, index|
+ run = step["run"]
+ next unless run
+
+ Tempfile.create(["gha-run-", ".sh"]) do |file|
+ file.write(run)
+ file.flush
+ ok = system("bash", "-n", file.path)
+ raise "bash -n failed for #{workflow} job #{job_name} step #{index + 1}" unless ok
+ end
+
+ puts "RUN SHELL OK: #{workflow} job #{job_name} step #{index + 1}"
+ end
+ end
+end
+RUBY
diff --git a/Scripts/write-release-gate-handoff.sh b/Scripts/write-release-gate-handoff.sh
new file mode 100755
index 0000000..7775975
--- /dev/null
+++ b/Scripts/write-release-gate-handoff.sh
@@ -0,0 +1,203 @@
+#!/usr/bin/env bash
+# Generate a release-machine handoff packet for the external gates that cannot be proven locally.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+STAMP="${STAMP:-$(date +%Y-%m-%d-%H%M%S)}"
+EVIDENCE_DIR="${EVIDENCE_DIR:-$ROOT/qa-evidence/release-gate-handoff-$STAMP}"
+HANDOFF_PATH="${HANDOFF_PATH:-$EVIDENCE_DIR/RELEASE_GATE_HANDOFF.md}"
+WEBSITE_ZIP="${WEBSITE_ZIP:-$ROOT/Website/public/downloads/Parcel.zip}"
+
+mkdir -p "$EVIDENCE_DIR"/{release,ui,hardware,upload,macos13,website}
+
+classify_status() {
+ local path="$1"
+ if grep -Eq -- '--- [0-9]+ passed, 0 failed, 0 gated ---' "$path"; then
+ echo "pass"
+ elif grep -Eq -- '--- [0-9]+ passed, 0 failed, [1-9][0-9]* gated ---' "$path"; then
+ echo "gated"
+ else
+ echo "fail"
+ fi
+}
+
+preflight_log="$EVIDENCE_DIR/release/gate-preflight.log"
+if Scripts/verify-release-gates.sh >"$preflight_log" 2>&1; then
+ preflight_status="pass"
+else
+ preflight_status="$(classify_status "$preflight_log")"
+fi
+
+verify_log="$EVIDENCE_DIR/release/verify-release-current.log"
+if Scripts/verify-release.sh "$WEBSITE_ZIP" >"$verify_log" 2>&1; then
+ verify_status="pass"
+else
+ verify_status="$(classify_status "$verify_log")"
+fi
+
+short_sha="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
+branch="$(git branch --show-current 2>/dev/null || echo unknown)"
+generated_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+if [[ -n "$(git status --short 2>/dev/null || true)" ]]; then
+ worktree_state="dirty"
+else
+ worktree_state="clean"
+fi
+if [[ -f "$WEBSITE_ZIP" ]]; then
+ zip_sha="$(shasum -a 256 "$WEBSITE_ZIP" | awk '{print $1}')"
+else
+ zip_sha="missing"
+fi
+
+write_manual_template() {
+ local relpath="$1"
+ local title="$2"
+ local checks="$3"
+ local path="$EVIDENCE_DIR/$relpath"
+ if [[ -e "$path" ]]; then
+ if grep -Eiq '^VERIFIED:[[:space:]]*yes[[:space:]]*$' "$path"; then
+ return
+ fi
+ fi
+
+ cat >"$path" <"$HANDOFF_PATH" < AfterCapturePlan {
+ var plan = AfterCapturePlan()
+ plan.copy = preferences.copy || intent == .forceCopy
+ plan.upload = preferences.upload
+ plan.save = preferences.save || intent == .forceSave
+ plan.pin = preferences.pin || intent == .forcePin
+ plan.openEditor = preferences.openEditor || intent == .forceEditor
+
+ guard !plan.openEditor else { return plan }
+
+ let forcedOnly = intent == .forceCopy || intent == .forceSave || intent == .forcePin
+ if preferences.useQuickAccess && !forcedOnly {
+ plan.showQuickAccess = true
+ } else if intent == .standard,
+ !preferences.pin,
+ !preferences.openEditor,
+ !preferences.useQuickAccess {
+ plan.openEditor = true
+ }
+ return plan
+ }
+}
diff --git a/Sources/Parcel/App/AppCoordinator.swift b/Sources/Parcel/App/AppCoordinator.swift
index f9c7506..bacd58b 100644
--- a/Sources/Parcel/App/AppCoordinator.swift
+++ b/Sources/Parcel/App/AppCoordinator.swift
@@ -9,11 +9,15 @@ final class AppCoordinator: ObservableObject {
@Published private(set) var isCapturing = false
@Published private(set) var isRecording = false
+ @Published private(set) var isPausedRecording = false
@Published private(set) var isStartingRecording = false
@Published private(set) var isScrollCapturing = false
@Published private(set) var isAddingScrollFrame = false
@Published private(set) var scrollFrameCount = 0
@Published private(set) var captureDelayRemaining: TimeInterval?
+ @Published private(set) var recordingCountdownRemaining: TimeInterval?
+ @Published private(set) var statusBanner: String?
+ @Published private(set) var pinsHidden = false
private let hotKeys = HotKeyManager()
private let captureController = CaptureController()
@@ -21,16 +25,29 @@ final class AppCoordinator: ObservableObject {
private let history = HistoryStore()
private let recorder = ScreenRecorder()
private var scrollSession: ScrollCaptureSession?
- private lazy var historyWindow = HistoryWindowController(store: history) { [weak self] id in
- self?.openHistoryEntry(id)
- }
+ private lazy var historyWindow = HistoryWindowController(
+ store: history,
+ onOpen: { [weak self] id in self?.openHistoryEntry(id) },
+ onPin: { [weak self] id in self?.pinHistoryEntry(id) }
+ )
private var editors: [EditorWindowController] = []
private var recordingEditors: [RecordingWindowController] = []
+ private var quickAccess: QuickAccessOverlayController?
+ private var pinnedCaptures: [PinnedCaptureController] = []
+ private var recentlyClosed = RecentlyClosedCaptures()
+ private var lastCapture: Capture?
+ private var pendingRecordingSelection: SelectionResult?
+ private let keystrokeHUD = KeystrokeHUDController()
+ private let webcamPiP = WebcamPiPController()
+ private var cancellables = Set()
+ private var doNotDisturbEngaged = false
+
+ private var pendingIntent: CaptureIntent = .standard
/// Called once from the app delegate after launch.
func start() {
captureController.onCaptureComplete = { [weak self] capture in
- self?.openEditor(with: capture)
+ self?.presentCapture(capture)
}
captureController.onScrollSelection = { [weak self] selection, initialCapture in
guard let self else { return }
@@ -44,17 +61,27 @@ final class AppCoordinator: ObservableObject {
captureController.onFailure = { [weak self] message in
self?.presentMessage(title: "Capture Failed", message: message)
}
- hotKeys.onHotKey = { [weak self] in
- self?.beginRegionCapture()
+ captureController.onOCRComplete = { [weak self] text in
+ self?.flashStatus("Copied \(text.count) characters of text")
+ }
+ captureController.onRecordingSelection = { [weak self] selection in
+ self?.promptRecordingSavePanel(for: selection)
}
- NotificationCenter.default.addObserver(
- forName: .hotKeyPreferencesDidChange, object: nil, queue: .main
- ) { [weak self] _ in
- self?.hotKeys.registerDefault()
+ hotKeys.onAction = { [weak self] action in
+ self?.handleHotKey(action)
}
+ NotificationCenter.default.publisher(for: .hotKeyPreferencesDidChange)
+ .receive(on: RunLoop.main)
+ .sink { [weak self] _ in
+ self?.hotKeys.registerDefault()
+ }
+ .store(in: &cancellables)
recorder.onFailure = { [weak self] error in
+ self?.stopRecordingOverlays()
self?.isRecording = false
+ self?.isPausedRecording = false
self?.isStartingRecording = false
+ self?.endDoNotDisturbIfNeeded()
if let recorderError = error as? ScreenRecorder.RecorderError,
recorderError == .permissionDenied {
self?.openPreferences()
@@ -67,14 +94,50 @@ final class AppCoordinator: ObservableObject {
func shutdown() {
hotKeys.unregisterAll()
+ stopRecordingOverlays()
+ endDoNotDisturbIfNeeded()
+ DesktopIconHider.endSession()
+ }
+
+ func handleURL(_ url: URL) {
+ guard CapturePreferences.urlSchemeEnabled else {
+ flashStatus("URL scheme is disabled in Preferences")
+ return
+ }
+ ParcelURLRouter.route(url, coordinator: self)
}
// MARK: Actions
func beginRegionCapture() {
+ pendingIntent = .standard
+ beginCapture(mode: .region)
+ }
+
+ func beginWindowCapture() {
+ pendingIntent = .standard
+ beginCapture(mode: .window)
+ }
+
+ func beginFullscreenCapture() {
guard !isCapturing else { return }
+ pendingIntent = .standard
isCapturing = true
- captureController.begin { [weak self] in
+ captureController.beginFullscreen { [weak self] in
+ self?.isCapturing = false
+ }
+ }
+
+ func beginOCRCapture() {
+ pendingIntent = .standard
+ beginCapture(mode: .ocr)
+ }
+
+ func beginPreviousAreaCapture() {
+ guard !isCapturing, !isScrollCapturing else { return }
+ pendingIntent = .standard
+ isCapturing = true
+ captureController.beginPreviousArea { [weak self] in
self?.isCapturing = false
}
}
@@ -85,18 +148,19 @@ final class AppCoordinator: ObservableObject {
beginRegionCapture()
return
}
+ pendingIntent = .standard
isCapturing = true
captureDelayRemaining = delay
Task { [weak self] in
var remaining = delay
while remaining > 0 {
try? await Task.sleep(nanoseconds: 100_000_000)
- remaining -= 0.1
- self?.captureDelayRemaining = max(0, remaining)
+ remaining = CountdownDisplay.nextRemaining(after: remaining)
+ self?.captureDelayRemaining = remaining
}
self?.captureDelayRemaining = nil
guard let self else { return }
- captureController.begin { [weak self] in
+ captureController.begin(mode: .region) { [weak self] in
self?.isCapturing = false
self?.captureDelayRemaining = nil
}
@@ -105,6 +169,7 @@ final class AppCoordinator: ObservableObject {
func beginAllDisplaysCapture() {
guard !isCapturing else { return }
+ pendingIntent = .standard
isCapturing = true
captureController.beginAllDisplays { [weak self] in
self?.isCapturing = false
@@ -112,9 +177,28 @@ final class AppCoordinator: ObservableObject {
}
func beginScrollCapture() {
+ pendingIntent = .standard
+ beginCapture(mode: .scroll)
+ }
+
+ func beginAreaCapture(rect: CGRect, displayID: CGDirectDisplayID?) {
+ guard !isCapturing else { return }
+ pendingIntent = .standard
+ // Store as previous area then re-capture.
+ if let displayID {
+ CapturePreferences.lastSelectionDisplayID = displayID
+ }
+ CapturePreferences.lastSelectionX = rect.origin.x
+ CapturePreferences.lastSelectionY = rect.origin.y
+ CapturePreferences.lastSelectionWidth = rect.width
+ CapturePreferences.lastSelectionHeight = rect.height
+ beginPreviousAreaCapture()
+ }
+
+ private func beginCapture(mode: OverlayCaptureMode) {
guard !isCapturing, !isScrollCapturing else { return }
isCapturing = true
- captureController.beginScrollSelection { [weak self] in
+ captureController.begin(mode: mode) { [weak self] in
self?.isCapturing = false
}
}
@@ -141,7 +225,7 @@ final class AppCoordinator: ObservableObject {
isScrollCapturing = false
isAddingScrollFrame = false
scrollFrameCount = 0
- openEditor(with: capture)
+ presentCapture(capture)
}
func cancelScrollCapture() {
@@ -159,55 +243,382 @@ final class AppCoordinator: ObservableObject {
historyWindow.show()
}
+ func restoreRecentlyClosed() {
+ guard let capture = recentlyClosed.restore() else {
+ flashStatus("Nothing to restore")
+ return
+ }
+ presentCapture(capture)
+ }
+
+ func hideAllOverlays() {
+ pinsHidden.toggle()
+ for pin in pinnedCaptures {
+ pin.setHidden(pinsHidden)
+ }
+ if pinsHidden {
+ quickAccess?.close()
+ flashStatus("Overlays hidden")
+ } else {
+ flashStatus("Overlays shown")
+ }
+ }
+
+ func closeAllPins() {
+ let pins = pinnedCaptures
+ pinnedCaptures.removeAll()
+ pins.forEach { $0.close() }
+ }
+
+ func openFromClipboard() {
+ let scale = NSScreen.main?.backingScaleFactor ?? 2
+ guard let capture = ClipboardCaptureReader.capture(from: .general, scale: scale) else {
+ flashStatus("Clipboard has no image")
+ return
+ }
+ _ = history.createDocument(for: capture) // include external opens in History
+ presentCapture(capture)
+ }
+
+ func annotateLastCapture() {
+ guard let lastCapture else {
+ flashStatus("No recent Capture")
+ return
+ }
+ openEditor(with: lastCapture)
+ }
+
func toggleRecording() {
if isRecording {
- stopRecording()
+ if isPausedRecording {
+ resumeRecording()
+ } else {
+ stopRecording()
+ }
} else {
beginRecording()
}
}
+ func pauseOrResumeRecording() {
+ guard isRecording else { return }
+ if isPausedRecording {
+ resumeRecording()
+ } else {
+ pauseRecording()
+ }
+ }
+
private func beginRecording() {
- guard !isStartingRecording, !isCapturing else { return }
+ guard !isStartingRecording, !isCapturing, !isRecording else { return }
+ isCapturing = true
+ captureController.beginRecordingSelection { [weak self] in
+ self?.isCapturing = false
+ }
+ }
+
+ /// Re-records the last recording Selection without showing the Overlay.
+ func beginPreviousRecordingArea() {
+ guard !isStartingRecording, !isCapturing, !isRecording else { return }
+ guard CapturePreferences.hasPreviousRecordingArea else {
+ flashStatus("No previous recording area")
+ return
+ }
+ isCapturing = true
+ captureController.beginPreviousRecordingArea { [weak self] in
+ self?.isCapturing = false
+ }
+ }
+
+ private func promptRecordingSavePanel(for selection: SelectionResult) {
+ pendingRecordingSelection = selection
+ let countdown = RecordingPreferences.countdownSeconds
+ if countdown > 0 {
+ runRecordingCountdown(seconds: countdown) { [weak self] in
+ self?.startRecording(to: nil, selection: selection)
+ }
+ return
+ }
let panel = NSSavePanel()
panel.allowedContentTypes = [.mpeg4Movie]
panel.canCreateDirectories = true
panel.nameFieldStringValue = recordingFileName()
panel.begin { [weak self] response in
guard response == .OK, let url = panel.url, let self else { return }
- isStartingRecording = true
- Task { [weak self] in
- guard let self else { return }
- do {
- try await recorder.start(to: url)
- isRecording = true
- } catch let error as ScreenRecorder.RecorderError where error == .permissionDenied {
- openPreferences()
- } catch {
- presentError(title: "Could Not Start Recording", error: error)
- }
- isStartingRecording = false
+ startRecording(to: url, selection: selection)
+ }
+ }
+
+ private func runRecordingCountdown(seconds: TimeInterval, then: @escaping () -> Void) {
+ recordingCountdownRemaining = seconds
+ Task { @MainActor [weak self] in
+ var remaining = seconds
+ while remaining > 0 {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ remaining = CountdownDisplay.nextRemaining(after: remaining)
+ self?.recordingCountdownRemaining = remaining
}
+ self?.recordingCountdownRemaining = nil
+ then()
+ }
+ }
+
+ private func startRecording(to url: URL?, selection: SelectionResult) {
+ let destination: URL
+ if let url {
+ destination = url
+ } else {
+ let temp = FileManager.default.temporaryDirectory
+ .appendingPathComponent(recordingFileName())
+ destination = temp
+ }
+ isStartingRecording = true
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await recorder.start(to: destination, selection: selection)
+ isRecording = true
+ isPausedRecording = false
+ beginDoNotDisturbIfNeeded()
+ startRecordingOverlays()
+ } catch let error as ScreenRecorder.RecorderError where error == .permissionDenied {
+ openPreferences()
+ } catch {
+ presentError(title: "Could Not Start Recording", error: error)
+ }
+ isStartingRecording = false
+ }
+ }
+
+ private func pauseRecording() {
+ recorder.pause()
+ isPausedRecording = true
+ flashStatus("Recording paused")
+ }
+
+ private func resumeRecording() {
+ recorder.resume()
+ isPausedRecording = false
+ flashStatus("Recording resumed")
+ }
+
+ func restartRecording() {
+ guard isRecording, let selection = pendingRecordingSelection else { return }
+ Task { [weak self] in
+ guard let self else { return }
+ stopRecordingOverlays()
+ _ = try? await recorder.stop()
+ isRecording = false
+ isPausedRecording = false
+ startRecording(to: nil, selection: selection)
}
}
private func stopRecording() {
Task { [weak self] in
guard let self else { return }
+ stopRecordingOverlays()
+ endDoNotDisturbIfNeeded()
do {
let url = try await recorder.stop()
isRecording = false
+ isPausedRecording = false
openRecordingEditor(url: url)
} catch {
isRecording = false
+ isPausedRecording = false
presentError(title: "Could Not Stop Recording", error: error)
}
}
}
+ private func startRecordingOverlays() {
+ keystrokeHUD.start()
+ webcamPiP.start()
+ }
+
+ private func stopRecordingOverlays() {
+ keystrokeHUD.stop()
+ webcamPiP.stop()
+ }
+
+ private func beginDoNotDisturbIfNeeded() {
+ guard RecordingPreferences.enableDoNotDisturb, !doNotDisturbEngaged else { return }
+ doNotDisturbEngaged = FocusAssist.setDoNotDisturb(true)
+ }
+
+ private func endDoNotDisturbIfNeeded() {
+ guard doNotDisturbEngaged else { return }
+ _ = FocusAssist.setDoNotDisturb(false)
+ doNotDisturbEngaged = false
+ }
+
+ // MARK: Hotkeys
+
+ private func handleHotKey(_ action: HotKeyManager.Action) {
+ switch action {
+ case .captureRegion:
+ beginRegionCapture()
+ case .captureCopy:
+ pendingIntent = .forceCopy
+ beginCapture(mode: .region)
+ case .captureAnnotate:
+ pendingIntent = .forceEditor
+ beginCapture(mode: .region)
+ case .capturePin:
+ pendingIntent = .forcePin
+ beginCapture(mode: .region)
+ case .captureSave:
+ pendingIntent = .forceSave
+ beginCapture(mode: .region)
+ case .capturePrevious:
+ beginPreviousAreaCapture()
+ case .openClipboard:
+ openFromClipboard()
+ case .restoreClosed:
+ restoreRecentlyClosed()
+ case .hideOverlays:
+ hideAllOverlays()
+ case .annotateLast:
+ annotateLastCapture()
+ case .ocr:
+ beginOCRCapture()
+ }
+ }
+
+ // MARK: Capture presentation
+
+ private func presentCapture(_ capture: Capture) {
+ lastCapture = capture
+ ShutterSoundFeedback.playIfEnabled(CapturePreferences.playShutterSound)
+
+ let intent = pendingIntent
+ pendingIntent = .standard
+
+ // Intent-specific actions still respect the after-Capture matrix by running it first
+ // when those toggles are on, then applying the forced action.
+ runAfterCaptureActions(capture, intent: intent)
+ }
+
+ private func runAfterCaptureActions(_ capture: Capture, intent: CaptureIntent) {
+ let plan = AfterCapturePlan.make(preferences: .current, intent: intent)
+
+ if plan.copy {
+ copyCapture(capture)
+ }
+ if plan.upload {
+ Task { await uploadCapture(capture) }
+ }
+ if plan.save {
+ saveCapture(capture)
+ }
+ if plan.pin {
+ pinCapture(capture)
+ }
+ if plan.openEditor {
+ openEditor(with: capture)
+ return
+ }
+ if plan.showQuickAccess {
+ showQuickAccess(with: capture)
+ }
+ }
+
+ private func showQuickAccess(with capture: Capture) {
+ quickAccess?.close()
+ let panel = QuickAccessOverlayController(capture: capture)
+ panel.onAnnotate = { [weak self] capture in
+ self?.quickAccess = nil
+ self?.openEditor(with: capture)
+ }
+ panel.onPin = { [weak self] capture in
+ self?.quickAccess = nil
+ self?.pinCapture(capture)
+ }
+ panel.onDismiss = { [weak self] in
+ if let panel = self?.quickAccess {
+ self?.pushRecentlyClosed(panel.captureForRestore)
+ }
+ self?.quickAccess = nil
+ }
+ quickAccess = panel
+ panel.show()
+ }
+
+ func pinCapture(_ capture: Capture) {
+ let pinned = PinnedCaptureController(capture: capture)
+ pinned.onAnnotate = { [weak self] capture in
+ self?.openEditor(with: capture)
+ }
+ pinned.onClose = { [weak self, weak pinned] in
+ if let pinned {
+ self?.pushRecentlyClosed(pinned.captureForRestore)
+ }
+ self?.pinnedCaptures.removeAll { $0 === pinned }
+ }
+ pinnedCaptures.append(pinned)
+ pinned.show()
+ if pinsHidden {
+ pinned.setHidden(true)
+ }
+ }
+
+ private func copyCapture(_ capture: Capture) {
+ let image = NSImage(cgImage: capture.image, size: capture.pointSize)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.writeObjects([image])
+ flashStatus("Copied to clipboard")
+ }
+
+ private func saveCapture(_ capture: Capture) {
+ let suggested = CaptureFileName.make(extension: "png")
+ if CapturePreferences.askForName {
+ AskForNamePanel.present(defaultName: suggested) { [weak self] name in
+ guard let name else { return }
+ self?.writeCapture(capture, fileName: name.hasSuffix(".png") ? name : "\(name).png")
+ }
+ return
+ }
+ writeCapture(capture, fileName: suggested)
+ }
+
+ private func writeCapture(_ capture: Capture, fileName: String) {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [.png]
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = fileName
+ panel.begin { response in
+ guard response == .OK, let url = panel.url else { return }
+ let rep = NSBitmapImageRep(cgImage: capture.image)
+ guard let data = rep.representation(using: .png, properties: [:]) else { return }
+ try? data.write(to: url, options: .atomic)
+ }
+ }
+
+ private func uploadCapture(_ capture: Capture) async {
+ guard UploadPreferences.isConfigured else { return }
+ let rep = NSBitmapImageRep(cgImage: capture.image)
+ guard let data = rep.representation(using: .png, properties: [:]) else { return }
+ do {
+ let url = try await UploadService.uploadPNG(
+ data: data,
+ fileName: CaptureFileName.make(extension: "png")
+ )
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(url.absoluteString, forType: .string)
+ flashStatus("Uploaded — link copied")
+ } catch {
+ NSLog("Parcel: after-Capture upload failed — \(error)")
+ }
+ }
+
+ private func pushRecentlyClosed(_ capture: Capture) {
+ recentlyClosed.push(capture)
+ }
+
// MARK: Editor lifecycle
- private func openEditor(with capture: Capture) {
+ func openEditor(with capture: Capture) {
let controller = EditorWindowController(
capture: capture,
historyStore: history,
@@ -220,6 +631,24 @@ final class AppCoordinator: ObservableObject {
controller.show()
}
+ func openParcelProject(at url: URL) {
+ guard let restored = ParcelProjectIO.open(from: url) else {
+ presentMessage(title: "Could Not Open Project", message: "The .parcel file could not be read.")
+ return
+ }
+ let controller = EditorWindowController(
+ capture: restored.capture,
+ historyStore: history,
+ document: restored.document,
+ onShowHistory: { [weak self] in self?.openHistory() }
+ )
+ controller.onClose = { [weak self, weak controller] in
+ self?.editors.removeAll { $0 === controller }
+ }
+ editors.append(controller)
+ controller.show()
+ }
+
private func openHistoryEntry(_ id: UUID) {
guard let restored = history.restore(id) else {
presentMessage(
@@ -241,6 +670,11 @@ final class AppCoordinator: ObservableObject {
controller.show()
}
+ private func pinHistoryEntry(_ id: UUID) {
+ guard let restored = history.restore(id) else { return }
+ pinCapture(restored.capture)
+ }
+
private func openRecordingEditor(url: URL) {
let controller = RecordingWindowController(url: url)
controller.onClose = { [weak self, weak controller] in
@@ -250,6 +684,16 @@ final class AppCoordinator: ObservableObject {
controller.show()
}
+ private func flashStatus(_ message: String) {
+ statusBanner = message
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 2_500_000_000)
+ if self?.statusBanner == message {
+ self?.statusBanner = nil
+ }
+ }
+ }
+
private func presentError(title: String, error: Error) {
let alert = NSAlert(error: error)
alert.messageText = title
@@ -265,8 +709,70 @@ final class AppCoordinator: ObservableObject {
}
private func recordingFileName() -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss"
- return "Parcel Recording \(formatter.string(from: Date())).mp4"
+ CaptureFileName.make(extension: "mp4")
+ .replacingOccurrences(of: "Parcel ", with: "Parcel Recording ")
+ }
+}
+
+// MARK: - Ask for name
+
+enum AskForNamePanel {
+ static func present(defaultName: String, completion: @escaping (String?) -> Void) {
+ let alert = NSAlert()
+ alert.messageText = "Name this Capture"
+ alert.informativeText = "Choose a file name, or discard."
+ alert.addButton(withTitle: "Save")
+ alert.addButton(withTitle: "Discard")
+ let field = NSTextField(string: defaultName)
+ field.frame = NSRect(x: 0, y: 0, width: 280, height: 24)
+ alert.accessoryView = field
+ NSApp.activate(ignoringOtherApps: true)
+ let response = alert.runModal()
+ if response == .alertFirstButtonReturn {
+ let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ completion(name.isEmpty ? defaultName : name)
+ } else {
+ completion(nil)
+ }
+ }
+}
+
+// MARK: - Focus / DND best-effort
+
+enum CountdownDisplay {
+ static let tickInterval: TimeInterval = 0.1
+
+ static func nextRemaining(after remaining: TimeInterval) -> TimeInterval {
+ max(0, remaining - tickInterval)
+ }
+
+ static func captureLabel(remaining: TimeInterval) -> String {
+ label(prefix: "Capturing in", remaining: remaining)
+ }
+
+ static func recordingLabel(remaining: TimeInterval) -> String {
+ label(prefix: "Recording in", remaining: remaining)
+ }
+
+ private static func label(prefix: String, remaining: TimeInterval) -> String {
+ "\(prefix) \(Int(ceil(max(0, remaining))))s…"
+ }
+}
+
+enum FocusAssist {
+ static func script(for enabled: Bool) -> String? {
+ enabled ? "tell application \"System Events\" to keystroke \"d\" using {command down, option down}" : nil
+ }
+
+ /// Best-effort Focus engagement via public AppleScript shortcuts. Returns whether an attempt ran.
+ @discardableResult
+ static func setDoNotDisturb(_ enabled: Bool) -> Bool {
+ guard let script = script(for: enabled) else { return false }
+ var error: NSDictionary?
+ if let appleScript = NSAppleScript(source: script) {
+ appleScript.executeAndReturnError(&error)
+ return error == nil
+ }
+ return false
}
}
diff --git a/Sources/Parcel/App/AppDelegate.swift b/Sources/Parcel/App/AppDelegate.swift
index fffaffd..26929d0 100644
--- a/Sources/Parcel/App/AppDelegate.swift
+++ b/Sources/Parcel/App/AppDelegate.swift
@@ -13,9 +13,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
+ guard !Self.isRunningUnitTests else { return }
AppIdentity.migrateFromNotableIfNeeded()
+ AppIdentity.migrateSandboxContainerDefaultsIfNeeded()
+ // Never auto-start Sparkle without a real EdDSA public key — a placeholder key
+ // shows “Unable to Check For Updates” and steals focus from Capture.
+ let startUpdater = Self.hasValidSparklePublicKey && {
+ #if DEBUG
+ return false
+ #else
+ return true
+ #endif
+ }()
updaterController = SPUStandardUpdaterController(
- startingUpdater: true,
+ startingUpdater: startUpdater,
updaterDelegate: nil,
userDriverDelegate: nil
)
@@ -23,6 +34,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
showOnboardingIfNeeded()
}
+ func application(_ application: NSApplication, open urls: [URL]) {
+ for url in urls {
+ if url.pathExtension == "parcel" {
+ coordinator.openParcelProject(at: url)
+ } else {
+ coordinator.handleURL(url)
+ }
+ }
+ }
+
func applicationWillTerminate(_ notification: Notification) {
coordinator.shutdown()
}
@@ -33,6 +54,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: Private
+ private static var hasValidSparklePublicKey: Bool {
+ guard let key = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String else {
+ return false
+ }
+ let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty,
+ !trimmed.contains("REPLACE_WITH"),
+ trimmed.count >= 40,
+ Data(base64Encoded: trimmed) != nil else {
+ return false
+ }
+ return true
+ }
+
+ private static var isRunningUnitTests: Bool {
+ ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
+ || NSClassFromString("XCTestCase") != nil
+ }
+
private func showOnboardingIfNeeded() {
guard !UserDefaults.standard.bool(forKey: OnboardingKeys.completed) else { return }
welcome.show()
diff --git a/Sources/Parcel/App/ClipboardCaptureReader.swift b/Sources/Parcel/App/ClipboardCaptureReader.swift
new file mode 100644
index 0000000..99cd6d1
--- /dev/null
+++ b/Sources/Parcel/App/ClipboardCaptureReader.swift
@@ -0,0 +1,12 @@
+import AppKit
+
+enum ClipboardCaptureReader {
+ static func capture(from pasteboard: NSPasteboard, scale: CGFloat) -> Capture? {
+ guard let image = NSImage(pasteboard: pasteboard),
+ let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
+ else {
+ return nil
+ }
+ return Capture(image: cgImage, scale: max(scale, 1))
+ }
+}
diff --git a/Sources/Parcel/App/ParcelURLRouter.swift b/Sources/Parcel/App/ParcelURLRouter.swift
new file mode 100644
index 0000000..4a39bbb
--- /dev/null
+++ b/Sources/Parcel/App/ParcelURLRouter.swift
@@ -0,0 +1,134 @@
+import CoreGraphics
+import Foundation
+
+/// Routes `parcel://` URL scheme actions into AppCoordinator.
+enum ParcelURLAction: Equatable {
+ case region
+ case area(CGRect, CGDirectDisplayID?)
+ case window
+ case fullscreen
+ case previous
+ case scroll
+ case ocr
+ case record
+ case history
+ case annotateLast
+ case openClipboard
+ case restoreRecentlyClosed
+ case hideOverlays
+}
+
+enum ParcelURLRouter {
+ @MainActor
+ static func route(_ url: URL, coordinator: AppCoordinator) {
+ guard let action = action(for: url) else {
+ NSLog("Parcel: unrecognized URL \(url.absoluteString)")
+ return
+ }
+ route(action, coordinator: coordinator)
+ }
+
+ static func action(for url: URL) -> ParcelURLAction? {
+ guard url.scheme?.lowercased() == "parcel" else { return nil }
+ let host = (url.host ?? "").lowercased()
+ let path = url.path.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+ var query: [String: String] = [:]
+ for item in components?.queryItems ?? [] {
+ if let value = item.value {
+ query[item.name.lowercased()] = value
+ }
+ }
+
+ switch (host, path) {
+ case ("capture", "region"), ("capture", ""):
+ if let rect = parseRect(query) {
+ let displayID: CGDirectDisplayID? = query["display"].flatMap { raw in
+ guard let value = UInt32(raw), value != 0 else { return nil }
+ return CGDirectDisplayID(value)
+ }
+ return .area(rect, displayID)
+ } else {
+ return .region
+ }
+ case ("capture", "window"):
+ return .window
+ case ("capture", "fullscreen"), ("capture", "display"):
+ return .fullscreen
+ case ("capture", "previous"):
+ return .previous
+ case ("capture", "scroll"):
+ return .scroll
+ case ("ocr", _), ("capture", "ocr"):
+ return .ocr
+ case ("record", _), ("capture", "record"):
+ return .record
+ case ("open", "history"), ("history", _):
+ return .history
+ case ("annotate", "last"), ("open", "annotate"):
+ return .annotateLast
+ case ("open", "clipboard"):
+ return .openClipboard
+ case ("restore", _), ("open", "restore"):
+ return .restoreRecentlyClosed
+ case ("overlays", "hide"), ("hide", "overlays"):
+ return .hideOverlays
+ default:
+ // parcel://region style without host path
+ switch host {
+ case "region": return .region
+ case "window": return .window
+ case "fullscreen", "display": return .fullscreen
+ case "previous": return .previous
+ case "scroll": return .scroll
+ case "ocr": return .ocr
+ case "record": return .record
+ case "history": return .history
+ default: return nil
+ }
+ }
+ }
+
+ @MainActor
+ private static func route(_ action: ParcelURLAction, coordinator: AppCoordinator) {
+ switch action {
+ case .region:
+ coordinator.beginRegionCapture()
+ case let .area(rect, displayID):
+ coordinator.beginAreaCapture(rect: rect, displayID: displayID)
+ case .window:
+ coordinator.beginWindowCapture()
+ case .fullscreen:
+ coordinator.beginFullscreenCapture()
+ case .previous:
+ coordinator.beginPreviousAreaCapture()
+ case .scroll:
+ coordinator.beginScrollCapture()
+ case .ocr:
+ coordinator.beginOCRCapture()
+ case .record:
+ coordinator.toggleRecording()
+ case .history:
+ coordinator.openHistory()
+ case .annotateLast:
+ coordinator.annotateLastCapture()
+ case .openClipboard:
+ coordinator.openFromClipboard()
+ case .restoreRecentlyClosed:
+ coordinator.restoreRecentlyClosed()
+ case .hideOverlays:
+ coordinator.hideAllOverlays()
+ }
+ }
+
+ private static func parseRect(_ query: [String: String]) -> CGRect? {
+ guard
+ let x = query["x"].flatMap(Double.init),
+ let y = query["y"].flatMap(Double.init),
+ let w = query["width"].flatMap(Double.init) ?? query["w"].flatMap(Double.init),
+ let h = query["height"].flatMap(Double.init) ?? query["h"].flatMap(Double.init),
+ w > 0, h > 0
+ else { return nil }
+ return CGRect(x: x, y: y, width: w, height: h)
+ }
+}
diff --git a/Sources/Parcel/App/RecentlyClosedCaptures.swift b/Sources/Parcel/App/RecentlyClosedCaptures.swift
new file mode 100644
index 0000000..7b91cf7
--- /dev/null
+++ b/Sources/Parcel/App/RecentlyClosedCaptures.swift
@@ -0,0 +1,21 @@
+struct RecentlyClosedCaptures {
+ private let limit: Int
+ private var captures: [Capture] = []
+
+ init(limit: Int = 12) {
+ self.limit = max(1, limit)
+ }
+
+ var count: Int { captures.count }
+
+ mutating func push(_ capture: Capture) {
+ captures.append(capture)
+ if captures.count > limit {
+ captures.removeFirst(captures.count - limit)
+ }
+ }
+
+ mutating func restore() -> Capture? {
+ captures.popLast()
+ }
+}
diff --git a/Sources/Parcel/Capture/CaptureController.swift b/Sources/Parcel/Capture/CaptureController.swift
index b3772d9..88f2a71 100644
--- a/Sources/Parcel/Capture/CaptureController.swift
+++ b/Sources/Parcel/Capture/CaptureController.swift
@@ -9,6 +9,10 @@ final class CaptureController {
var onCaptureComplete: ((Capture) -> Void)?
/// Called when a Scroll Capture starts: the initial Selection and its first frozen pixels.
var onScrollSelection: ((SelectionResult, Capture) -> Void)?
+ /// Called when the user confirms a Selection for screen recording (region pick).
+ var onRecordingSelection: ((SelectionResult) -> Void)?
+ /// Called after an OCR Selection copies recognized text to the clipboard.
+ var onOCRComplete: ((String) -> Void)?
/// Called when Screen Recording permission is missing, so the app can show guidance.
var onNeedsPermission: (() -> Void)?
/// Called when a Capture attempt fails after permission is granted.
@@ -19,14 +23,45 @@ final class CaptureController {
private var completion: (() -> Void)?
private var isRunning = false
private var isScrollSelection = false
+ private var isRecordingSelection = false
+ private var initialMode: OverlayCaptureMode = .region
/// `completion` always runs exactly once when the attempt finishes (commit or cancel or error).
func begin(completion: @escaping () -> Void) {
- begin(scrollSelection: false, completion: completion)
+ begin(mode: .region, completion: completion)
+ }
+
+ func begin(mode: OverlayCaptureMode, completion: @escaping () -> Void) {
+ guard !isRunning else { completion(); return }
+ isRunning = true
+ initialMode = mode
+ isScrollSelection = mode == .scroll
+ isRecordingSelection = mode == .record
+ self.completion = completion
+ Task { await run() }
}
func beginScrollSelection(completion: @escaping () -> Void) {
- begin(scrollSelection: true, completion: completion)
+ begin(mode: .scroll, completion: completion)
+ }
+
+ /// Freeze displays and present the Overlay to pick a recording region.
+ func beginRecordingSelection(completion: @escaping () -> Void) {
+ begin(mode: .record, completion: completion)
+ }
+
+ /// Reuses the last recording Selection rect without showing an Overlay.
+ func beginPreviousRecordingArea(completion: @escaping () -> Void) {
+ guard !isRunning else { completion(); return }
+ guard CapturePreferences.hasPreviousRecordingArea else {
+ onFailure?("No previous recording area is available yet.")
+ completion()
+ return
+ }
+ isRunning = true
+ isRecordingSelection = true
+ self.completion = completion
+ Task { await runPreviousRecordingArea() }
}
/// Captures every display into one stitched Canvas without showing an Overlay.
@@ -37,35 +72,56 @@ final class CaptureController {
Task { await runAllDisplays() }
}
- private func begin(scrollSelection: Bool, completion: @escaping () -> Void) {
+ /// Captures the main display (or first frozen screen) without showing an Overlay.
+ func beginFullscreen(completion: @escaping () -> Void) {
guard !isRunning else { completion(); return }
isRunning = true
- isScrollSelection = scrollSelection
self.completion = completion
- Task { await run() }
+ Task { await runFullscreen() }
}
- private func run() async {
- ScreenRecordingPermission.requestIfNeeded()
+ /// Re-captures the last Selection rect without showing an Overlay.
+ func beginPreviousArea(completion: @escaping () -> Void) {
+ guard !isRunning else { completion(); return }
+ guard CapturePreferences.hasPreviousArea else {
+ onFailure?("No previous Capture area is available yet.")
+ completion()
+ return
+ }
+ isRunning = true
+ self.completion = completion
+ Task { await runPreviousArea() }
+ }
+ private func run() async {
+ // If TCC preflight is false, do not call ScreenCaptureKit — on recent macOS it shows
+ // the same “would like to record” sheet even when Settings already lists Parcel ON
+ // for a different code signature. Guide via Preferences instead.
+ guard ScreenRecordingPermission.isGranted else {
+ onNeedsPermission?()
+ finish()
+ return
+ }
+ DesktopIconHider.beginSessionIfNeeded()
do {
let frozen = try await engine.freezeScreens()
presentOverlay(frozen)
} catch {
NSLog("Parcel: capture failed — \(error)")
- let hasAccess = await ScreenRecordingPermission.hasEffectiveAccess()
- if !hasAccess {
- onNeedsPermission?()
- } else {
- onFailure?("Could not freeze the screen for Capture. \(error.localizedDescription)")
- }
+ DesktopIconHider.endSession()
+ await handleCaptureFailure(error, fallback: "Could not freeze the screen for Capture.")
finish()
}
}
private func runAllDisplays() async {
- ScreenRecordingPermission.requestIfNeeded()
-
+ guard ScreenRecordingPermission.isGranted else {
+ onNeedsPermission?()
+ finish()
+ return
+ }
+ DesktopIconHider.beginSessionIfNeeded()
+ defer { DesktopIconHider.endSession() }
do {
let frozen = try await engine.freezeScreens()
if let capture = engine.makeStitchedCapture(from: frozen) {
@@ -75,25 +131,139 @@ final class CaptureController {
}
} catch {
NSLog("Parcel: multi-display capture failed — \(error)")
- let hasAccess = await ScreenRecordingPermission.hasEffectiveAccess()
- if !hasAccess {
- onNeedsPermission?()
- } else {
- onFailure?("Could not capture all displays. \(error.localizedDescription)")
+ await handleCaptureFailure(error, fallback: "Could not capture all displays.")
+ }
+ finish()
+ }
+
+ private func runFullscreen() async {
+ guard ScreenRecordingPermission.isGranted else {
+ onNeedsPermission?()
+ finish()
+ return
+ }
+ DesktopIconHider.beginSessionIfNeeded()
+ defer { DesktopIconHider.endSession() }
+ do {
+ let frozen = try await engine.freezeScreens()
+ let screen = frozen.first(where: { $0.screen == NSScreen.main }) ?? frozen.first
+ guard let screen,
+ let capture = engine.makeCapture(
+ from: SelectionResult(
+ screen: screen,
+ rectInPoints: CGRect(origin: .zero, size: screen.pointSize)
+ )
+ )
+ else {
+ onFailure?("Could not Capture the current display.")
+ finish()
+ return
}
+ onCaptureComplete?(capture)
+ } catch {
+ NSLog("Parcel: fullscreen capture failed — \(error)")
+ await handleCaptureFailure(error, fallback: "Could not Capture the current display.")
}
finish()
}
+ private func runPreviousArea() async {
+ guard ScreenRecordingPermission.isGranted else {
+ onNeedsPermission?()
+ finish()
+ return
+ }
+ DesktopIconHider.beginSessionIfNeeded()
+ defer { DesktopIconHider.endSession() }
+ do {
+ let frozen = try await engine.freezeScreens()
+ let displayID = CapturePreferences.lastSelectionDisplayID
+ let rect = CapturePreferences.lastSelectionRect
+ guard let screen = frozen.first(where: { $0.id == displayID }) ?? frozen.first else {
+ onFailure?("The previous Capture display is no longer available.")
+ finish()
+ return
+ }
+ let clamped = rect.intersection(CGRect(origin: .zero, size: screen.pointSize))
+ guard clamped.width >= 1, clamped.height >= 1,
+ let capture = engine.makeCapture(from: SelectionResult(screen: screen, rectInPoints: clamped))
+ else {
+ onFailure?("The previous Capture area is no longer valid on this display.")
+ finish()
+ return
+ }
+ CapturePreferences.rememberSelection(SelectionResult(screen: screen, rectInPoints: clamped))
+ onCaptureComplete?(capture)
+ } catch {
+ NSLog("Parcel: previous-area capture failed — \(error)")
+ await handleCaptureFailure(error, fallback: "Could not Capture the previous area.")
+ }
+ finish()
+ }
+
+ private func runPreviousRecordingArea() async {
+ guard ScreenRecordingPermission.isGranted else {
+ onNeedsPermission?()
+ finish()
+ return
+ }
+ do {
+ let frozen = try await engine.freezeScreens()
+ let displayID = CapturePreferences.lastRecordingDisplayID
+ let rect = CGRect(
+ x: CapturePreferences.lastRecordingX,
+ y: CapturePreferences.lastRecordingY,
+ width: CapturePreferences.lastRecordingWidth,
+ height: CapturePreferences.lastRecordingHeight
+ )
+ guard let screen = frozen.first(where: { $0.id == displayID }) ?? frozen.first else {
+ onFailure?("The previous recording display is no longer available.")
+ finish()
+ return
+ }
+ let clamped = rect.intersection(CGRect(origin: .zero, size: screen.pointSize))
+ guard clamped.width >= 1, clamped.height >= 1 else {
+ onFailure?("The previous recording area is no longer valid.")
+ finish()
+ return
+ }
+ let result = SelectionResult(screen: screen, rectInPoints: clamped)
+ CapturePreferences.rememberRecordingSelection(displayID: screen.id, rect: clamped)
+ // Dismiss freeze without Overlay — recording starts from frozen geometry.
+ DesktopIconHider.endSession()
+ onRecordingSelection?(result)
+ } catch {
+ NSLog("Parcel: previous recording area failed — \(error)")
+ await handleCaptureFailure(error, fallback: "Could not restore the previous recording area.")
+ }
+ finish()
+ }
+
+ private func handleCaptureFailure(_ error: Error, fallback: String) async {
+ if !ScreenRecordingPermission.isGranted {
+ onNeedsPermission?()
+ } else {
+ // Avoid a second ScreenCaptureKit probe (it can re-show the system sheet).
+ onFailure?("\(fallback) \(error.localizedDescription)")
+ }
+ }
+
private func presentOverlay(_ frozen: [FrozenScreen]) {
- let controller = OverlayController(screens: frozen)
+ let controller = OverlayController(screens: frozen, initialMode: initialMode)
controller.onSelection = { [weak self] result in
- self?.handle(result, scroll: false)
+ self?.handle(result, kind: .region)
}
controller.onScrollSelection = { [weak self] result in
- self?.handle(result, scroll: true)
+ self?.handle(result, kind: .scroll)
+ }
+ controller.onOCRSelection = { [weak self] result in
+ self?.handle(result, kind: .ocr)
+ }
+ controller.onRecordingSelection = { [weak self] result in
+ self?.handle(result, kind: .record)
}
controller.onCancel = { [weak self] in
+ DesktopIconHider.endSession()
self?.dismissOverlay()
self?.finish()
}
@@ -101,12 +271,32 @@ final class CaptureController {
controller.present()
}
- private func handle(_ result: SelectionResult, scroll: Bool) {
+ private enum HandleKind { case region, scroll, ocr, record }
+
+ private func handle(_ result: SelectionResult, kind: HandleKind) {
dismissOverlay()
+ DesktopIconHider.endSession()
+
+ if kind == .record || isRecordingSelection {
+ CapturePreferences.rememberRecordingSelection(
+ displayID: result.screen.id,
+ rect: result.rectInPoints
+ )
+ onRecordingSelection?(result)
+ finish()
+ return
+ }
+
+ if kind == .ocr {
+ Task { await runOCR(from: result) }
+ return
+ }
+
if let capture = engine.makeCapture(from: result) {
- if scroll || isScrollSelection {
+ if kind == .scroll || isScrollSelection {
onScrollSelection?(result, capture)
} else {
+ CapturePreferences.rememberSelection(result)
onCaptureComplete?(capture)
}
} else {
@@ -115,6 +305,27 @@ final class CaptureController {
finish()
}
+ private func runOCR(from result: SelectionResult) async {
+ guard let capture = engine.makeCapture(from: result) else {
+ onFailure?("The selected region was too small for OCR.")
+ finish()
+ return
+ }
+ let analysis = await VisionAnalyzer.analyze(image: capture.image, pointSize: capture.pointSize)
+ let text = OCRTextFormatter.outputText(
+ from: analysis.recognizedText,
+ stripLineBreaks: CapturePreferences.ocrStripLineBreaks
+ )
+ if text.isEmpty {
+ onFailure?("No text was recognized in the Selection.")
+ } else {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ onOCRComplete?(text)
+ }
+ finish()
+ }
+
private func dismissOverlay() {
overlay?.dismiss()
overlay = nil
@@ -123,6 +334,8 @@ final class CaptureController {
private func finish() {
isRunning = false
isScrollSelection = false
+ isRecordingSelection = false
+ initialMode = .region
let done = completion
completion = nil
done?()
diff --git a/Sources/Parcel/Capture/CaptureEngine.swift b/Sources/Parcel/Capture/CaptureEngine.swift
index 5ce9d7c..7084b14 100644
--- a/Sources/Parcel/Capture/CaptureEngine.swift
+++ b/Sources/Parcel/Capture/CaptureEngine.swift
@@ -111,16 +111,58 @@ final class CaptureEngine {
pixelRect = pixelRect.intersection(bounds)
guard pixelRect.width >= 1, pixelRect.height >= 1 else { return nil }
guard let cropped = result.screen.image.cropping(to: pixelRect) else { return nil }
- return Capture(image: cropped, scale: scale)
+ return applyRetinaPreference(Capture(image: cropped, scale: scale))
+ }
+
+ /// Optionally downscales a Retina Capture to 1× points for smaller shares.
+ func applyRetinaPreference(_ capture: Capture) -> Capture {
+ guard CapturePreferences.scaleDownRetina, capture.scale > 1 else { return capture }
+ let targetWidth = max(1, Int(capture.pointSize.width.rounded()))
+ let targetHeight = max(1, Int(capture.pointSize.height.rounded()))
+ guard let context = CGContext(
+ data: nil,
+ width: targetWidth,
+ height: targetHeight,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return capture }
+ context.interpolationQuality = .high
+ context.draw(capture.image, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
+ guard let scaled = context.makeImage() else { return capture }
+ return Capture(image: scaled, scale: 1)
}
/// Combines all frozen displays into one Capture in their actual desktop arrangement. Mixed
/// density displays are normalized to the highest connected backing scale so no source image
/// is upscaled below its Capture-point dimensions.
func makeStitchedCapture(from screens: [FrozenScreen]) -> Capture? {
- guard !screens.isEmpty else { return nil }
- let desktop = screens.map { $0.screen.frame }.reduce(screens[0].screen.frame) { $0.union($1) }
- let scale = screens.map(\.scale).max() ?? 1
+ AllDisplayStitcher.stitch(
+ screens.map { screen in
+ AllDisplayStitcher.Item(
+ frame: screen.screen.frame,
+ image: screen.image,
+ scale: screen.scale
+ )
+ },
+ scaleDownRetina: CapturePreferences.scaleDownRetina
+ )
+ }
+}
+
+enum AllDisplayStitcher {
+ struct Item {
+ let frame: CGRect
+ let image: CGImage
+ let scale: CGFloat
+ }
+
+ /// Composes displays in desktop coordinates into one top-left Capture space.
+ static func stitch(_ items: [Item], scaleDownRetina: Bool) -> Capture? {
+ guard !items.isEmpty else { return nil }
+ let desktop = items.map(\.frame).reduce(items[0].frame) { $0.union($1) }
+ let scale = items.map(\.scale).max() ?? 1
let width = Int((desktop.width * scale).rounded(.up))
let height = Int((desktop.height * scale).rounded(.up))
guard width > 0, height > 0,
@@ -140,8 +182,8 @@ final class CaptureEngine {
context.translateBy(x: 0, y: CGFloat(height))
context.scaleBy(x: scale, y: -scale)
- for frozen in screens {
- let frame = frozen.screen.frame
+ for item in items {
+ let frame = item.frame
let rect = CGRect(
x: frame.minX - desktop.minX,
y: desktop.maxY - frame.maxY,
@@ -149,10 +191,30 @@ final class CaptureEngine {
height: frame.height
)
context.interpolationQuality = .high
- context.draw(frozen.image, in: rect)
+ context.draw(item.image, in: rect)
}
guard let image = context.makeImage() else { return nil }
- return Capture(image: image, scale: scale)
+ let capture = Capture(image: image, scale: scale)
+ guard scaleDownRetina, capture.scale > 1 else { return capture }
+ return downscaleRetinaCapture(capture)
+ }
+
+ private static func downscaleRetinaCapture(_ capture: Capture) -> Capture {
+ let targetWidth = max(1, Int(capture.pointSize.width.rounded()))
+ let targetHeight = max(1, Int(capture.pointSize.height.rounded()))
+ guard let context = CGContext(
+ data: nil,
+ width: targetWidth,
+ height: targetHeight,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return capture }
+ context.interpolationQuality = .high
+ context.draw(capture.image, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
+ guard let scaled = context.makeImage() else { return capture }
+ return Capture(image: scaled, scale: 1)
}
}
diff --git a/Sources/Parcel/Capture/CaptureModels.swift b/Sources/Parcel/Capture/CaptureModels.swift
index b28caf0..be4f08c 100644
--- a/Sources/Parcel/Capture/CaptureModels.swift
+++ b/Sources/Parcel/Capture/CaptureModels.swift
@@ -11,6 +11,16 @@ struct SnapWindow: Identifiable, Equatable {
let frameInScreen: CGRect
}
+enum SnapWindowPicker {
+ /// The frontmost detected window under a local point is represented by the
+ /// smallest containing frame, because ScreenCaptureKit gives us windows in display space.
+ static func frontmostWindow(at point: CGPoint, windows: [SnapWindow]) -> SnapWindow? {
+ windows
+ .filter { $0.frameInScreen.contains(point) }
+ .min { $0.frameInScreen.area < $1.frameInScreen.area }
+ }
+}
+
/// A single display, captured full-resolution at hotkey time and frozen for selection.
struct FrozenScreen: Identifiable {
let id: CGDirectDisplayID
@@ -26,9 +36,7 @@ struct FrozenScreen: Identifiable {
/// The frontmost (smallest containing) window under a local point, if any.
func window(at point: CGPoint) -> SnapWindow? {
- windows
- .filter { $0.frameInScreen.contains(point) }
- .min { $0.frameInScreen.area < $1.frameInScreen.area }
+ SnapWindowPicker.frontmostWindow(at: point, windows: windows)
}
}
diff --git a/Sources/Parcel/Capture/OverlayCaptureMode.swift b/Sources/Parcel/Capture/OverlayCaptureMode.swift
new file mode 100644
index 0000000..1c33b36
--- /dev/null
+++ b/Sources/Parcel/Capture/OverlayCaptureMode.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+/// Capture intent chosen from the All-in-One strip on the Selection Overlay.
+enum OverlayCaptureMode: String, CaseIterable, Identifiable {
+ case region
+ case window
+ case fullscreen
+ case scroll
+ case ocr
+ case record
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .region: return "Region"
+ case .window: return "Window"
+ case .fullscreen: return "Display"
+ case .scroll: return "Scroll"
+ case .ocr: return "OCR"
+ case .record: return "Record"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .region: return "rectangle.dashed"
+ case .window: return "macwindow"
+ case .fullscreen: return "rectangle"
+ case .scroll: return "arrow.up.and.down.text.horizontal"
+ case .ocr: return "text.viewfinder"
+ case .record: return "record.circle"
+ }
+ }
+
+ var hint: String {
+ switch self {
+ case .region: return "Drag to select · Click window to snap"
+ case .window: return "Click a window to Capture it"
+ case .fullscreen: return "Click anywhere to Capture this display"
+ case .scroll: return "Drag a tall region, then add scroll frames"
+ case .ocr: return "Drag text to copy it on-device to the clipboard"
+ case .record: return "Drag a region to start recording"
+ }
+ }
+}
diff --git a/Sources/Parcel/Capture/OverlayController.swift b/Sources/Parcel/Capture/OverlayController.swift
index ff3d65d..8d7abc2 100644
--- a/Sources/Parcel/Capture/OverlayController.swift
+++ b/Sources/Parcel/Capture/OverlayController.swift
@@ -8,30 +8,41 @@ final class OverlayController {
var onSelection: ((SelectionResult) -> Void)?
var onScrollSelection: ((SelectionResult) -> Void)?
+ var onOCRSelection: ((SelectionResult) -> Void)?
+ var onRecordingSelection: ((SelectionResult) -> Void)?
var onCancel: (() -> Void)?
private let screens: [FrozenScreen]
+ private let initialMode: OverlayCaptureMode
private var windows: [OverlayWindow] = []
private var keyMonitor: Any?
private var finished = false
- init(screens: [FrozenScreen]) {
+ init(screens: [FrozenScreen], initialMode: OverlayCaptureMode = .region) {
self.screens = screens
+ self.initialMode = initialMode
}
func present() {
NSApp.activate(ignoringOtherApps: true)
for frozen in screens {
- let window = OverlayWindow(screen: frozen.screen)
+ let window = OverlayWindow(screen: frozen.screen, displayID: frozen.id)
let root = SelectionOverlayView(
frozen: frozen,
onCommit: { [weak self] rect in
- self?.commit(SelectionResult(screen: frozen, rectInPoints: rect), scroll: false)
+ self?.commit(SelectionResult(screen: frozen, rectInPoints: rect), kind: .region)
},
- onScrollCommit: onScrollSelection == nil ? nil : { [weak self] rect in
- self?.commit(SelectionResult(screen: frozen, rectInPoints: rect), scroll: true)
- }
+ onScrollCommit: { [weak self] rect in
+ self?.commit(SelectionResult(screen: frozen, rectInPoints: rect), kind: .scroll)
+ },
+ onOCRCommit: { [weak self] rect in
+ self?.commit(SelectionResult(screen: frozen, rectInPoints: rect), kind: .ocr)
+ },
+ onRecordCommit: { [weak self] rect in
+ self?.commit(SelectionResult(screen: frozen, rectInPoints: rect), kind: .record)
+ },
+ initialMode: initialMode
)
let hosting = NSHostingView(rootView: root)
hosting.frame = window.contentLayoutRect
@@ -49,6 +60,15 @@ final class OverlayController {
self.cancel()
return nil
}
+ if event.keyCode == 48, !event.modifierFlags.contains(.shift) { // Tab
+ if let window = self.windows.first(where: \.isKeyWindow) ?? self.windows.first {
+ NotificationCenter.default.post(
+ name: .overlayTabPressed,
+ object: window.displayID
+ )
+ }
+ return nil
+ }
return event
}
}
@@ -59,12 +79,19 @@ final class OverlayController {
// MARK: Private
- private func commit(_ result: SelectionResult, scroll: Bool) {
+ private enum CommitKind { case region, scroll, ocr, record }
+
+ private func commit(_ result: SelectionResult, kind: CommitKind) {
guard !finished else { return }
finished = true
- if scroll {
+ switch kind {
+ case .scroll:
onScrollSelection?(result)
- } else {
+ case .ocr:
+ onOCRSelection?(result)
+ case .record:
+ onRecordingSelection?(result)
+ case .region:
onSelection?(result)
}
}
diff --git a/Sources/Parcel/Capture/OverlayNotifications.swift b/Sources/Parcel/Capture/OverlayNotifications.swift
new file mode 100644
index 0000000..1b78652
--- /dev/null
+++ b/Sources/Parcel/Capture/OverlayNotifications.swift
@@ -0,0 +1,7 @@
+import CoreGraphics
+import Foundation
+
+extension Notification.Name {
+ /// Posted when Tab is pressed in an Overlay. `object` is the target `CGDirectDisplayID`.
+ static let overlayTabPressed = Notification.Name("dev.parable.overlayTabPressed")
+}
diff --git a/Sources/Parcel/Capture/OverlayWindow.swift b/Sources/Parcel/Capture/OverlayWindow.swift
index d144e0d..4ce8ff4 100644
--- a/Sources/Parcel/Capture/OverlayWindow.swift
+++ b/Sources/Parcel/Capture/OverlayWindow.swift
@@ -4,7 +4,10 @@ import AppKit
/// Sits above normal windows (`.screenSaver` level) and can become key so it receives the drag.
final class OverlayWindow: NSPanel {
- init(screen: NSScreen) {
+ let displayID: CGDirectDisplayID
+
+ init(screen: NSScreen, displayID: CGDirectDisplayID) {
+ self.displayID = displayID
super.init(
contentRect: screen.frame,
styleMask: [.borderless, .nonactivatingPanel],
diff --git a/Sources/Parcel/Capture/PinnedCaptureController.swift b/Sources/Parcel/Capture/PinnedCaptureController.swift
new file mode 100644
index 0000000..f2a0308
--- /dev/null
+++ b/Sources/Parcel/Capture/PinnedCaptureController.swift
@@ -0,0 +1,371 @@
+import AppKit
+import SwiftUI
+
+enum PinnedCaptureInteraction {
+ static let minimumOpacity = 0.2
+ static let maximumOpacity = 1.0
+ static let scrollOpacityStep = 0.05
+
+ static func clampedOpacity(_ value: Double) -> Double {
+ min(maximumOpacity, max(minimumOpacity, value))
+ }
+
+ static func adjustedOpacity(current: Double, delta: Double) -> Double {
+ clampedOpacity(current + delta)
+ }
+
+ static func opacityDelta(forScrollingDeltaY deltaY: CGFloat) -> Double {
+ deltaY > 0 ? scrollOpacityStep : -scrollOpacityStep
+ }
+
+ static func shouldClose(eventType: NSEvent.EventType, buttonNumber: Int) -> Bool {
+ eventType == .otherMouseDown || buttonNumber == 2
+ }
+}
+
+struct PinnedCaptureState: Equatable {
+ private(set) var opacity: Double
+ private(set) var locked: Bool
+ private(set) var isHidden: Bool
+
+ init(opacity: Double = PinnedCaptureInteraction.maximumOpacity, locked: Bool = false, isHidden: Bool = false) {
+ self.opacity = PinnedCaptureInteraction.clampedOpacity(opacity)
+ self.locked = locked
+ self.isHidden = isHidden
+ }
+
+ var ignoresMouseEvents: Bool { locked }
+
+ mutating func setHidden(_ hidden: Bool) {
+ isHidden = hidden
+ }
+
+ @discardableResult
+ mutating func toggleLock() -> Bool {
+ locked.toggle()
+ return locked
+ }
+
+ @discardableResult
+ mutating func setOpacity(_ value: Double) -> Double {
+ opacity = PinnedCaptureInteraction.clampedOpacity(value)
+ return opacity
+ }
+
+ @discardableResult
+ mutating func adjustOpacity(by delta: Double) -> Double {
+ setOpacity(opacity + delta)
+ }
+}
+
+/// Always-on-top floating Capture reference (CleanShot “Pin screenshots”).
+@MainActor
+final class PinnedCaptureController: NSObject, NSWindowDelegate {
+
+ var onClose: (() -> Void)?
+ var onAnnotate: ((Capture) -> Void)?
+
+ var captureForRestore: Capture { capture }
+
+ private let capture: Capture
+ private let window: NSPanel
+ private var keyMonitor: Any?
+ private var localMonitor: Any?
+ private var state = PinnedCaptureState()
+
+ init(capture: Capture) {
+ self.capture = capture
+ let point = capture.pointSize
+ let width = min(max(point.width, 160), 640)
+ let height = width * (point.height / max(point.width, 1))
+ let size = CGSize(width: width, height: height + 28)
+
+ window = NSPanel(
+ contentRect: NSRect(origin: .zero, size: size),
+ styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
+ backing: .buffered,
+ defer: false
+ )
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.hasShadow = true
+ window.level = .floating
+ window.isFloatingPanel = true
+ window.isMovableByWindowBackground = true
+ window.isReleasedWhenClosed = false
+ window.hidesOnDeactivate = false
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ window.alphaValue = 1
+
+ super.init()
+ window.delegate = self
+ refreshContent()
+ positionNearCursor()
+ }
+
+ func show() {
+ window.orderFrontRegardless()
+ installKeyMonitor()
+ installLocalMonitor()
+ }
+
+ func setHidden(_ hidden: Bool) {
+ state.setHidden(hidden)
+ if hidden {
+ window.orderOut(nil)
+ } else {
+ window.orderFrontRegardless()
+ }
+ }
+
+ func close() {
+ teardown()
+ window.orderOut(nil)
+ onClose?()
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ teardown()
+ onClose?()
+ }
+
+ // MARK: Private
+
+ private func refreshContent() {
+ let root = PinnedCaptureView(
+ capture: capture,
+ opacity: state.opacity,
+ locked: state.locked,
+ onClose: { [weak self] in self?.close() },
+ onAnnotate: { [weak self] in self?.openEditor() },
+ onOpacity: { [weak self] value in
+ guard let self else { return }
+ let opacity = state.setOpacity(value)
+ window.alphaValue = opacity
+ },
+ onToggleLock: { [weak self] in
+ guard let self else { return }
+ state.toggleLock()
+ window.ignoresMouseEvents = state.ignoresMouseEvents
+ refreshContent()
+ },
+ onScrollOpacity: { [weak self] delta in
+ guard let self else { return }
+ let opacity = state.adjustOpacity(by: delta)
+ window.alphaValue = opacity
+ refreshContent()
+ }
+ )
+ let hosting = NSHostingView(rootView: root)
+ hosting.frame = window.contentLayoutRect
+ hosting.autoresizingMask = [.width, .height]
+ window.contentView = hosting
+ }
+
+ private func openEditor() {
+ let capture = self.capture
+ close()
+ onAnnotate?(capture)
+ }
+
+ private func positionNearCursor() {
+ let mouse = NSEvent.mouseLocation
+ let size = window.frame.size
+ var origin = CGPoint(x: mouse.x + 12, y: mouse.y - size.height - 12)
+ if let screen = NSScreen.screens.first(where: { $0.frame.contains(mouse) }) ?? NSScreen.main {
+ let vis = screen.visibleFrame
+ origin.x = min(max(origin.x, vis.minX + 8), vis.maxX - size.width - 8)
+ origin.y = min(max(origin.y, vis.minY + 8), vis.maxY - size.height - 8)
+ }
+ window.setFrameOrigin(origin)
+ }
+
+ private func installKeyMonitor() {
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+ guard let self, window.isKeyWindow || window.isVisible else { return event }
+ switch event.keyCode {
+ case 53: // Esc
+ close()
+ return nil
+ case 123: // left
+ nudge(dx: event.modifierFlags.contains(.shift) ? -10 : -1, dy: 0)
+ return nil
+ case 124: // right
+ nudge(dx: event.modifierFlags.contains(.shift) ? 10 : 1, dy: 0)
+ return nil
+ case 125: // down
+ nudge(dx: 0, dy: event.modifierFlags.contains(.shift) ? -10 : -1)
+ return nil
+ case 126: // up
+ nudge(dx: 0, dy: event.modifierFlags.contains(.shift) ? 10 : 1)
+ return nil
+ default:
+ return event
+ }
+ }
+ }
+
+ private func installLocalMonitor() {
+ localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .otherMouseDown, .scrollWheel]) { [weak self] event in
+ guard let self, window.isVisible, !state.isHidden else { return event }
+ let location = window.convertPoint(fromScreen: NSEvent.mouseLocation)
+ guard window.contentView?.bounds.contains(location) == true || window.frame.contains(NSEvent.mouseLocation) else {
+ return event
+ }
+ if PinnedCaptureInteraction.shouldClose(eventType: event.type, buttonNumber: event.buttonNumber) {
+ close()
+ return nil
+ }
+ if event.type == .scrollWheel {
+ let delta = PinnedCaptureInteraction.opacityDelta(forScrollingDeltaY: event.scrollingDeltaY)
+ let opacity = state.adjustOpacity(by: delta)
+ window.alphaValue = opacity
+ refreshContent()
+ return nil
+ }
+ return event
+ }
+ }
+
+ private func nudge(dx: CGFloat, dy: CGFloat) {
+ var frame = window.frame
+ frame.origin.x += dx
+ frame.origin.y += dy
+ window.setFrame(frame, display: true)
+ }
+
+ private func teardown() {
+ if let keyMonitor {
+ NSEvent.removeMonitor(keyMonitor)
+ self.keyMonitor = nil
+ }
+ if let localMonitor {
+ NSEvent.removeMonitor(localMonitor)
+ self.localMonitor = nil
+ }
+ }
+}
+
+// MARK: - SwiftUI
+
+private struct PinnedCaptureView: View {
+ let capture: Capture
+ let opacity: Double
+ let locked: Bool
+ let onClose: () -> Void
+ let onAnnotate: () -> Void
+ let onOpacity: (Double) -> Void
+ let onToggleLock: () -> Void
+ let onScrollOpacity: (Double) -> Void
+
+ @State private var localOpacity: Double
+
+ init(
+ capture: Capture,
+ opacity: Double,
+ locked: Bool,
+ onClose: @escaping () -> Void,
+ onAnnotate: @escaping () -> Void,
+ onOpacity: @escaping (Double) -> Void,
+ onToggleLock: @escaping () -> Void,
+ onScrollOpacity: @escaping (Double) -> Void = { _ in }
+ ) {
+ self.capture = capture
+ self.opacity = opacity
+ self.locked = locked
+ self.onClose = onClose
+ self.onAnnotate = onAnnotate
+ self.onOpacity = onOpacity
+ self.onToggleLock = onToggleLock
+ self.onScrollOpacity = onScrollOpacity
+ _localOpacity = State(initialValue: opacity)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 6) {
+ Image(systemName: "pin.fill")
+ .font(.system(size: 10))
+ Text("Pinned Capture")
+ .font(.system(size: 11, weight: .semibold))
+ Spacer()
+ Button(action: onToggleLock) {
+ Image(systemName: locked ? "lock.fill" : "lock.open")
+ .font(.system(size: 11))
+ }
+ .buttonStyle(.plain)
+ .help(locked ? "Unlock to interact" : "Lock — click through to apps underneath")
+ Button(action: onAnnotate) {
+ Image(systemName: "pencil")
+ .font(.system(size: 11))
+ }
+ .buttonStyle(.plain)
+ .help("Open in Editor")
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 11, weight: .bold))
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(.ultraThinMaterial)
+
+ Image(decorative: capture.image, scale: capture.scale, orientation: .up)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .onTapGesture(count: 2, perform: onAnnotate)
+ .onScrollWheel { delta in
+ onScrollOpacity(PinnedCaptureInteraction.opacityDelta(forScrollingDeltaY: delta))
+ }
+
+ HStack {
+ Image(systemName: "circle.lefthalf.filled")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ Slider(value: $localOpacity, in: 0.2...1)
+ .controlSize(.mini)
+ .onChange(of: localOpacity) { newValue in
+ onOpacity(newValue)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(.ultraThinMaterial)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(0.35), radius: 12, y: 4)
+ }
+}
+
+private extension View {
+ func onScrollWheel(_ handler: @escaping (CGFloat) -> Void) -> some View {
+ background(ScrollWheelCatcher(handler: handler))
+ }
+}
+
+private struct ScrollWheelCatcher: NSViewRepresentable {
+ let handler: (CGFloat) -> Void
+
+ func makeNSView(context: Context) -> NSView {
+ let view = ScrollWheelView()
+ view.handler = handler
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ (nsView as? ScrollWheelView)?.handler = handler
+ }
+
+ final class ScrollWheelView: NSView {
+ var handler: ((CGFloat) -> Void)?
+
+ override func scrollWheel(with event: NSEvent) {
+ handler?(event.scrollingDeltaY)
+ }
+ }
+}
diff --git a/Sources/Parcel/Capture/QuickAccessOverlayController.swift b/Sources/Parcel/Capture/QuickAccessOverlayController.swift
new file mode 100644
index 0000000..5057036
--- /dev/null
+++ b/Sources/Parcel/Capture/QuickAccessOverlayController.swift
@@ -0,0 +1,382 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+
+/// Floating post-Capture panel: Copy / Save / Annotate / Pin / Upload / drag-out.
+@MainActor
+final class QuickAccessOverlayController: NSObject, NSWindowDelegate {
+
+ var onAnnotate: ((Capture) -> Void)?
+ var onPin: ((Capture) -> Void)?
+ var onDismiss: (() -> Void)?
+
+ /// Exposed so AppCoordinator can stash recently-closed Captures.
+ var captureForRestore: Capture { capture }
+
+ private let capture: Capture
+ private let isNewest: Bool
+ private let window: NSPanel
+ private var autoCloseTimer: Timer?
+ private var dragFileURL: URL?
+ private var keyMonitor: Any?
+
+ init(capture: Capture, isNewest: Bool = true) {
+ self.capture = capture
+ self.isNewest = isNewest
+ let size = CGSize(width: 280, height: 248)
+ window = NSPanel(
+ contentRect: NSRect(origin: .zero, size: size),
+ styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = "Capture"
+ window.titleVisibility = .hidden
+ window.titlebarAppearsTransparent = true
+ window.isFloatingPanel = true
+ window.level = .floating
+ window.isReleasedWhenClosed = false
+ window.hidesOnDeactivate = false
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ window.isMovableByWindowBackground = true
+ window.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.96)
+ window.hasShadow = true
+
+ super.init()
+ window.delegate = self
+
+ let root = QuickAccessView(
+ capture: capture,
+ isNewest: isNewest,
+ temporaryFileURL: { [weak self] in self?.ensureDragFile() },
+ onAnnotate: { [weak self] in self?.annotate() },
+ onCopy: { [weak self] in self?.copyImage() },
+ onSave: { [weak self] in self?.save() },
+ onPin: { [weak self] in self?.pin() },
+ onUpload: { [weak self] in Task { await self?.upload() } },
+ onPrint: { [weak self] in self?.printCapture() },
+ onClose: { [weak self] in self?.close() },
+ onSwipeDiscard: { [weak self] in self?.close() }
+ )
+ window.contentView = NSHostingView(rootView: root)
+ positionOnScreen()
+ }
+
+ func show() {
+ NSApp.activate(ignoringOtherApps: true)
+ window.makeKeyAndOrderFront(nil)
+ installKeyMonitor()
+ scheduleAutoClose()
+ }
+
+ func close() {
+ teardownKeys()
+ autoCloseTimer?.invalidate()
+ autoCloseTimer = nil
+ cleanupDragFile()
+ window.orderOut(nil)
+ onDismiss?()
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ teardownKeys()
+ autoCloseTimer?.invalidate()
+ cleanupDragFile()
+ onDismiss?()
+ }
+
+ // MARK: Actions
+
+ private func annotate() {
+ autoCloseTimer?.invalidate()
+ teardownKeys()
+ let capture = self.capture
+ window.orderOut(nil)
+ onAnnotate?(capture)
+ onDismiss?()
+ }
+
+ private func pin() {
+ autoCloseTimer?.invalidate()
+ teardownKeys()
+ let capture = self.capture
+ window.orderOut(nil)
+ onPin?(capture)
+ onDismiss?()
+ }
+
+ private func copyImage() {
+ let image = NSImage(cgImage: capture.image, size: capture.pointSize)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.writeObjects([image])
+ scheduleAutoClose()
+ }
+
+ private func save() {
+ let suggested = CaptureFileName.make(extension: "png")
+ if CapturePreferences.askForName {
+ AskForNamePanel.present(defaultName: suggested) { [weak self] name in
+ guard let self, let name else { return }
+ self.presentSavePanel(name: name.hasSuffix(".png") ? name : "\(name).png")
+ }
+ return
+ }
+ presentSavePanel(name: suggested)
+ }
+
+ private func presentSavePanel(name: String) {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [.png]
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = name
+ panel.begin { [weak self] response in
+ guard response == .OK, let url = panel.url, let self else { return }
+ self.writePNG(to: url)
+ self.scheduleAutoClose()
+ }
+ }
+
+ private func printCapture() {
+ let image = NSImage(cgImage: capture.image, size: capture.pointSize)
+ CapturePrintPayload.printOperation(for: image).run()
+ }
+
+ private func upload() async {
+ guard UploadPreferences.isConfigured else { return }
+ let rep = NSBitmapImageRep(cgImage: capture.image)
+ guard let data = rep.representation(using: .png, properties: [:]) else { return }
+ do {
+ let url = try await UploadService.uploadPNG(
+ data: data,
+ fileName: CaptureFileName.make(extension: "png")
+ )
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(url.absoluteString, forType: .string)
+ } catch {
+ NSLog("Parcel: Quick Access upload failed — \(error)")
+ }
+ scheduleAutoClose()
+ }
+
+ // MARK: Helpers
+
+ private func installKeyMonitor() {
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+ guard let self, self.window.isKeyWindow else { return event }
+ switch QuickAccessShortcut.action(
+ charactersIgnoringModifiers: event.charactersIgnoringModifiers,
+ modifierFlags: event.modifierFlags,
+ keyCode: event.keyCode
+ ) {
+ case .copy:
+ self.copyImage()
+ return nil
+ case .save:
+ self.save()
+ return nil
+ case .close:
+ self.close()
+ return nil
+ case .upload:
+ Task { await self.upload() }
+ return nil
+ case .annotate:
+ self.annotate()
+ return nil
+ case .printCapture:
+ self.printCapture()
+ return nil
+ case nil:
+ return event
+ }
+ }
+ }
+
+ private func teardownKeys() {
+ if let keyMonitor {
+ NSEvent.removeMonitor(keyMonitor)
+ self.keyMonitor = nil
+ }
+ }
+
+ private func positionOnScreen() {
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
+ let size = window.frame.size
+ let origin = CGPoint(
+ x: screen.maxX - size.width - 16,
+ y: screen.minY + 16
+ )
+ window.setFrameOrigin(origin)
+ }
+
+ private func scheduleAutoClose() {
+ autoCloseTimer?.invalidate()
+ let seconds = CapturePreferences.quickAccessAutoCloseSeconds
+ guard seconds > 0 else { return }
+ autoCloseTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { [weak self] _ in
+ Task { @MainActor in self?.close() }
+ }
+ }
+
+ fileprivate func ensureDragFile() -> URL? {
+ if let dragFileURL { return dragFileURL }
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(
+ CaptureFileName.make(extension: "png")
+ )
+ writePNG(to: url)
+ dragFileURL = url
+ return url
+ }
+
+ private func writePNG(to url: URL) {
+ let rep = NSBitmapImageRep(cgImage: capture.image)
+ guard let data = rep.representation(using: .png, properties: [:]) else { return }
+ try? data.write(to: url, options: .atomic)
+ }
+
+ private func cleanupDragFile() {
+ if let dragFileURL {
+ try? FileManager.default.removeItem(at: dragFileURL)
+ self.dragFileURL = nil
+ }
+ }
+}
+
+enum QuickAccessShortcut: Equatable {
+ case copy
+ case save
+ case close
+ case upload
+ case annotate
+ case printCapture
+
+ static func action(
+ charactersIgnoringModifiers: String?,
+ modifierFlags: NSEvent.ModifierFlags,
+ keyCode: UInt16
+ ) -> QuickAccessShortcut? {
+ let mods = modifierFlags.intersection(.deviceIndependentFlagsMask)
+ if mods.contains(.command) {
+ switch charactersIgnoringModifiers?.lowercased() {
+ case "c": return .copy
+ case "s": return .save
+ case "w": return .close
+ case "u": return .upload
+ case "e": return .annotate
+ case "p": return .printCapture
+ default: break
+ }
+ }
+ if keyCode == 53 { return .close } // Esc
+ return nil
+ }
+}
+
+enum QuickAccessSwipe {
+ static let discardThreshold: CGFloat = 80
+
+ static func shouldDiscard(translationHeight: CGFloat) -> Bool {
+ translationHeight > discardThreshold
+ }
+}
+
+// MARK: - SwiftUI
+
+private struct QuickAccessView: View {
+ let capture: Capture
+ let isNewest: Bool
+ let temporaryFileURL: () -> URL?
+ let onAnnotate: () -> Void
+ let onCopy: () -> Void
+ let onSave: () -> Void
+ let onPin: () -> Void
+ let onUpload: () -> Void
+ let onPrint: () -> Void
+ let onClose: () -> Void
+ let onSwipeDiscard: () -> Void
+
+ @State private var dragOffset: CGFloat = 0
+
+ var body: some View {
+ VStack(spacing: 10) {
+ ZStack(alignment: .topTrailing) {
+ Image(decorative: capture.image, scale: capture.scale, orientation: .up)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxHeight: 120)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(isNewest ? Color.accentColor.opacity(0.7) : Color.primary.opacity(0.08), lineWidth: isNewest ? 2 : 1)
+ )
+ .onTapGesture(count: 2, perform: onAnnotate)
+ .onDrag {
+ if let url = temporaryFileURL() {
+ return NSItemProvider(contentsOf: url) ?? NSItemProvider()
+ }
+ return NSItemProvider()
+ }
+ .help("Double-click to Annotate · Drag thumbnail to share · Swipe down to discard")
+ .gesture(
+ DragGesture()
+ .onChanged { value in
+ if value.translation.height > 0 {
+ dragOffset = value.translation.height
+ }
+ }
+ .onEnded { value in
+ if QuickAccessSwipe.shouldDiscard(translationHeight: value.translation.height) {
+ onSwipeDiscard()
+ }
+ dragOffset = 0
+ }
+ )
+ .offset(y: dragOffset)
+
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 10, weight: .bold))
+ .padding(5)
+ .background(.ultraThinMaterial, in: Circle())
+ }
+ .buttonStyle(.plain)
+ .padding(4)
+ }
+
+ Text(sizeLabel)
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ actionButton("Annotate", systemImage: "pencil.tip.crop.circle", action: onAnnotate)
+ actionButton("Copy", systemImage: "doc.on.doc", action: onCopy)
+ actionButton("Save", systemImage: "square.and.arrow.down", action: onSave)
+ }
+
+ HStack(spacing: 8) {
+ actionButton("Pin", systemImage: "pin", action: onPin)
+ if UploadPreferences.isConfigured {
+ actionButton("Upload", systemImage: "link", action: onUpload)
+ }
+ actionButton("Print", systemImage: "printer", action: onPrint)
+ }
+ }
+ .padding(12)
+ .frame(width: 280)
+ .opacity(1 - Double(min(dragOffset, 120)) / 200)
+ }
+
+ private var sizeLabel: String {
+ "\(Int(capture.pointSize.width.rounded())) × \(Int(capture.pointSize.height.rounded())) pt · ⌘C ⌘S ⌘E"
+ }
+
+ private func actionButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Label(title, systemImage: systemImage)
+ .font(.system(size: 11, weight: .medium))
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+}
diff --git a/Sources/Parcel/Capture/ScrollCapture.swift b/Sources/Parcel/Capture/ScrollCapture.swift
index 2c42ec6..24f6e11 100644
--- a/Sources/Parcel/Capture/ScrollCapture.swift
+++ b/Sources/Parcel/Capture/ScrollCapture.swift
@@ -62,26 +62,39 @@ final class ScrollCaptureSession {
/// Uses Vision for a likely translation, then validates the overlap directly against sampled
/// pixels before compositing. Vision gets us to the right neighbourhood; pixel scoring rejects
/// weak registrations on repeated or static page regions.
-private enum ScrollCaptureStitcher {
+enum ScrollCaptureStitcher {
static func append(upper: CGImage, lower: CGImage) throws -> CGImage? {
- guard upper.width == lower.width, upper.height == lower.height else { return nil }
- let expected = visionExpectedOverlap(upper: upper, lower: lower)
- guard let overlap = PixelOverlapFinder.bestOverlap(upper: upper, lower: lower, expected: expected) else {
+ // Prefer vertical stitch; fall back to horizontal for sideways scrolling content.
+ if upper.width == lower.width, upper.height == lower.height {
+ let expected = visionExpectedOverlap(upper: upper, lower: lower, horizontal: false)
+ if let overlap = PixelOverlapFinder.bestOverlap(upper: upper, lower: lower, expected: expected, horizontal: false) {
+ return verticallyStack(upper: upper, lower: lower, overlap: overlap)
+ }
+ let expectedH = visionExpectedOverlap(upper: upper, lower: lower, horizontal: true)
+ if let overlap = PixelOverlapFinder.bestOverlap(upper: upper, lower: lower, expected: expectedH, horizontal: true) {
+ return horizontallyStack(left: upper, right: lower, overlap: overlap)
+ }
return nil
}
- return verticallyStack(upper: upper, lower: lower, overlap: overlap)
+ return nil
}
- private static func visionExpectedOverlap(upper: CGImage, lower: CGImage) -> Int? {
+ private static func visionExpectedOverlap(upper: CGImage, lower: CGImage, horizontal: Bool) -> Int? {
let request = VNTranslationalImageRegistrationRequest(targetedCGImage: upper)
let handler = VNImageRequestHandler(cgImage: lower, orientation: .up)
do {
try handler.perform([request])
guard let transform = request.results?.first?.alignmentTransform else { return nil }
- let shift = abs(Int(transform.ty.rounded()))
- let overlap = lower.height - shift
- return (16.. CGImage? {
+ let height = left.height
+ let width = left.width + right.width - overlap
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.translateBy(x: 0, y: CGFloat(height))
+ context.scaleBy(x: 1, y: -1)
+ context.draw(left, in: CGRect(x: 0, y: 0, width: left.width, height: height))
+ context.draw(
+ right,
+ in: CGRect(x: left.width - overlap, y: 0, width: right.width, height: height)
+ )
+ return context.makeImage()
+ }
}
private enum PixelOverlapFinder {
- /// Finds the lower-frame top rows that best match the upper-frame bottom rows. A score below
- /// 22 (mean absolute RGB difference on a 0…255 scale) is a conservative acceptance bound.
- static func bestOverlap(upper: CGImage, lower: CGImage, expected: Int?) -> Int? {
+ /// Finds the best overlap for vertical or horizontal stitching.
+ static func bestOverlap(upper: CGImage, lower: CGImage, expected: Int?, horizontal: Bool) -> Int? {
guard let a = PixelGrid(image: upper), let b = PixelGrid(image: lower),
a.width == b.width, a.height == b.height else { return nil }
- let minimum = max(24, a.height / 50)
- let maximum = a.height - 4
+ let axis = horizontal ? a.width : a.height
+ let minimum = max(24, axis / 50)
+ let maximum = axis - 4
guard minimum < maximum else { return nil }
var candidates = Set(stride(from: minimum, through: maximum, by: 8))
if let expected {
@@ -131,7 +165,9 @@ private enum PixelOverlapFinder {
var best: (overlap: Int, score: Double)?
for overlap in candidates {
- let score = a.difference(to: b, overlap: overlap)
+ let score = horizontal
+ ? a.horizontalDifference(to: b, overlap: overlap)
+ : a.difference(to: b, overlap: overlap)
if best == nil || score < best!.score { best = (overlap, score) }
}
guard let best, best.score < 22 else { return nil }
@@ -191,4 +227,25 @@ private struct PixelGrid {
}
return count > 0 ? Double(total) / Double(count) : .greatestFiniteMagnitude
}
+
+ func horizontalDifference(to other: PixelGrid, overlap: Int) -> Double {
+ let columnSamples = min(14, max(overlap / 20, 4))
+ let rowStep = max(1, height / 32)
+ var total = 0
+ var count = 0
+ for sample in 0.. 0 ? Double(total) / Double(count) : .greatestFiniteMagnitude
+ }
}
diff --git a/Sources/Parcel/Capture/SelectionAspectPreset.swift b/Sources/Parcel/Capture/SelectionAspectPreset.swift
new file mode 100644
index 0000000..3e2ab70
--- /dev/null
+++ b/Sources/Parcel/Capture/SelectionAspectPreset.swift
@@ -0,0 +1,55 @@
+import CoreGraphics
+
+enum SelectionAspectPreset: String, CaseIterable, Identifiable {
+ case free, square, standard, widescreen
+
+ var id: String { rawValue }
+ var label: String {
+ switch self {
+ case .free: return "Free"
+ case .square: return "1:1"
+ case .standard: return "4:3"
+ case .widescreen: return "16:9"
+ }
+ }
+ var ratio: CGFloat? {
+ switch self {
+ case .free: return nil
+ case .square: return 1
+ case .standard: return 4.0 / 3.0
+ case .widescreen: return 16.0 / 9.0
+ }
+ }
+
+ var next: SelectionAspectPreset {
+ let all = Self.allCases
+ guard let index = all.firstIndex(of: self) else { return self }
+ return all[(index + 1) % all.count]
+ }
+}
+
+enum SelectionGeometry {
+ static func rect(
+ start: CGPoint,
+ snappedEnd: CGPoint,
+ aspectPreset: SelectionAspectPreset,
+ bypassPreset: Bool
+ ) -> CGRect {
+ var end = snappedEnd
+ if !bypassPreset, let ratio = aspectPreset.ratio {
+ let dx = end.x - start.x
+ let dy = end.y - start.y
+ let horizontal = dx >= 0 ? 1.0 : -1.0
+ let vertical = dy >= 0 ? 1.0 : -1.0
+ var width = abs(dx)
+ var height = abs(dy)
+ if width / max(height, 0.001) > ratio {
+ height = width / ratio
+ } else {
+ width = height * ratio
+ }
+ end = CGPoint(x: start.x + horizontal * width, y: start.y + vertical * height)
+ }
+ return CGRect(corner: start, corner: end)
+ }
+}
diff --git a/Sources/Parcel/Capture/SelectionOverlayView.swift b/Sources/Parcel/Capture/SelectionOverlayView.swift
index d85f232..ac231d2 100644
--- a/Sources/Parcel/Capture/SelectionOverlayView.swift
+++ b/Sources/Parcel/Capture/SelectionOverlayView.swift
@@ -10,17 +10,31 @@ struct SelectionOverlayView: View {
let onCommit: (CGRect) -> Void
/// When set, a Scroll Capture button appears; commits route here instead of `onCommit`.
var onScrollCommit: ((CGRect) -> Void)? = nil
+ /// When set, OCR-mode commits route here.
+ var onOCRCommit: ((CGRect) -> Void)? = nil
+ /// When set, Record-mode commits route here.
+ var onRecordCommit: ((CGRect) -> Void)? = nil
+ /// Initial All-in-One mode (defaults to region).
+ var initialMode: OverlayCaptureMode = .region
@State private var dragOrigin: CGPoint?
+ @State private var dragCurrent: CGPoint?
@State private var liveRect: CGRect?
@State private var hoverWindow: SnapWindow?
+ @State private var snapCycleIndex = -1
@State private var isDragging = false
@State private var aspectPreset: SelectionAspectPreset = .free
- @State private var isScrollMode = false
+ @State private var mode: OverlayCaptureMode = .region
+ @State private var cursorPoint: CGPoint?
+
+ private var showCrosshair: Bool { CapturePreferences.showCrosshair }
+ private var showMagnifier: Bool { CapturePreferences.showMagnifier }
+ private var showAllInOne: Bool { CapturePreferences.showAllInOneBar }
/// The rect currently emphasized: an in-progress drag, else the hovered window.
private var highlightRect: CGRect? {
- liveRect ?? hoverWindow?.frameInScreen
+ if mode == .fullscreen { return CGRect(origin: .zero, size: frozen.pointSize) }
+ return liveRect ?? hoverWindow?.frameInScreen
}
var body: some View {
@@ -32,7 +46,15 @@ struct SelectionOverlayView: View {
DimmingLayer(hole: highlightRect)
- if liveRect == nil, let win = hoverWindow {
+ if showCrosshair, let point = cursorPoint, liveRect == nil {
+ CrosshairLayer(point: point, bounds: frozen.pointSize)
+ }
+
+ if showMagnifier, let point = cursorPoint, !isDragging {
+ MagnifierLoupe(frozen: frozen, point: point)
+ }
+
+ if liveRect == nil, mode != .fullscreen, let win = hoverWindow {
windowHighlight(win)
}
@@ -42,19 +64,25 @@ struct SelectionOverlayView: View {
}
VStack {
+ if showAllInOne {
+ allInOneBar
+ .padding(.top, 14)
+ }
Spacer()
HStack {
overlayHints
Spacer()
- if onScrollCommit != nil {
+ if onScrollCommit != nil, !showAllInOne {
scrollCaptureButton
}
}
.padding(14)
}
- aspectMenu
- .padding(14)
+ if !showAllInOne {
+ aspectMenu
+ .padding(14)
+ }
}
.frame(width: frozen.pointSize.width, height: frozen.pointSize.height)
.contentShape(Rectangle())
@@ -63,11 +91,22 @@ struct SelectionOverlayView: View {
switch phase {
case .active(let point):
NSCursor.crosshair.set()
- if !isDragging { hoverWindow = frozen.window(at: point) }
+ cursorPoint = point
+ if !isDragging, mode != .fullscreen {
+ hoverWindow = frozen.window(at: point)
+ snapCycleIndex = -1
+ }
case .ended:
+ cursorPoint = nil
hoverWindow = nil
+ snapCycleIndex = -1
}
}
+ .onReceive(NotificationCenter.default.publisher(for: .overlayTabPressed)) { note in
+ guard note.object as? CGDirectDisplayID == frozen.id else { return }
+ handleTab()
+ }
+ .onAppear { mode = initialMode }
}
.ignoresSafeArea()
}
@@ -77,21 +116,44 @@ struct SelectionOverlayView: View {
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
+ cursorPoint = value.location
+ if mode == .window || mode == .fullscreen { return }
if dragOrigin == nil { dragOrigin = value.startLocation }
+ dragCurrent = value.location
let rect = selectionRect(start: value.startLocation, current: value.location)
if rect.width > 2 || rect.height > 2 {
isDragging = true
hoverWindow = nil
+ snapCycleIndex = -1
liveRect = rect
}
}
.onEnded { value in
- defer { dragOrigin = nil; isDragging = false; liveRect = nil }
+ defer {
+ dragOrigin = nil
+ dragCurrent = nil
+ isDragging = false
+ liveRect = nil
+ }
+
+ if mode == .fullscreen {
+ commitSelection(CGRect(origin: .zero, size: frozen.pointSize))
+ return
+ }
+
+ if mode == .window {
+ if let win = hoverWindow ?? frozen.window(at: value.location) {
+ commitSelection(win.frameInScreen.integral)
+ }
+ return
+ }
+
let travelled = value.startLocation.distance(to: value.location)
if travelled < 4 {
- // A click — snap to the window under the cursor, if any.
- if let win = frozen.window(at: value.location) {
- commitSelection(win.frameInScreen.integral)
+ if mode == .region || mode == .scroll {
+ if let win = hoverWindow ?? frozen.window(at: value.location) {
+ commitSelection(win.frameInScreen.integral)
+ }
}
} else {
let rect = selectionRect(start: value.startLocation, current: value.location).integral
@@ -100,33 +162,57 @@ struct SelectionOverlayView: View {
}
}
+ private func handleTab() {
+ if isDragging, let start = dragOrigin, let current = dragCurrent {
+ aspectPreset = aspectPreset.next
+ liveRect = selectionRect(start: start, current: current)
+ return
+ }
+ cycleSnapWindow()
+ }
+
+ private func cycleSnapWindow() {
+ let windows = frozen.windows.sorted { $0.frameInScreen.area < $1.frameInScreen.area }
+ guard !windows.isEmpty else { return }
+ snapCycleIndex = (snapCycleIndex + 1) % windows.count
+ hoverWindow = windows[snapCycleIndex]
+ }
+
private func commitSelection(_ rect: CGRect) {
- if isScrollMode, let onScrollCommit {
- onScrollCommit(rect)
- } else {
+ switch mode {
+ case .scroll:
+ if let onScrollCommit {
+ onScrollCommit(rect)
+ } else {
+ onCommit(rect)
+ }
+ case .ocr:
+ if let onOCRCommit {
+ onOCRCommit(rect)
+ } else {
+ onCommit(rect)
+ }
+ case .record:
+ if let onRecordCommit {
+ onRecordCommit(rect)
+ } else {
+ onCommit(rect)
+ }
+ case .region, .window, .fullscreen:
onCommit(rect)
}
}
/// Snaps the actively dragged corner to display and detected-window boundaries, then applies
/// an optional aspect preset while retaining the original drag direction.
+ /// Hold ⇧ Shift while dragging to temporarily ignore the aspect preset.
private func selectionRect(start: CGPoint, current: CGPoint) -> CGRect {
- var end = snapped(point: current)
- if let ratio = aspectPreset.ratio {
- let dx = end.x - start.x
- let dy = end.y - start.y
- let horizontal = dx >= 0 ? 1.0 : -1.0
- let vertical = dy >= 0 ? 1.0 : -1.0
- var width = abs(dx)
- var height = abs(dy)
- if width / max(height, 0.001) > ratio {
- height = width / ratio
- } else {
- width = height * ratio
- }
- end = CGPoint(x: start.x + horizontal * width, y: start.y + vertical * height)
- }
- return CGRect(corner: start, corner: end)
+ SelectionGeometry.rect(
+ start: start,
+ snappedEnd: snapped(point: current),
+ aspectPreset: aspectPreset,
+ bypassPreset: NSEvent.modifierFlags.contains(.shift)
+ )
}
private func snapped(point: CGPoint) -> CGPoint {
@@ -158,7 +244,7 @@ struct SelectionOverlayView: View {
private func selectionBorder(_ rect: CGRect) -> some View {
Rectangle()
- .stroke(Color.accentColor, lineWidth: 1.5)
+ .stroke(mode == .ocr ? Color.green : Color.accentColor, lineWidth: 1.5)
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
.allowsHitTesting(false)
@@ -178,9 +264,9 @@ struct SelectionOverlayView: View {
private var overlayHints: some View {
VStack(alignment: .leading, spacing: 4) {
- Text(isScrollMode ? "Scroll Capture — drag a tall region" : "Drag to select · Click window to snap")
+ Text(mode.hint)
.font(.system(size: 11, weight: .medium))
- Text("Esc to cancel")
+ Text("Tab window snap · Tab aspect while dragging · Esc to cancel")
.font(.system(size: 10))
.foregroundStyle(.secondary)
}
@@ -191,16 +277,69 @@ struct SelectionOverlayView: View {
private var scrollCaptureButton: some View {
Button {
- isScrollMode.toggle()
+ mode = mode == .scroll ? .region : .scroll
} label: {
- Label(isScrollMode ? "Scroll mode on" : "Scroll Capture", systemImage: "arrow.up.and.down.text.horizontal")
+ Label(mode == .scroll ? "Scroll mode on" : "Scroll Capture", systemImage: "arrow.up.and.down.text.horizontal")
.font(.system(size: 11, weight: .medium))
}
.buttonStyle(.borderedProminent)
- .tint(isScrollMode ? .orange : .accentColor)
+ .tint(mode == .scroll ? .orange : .accentColor)
.help("Select a region, then scroll the source to stitch frames")
}
+ private var allInOneBar: some View {
+ HStack(spacing: 4) {
+ ForEach(availableModes) { item in
+ Button {
+ mode = item
+ } label: {
+ VStack(spacing: 2) {
+ Image(systemName: item.systemImage)
+ .font(.system(size: 13, weight: .semibold))
+ Text(item.label)
+ .font(.system(size: 9, weight: .medium))
+ }
+ .frame(width: 58, height: 40)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(mode == item ? Color.accentColor.opacity(0.9) : Color.black.opacity(0.55))
+ )
+ .foregroundStyle(.white)
+ }
+ .buttonStyle(.plain)
+ .help(item.hint)
+ }
+
+ Menu {
+ ForEach(SelectionAspectPreset.allCases) { preset in
+ Button {
+ aspectPreset = preset
+ } label: {
+ if aspectPreset == preset {
+ Label(preset.label, systemImage: "checkmark")
+ } else {
+ Text(preset.label)
+ }
+ }
+ }
+ } label: {
+ Image(systemName: "aspectratio")
+ .frame(width: 36, height: 40)
+ .background(Color.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 8))
+ .foregroundStyle(.white)
+ }
+ .menuStyle(.borderlessButton)
+ .help("Selection aspect ratio")
+ }
+ .padding(6)
+ .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .shadow(color: .black.opacity(0.25), radius: 10, y: 4)
+ }
+
+ private var availableModes: [OverlayCaptureMode] {
+ OverlayCaptureMode.allCases
+ }
+
private var aspectMenu: some View {
Menu {
ForEach(SelectionAspectPreset.allCases) { preset in
@@ -225,25 +364,77 @@ struct SelectionOverlayView: View {
}
}
-private enum SelectionAspectPreset: String, CaseIterable, Identifiable {
- case free, square, standard, widescreen
+// MARK: - Crosshair / Magnifier
+
+private struct CrosshairLayer: View {
+ let point: CGPoint
+ let bounds: CGSize
+
+ var body: some View {
+ ZStack {
+ Path { path in
+ path.move(to: CGPoint(x: 0, y: point.y))
+ path.addLine(to: CGPoint(x: bounds.width, y: point.y))
+ }
+ .stroke(Color.white.opacity(0.55), lineWidth: 1)
+
+ Path { path in
+ path.move(to: CGPoint(x: point.x, y: 0))
+ path.addLine(to: CGPoint(x: point.x, y: bounds.height))
+ }
+ .stroke(Color.white.opacity(0.55), lineWidth: 1)
+ }
+ .allowsHitTesting(false)
+ }
+}
+
+private struct MagnifierLoupe: View {
+ let frozen: FrozenScreen
+ let point: CGPoint
+
+ private let loupeSize: CGFloat = 110
+ private let zoom: CGFloat = 2.5
+
+ var body: some View {
+ let crop = CGRect(
+ x: point.x - loupeSize / (2 * zoom),
+ y: point.y - loupeSize / (2 * zoom),
+ width: loupeSize / zoom,
+ height: loupeSize / zoom
+ )
+ let pixel = CGRect(
+ x: crop.minX * frozen.scale,
+ y: crop.minY * frozen.scale,
+ width: crop.width * frozen.scale,
+ height: crop.height * frozen.scale
+ ).integral
+ let clamped = pixel.intersection(
+ CGRect(x: 0, y: 0, width: frozen.image.width, height: frozen.image.height)
+ )
- var id: String { rawValue }
- var label: String {
- switch self {
- case .free: return "Free"
- case .square: return "1:1"
- case .standard: return "4:3"
- case .widescreen: return "16:9"
+ Group {
+ if clamped.width > 1, clamped.height > 1, let cropped = frozen.image.cropping(to: clamped) {
+ Image(decorative: cropped, scale: frozen.scale / zoom, orientation: .up)
+ .frame(width: loupeSize, height: loupeSize)
+ .clipShape(Circle())
+ .overlay(Circle().stroke(Color.white, lineWidth: 2))
+ .shadow(radius: 6)
+ .position(loupePosition)
+ }
}
+ .allowsHitTesting(false)
}
- var ratio: CGFloat? {
- switch self {
- case .free: return nil
- case .square: return 1
- case .standard: return 4.0 / 3.0
- case .widescreen: return 16.0 / 9.0
+
+ private var loupePosition: CGPoint {
+ var x = point.x + loupeSize * 0.7
+ var y = point.y + loupeSize * 0.7
+ if x + loupeSize / 2 > frozen.pointSize.width {
+ x = point.x - loupeSize * 0.7
+ }
+ if y + loupeSize / 2 > frozen.pointSize.height {
+ y = point.y - loupeSize * 0.7
}
+ return CGPoint(x: x, y: y)
}
}
diff --git a/Sources/Parcel/Editor/AnnotatedCanvas.swift b/Sources/Parcel/Editor/AnnotatedCanvas.swift
index 2d83ceb..0437765 100644
--- a/Sources/Parcel/Editor/AnnotatedCanvas.swift
+++ b/Sources/Parcel/Editor/AnnotatedCanvas.swift
@@ -209,7 +209,7 @@ struct AnnotatedCanvas: View {
context.fill(Path(rect), with: .color(style.color.color))
case .erase:
if let cg = base.cgImage(forProposedRect: nil, context: nil, hints: nil),
- let fill = CensorEraseSampler.averageBorderColor(in: rect, image: cg) {
+ let fill = CensorEraseSampler.averageSurroundingColor(in: rect, image: cg, pointSize: pointSize) {
context.fill(Path(rect), with: .color(fill.color))
} else {
context.fill(Path(rect), with: .color(style.color.color))
diff --git a/Sources/Parcel/Editor/CaptureTransform.swift b/Sources/Parcel/Editor/CaptureTransform.swift
new file mode 100644
index 0000000..2d7fd5f
--- /dev/null
+++ b/Sources/Parcel/Editor/CaptureTransform.swift
@@ -0,0 +1,326 @@
+import AppKit
+import CoreGraphics
+import CoreImage
+
+/// Document-level Capture transforms (crop / resize / rotate / flip / expand / combine).
+/// Remaps Annotation geometry in Capture-point space so the Phase 2 render pipeline stays valid.
+enum CaptureTransform {
+
+ static func crop(_ capture: Capture, toPoints rect: CGRect) -> Capture? {
+ let scale = capture.scale
+ var pixelRect = CGRect(
+ x: rect.minX * scale,
+ y: rect.minY * scale,
+ width: rect.width * scale,
+ height: rect.height * scale
+ ).integral
+ let bounds = CGRect(x: 0, y: 0, width: capture.image.width, height: capture.image.height)
+ pixelRect = pixelRect.intersection(bounds)
+ guard pixelRect.width >= 1, pixelRect.height >= 1,
+ let cropped = capture.image.cropping(to: pixelRect)
+ else { return nil }
+ return Capture(image: cropped, scale: scale)
+ }
+
+ static func resize(_ capture: Capture, toPointSize size: CGSize) -> Capture? {
+ let width = max(1, Int((size.width * capture.scale).rounded()))
+ let height = max(1, Int((size.height * capture.scale).rounded()))
+ guard let image = redraw(capture.image, width: width, height: height) else { return nil }
+ return Capture(image: image, scale: capture.scale)
+ }
+
+ static func rotate90CW(_ capture: Capture) -> Capture? {
+ guard let image = rotate(capture.image, degrees: -90) else { return nil }
+ return Capture(image: image, scale: capture.scale)
+ }
+
+ static func flipHorizontal(_ capture: Capture) -> Capture? {
+ guard let image = flip(capture.image, horizontal: true, vertical: false) else { return nil }
+ return Capture(image: image, scale: capture.scale)
+ }
+
+ static func flipVertical(_ capture: Capture) -> Capture? {
+ guard let image = flip(capture.image, horizontal: false, vertical: true) else { return nil }
+ return Capture(image: image, scale: capture.scale)
+ }
+
+ /// Expand canvas by padding (Capture points) filled with `color`.
+ static func expand(
+ _ capture: Capture,
+ top: CGFloat,
+ left: CGFloat,
+ bottom: CGFloat,
+ right: CGFloat,
+ fill: NSColor
+ ) -> Capture? {
+ let scale = capture.scale
+ let newWidth = max(1, Int(((capture.pointSize.width + left + right) * scale).rounded()))
+ let newHeight = max(1, Int(((capture.pointSize.height + top + bottom) * scale).rounded()))
+ guard let context = CGContext(
+ data: nil,
+ width: newWidth,
+ height: newHeight,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.setFillColor(fill.cgColor)
+ context.fill(CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
+ let dest = CGRect(
+ x: left * scale,
+ y: bottom * scale,
+ width: CGFloat(capture.image.width),
+ height: CGFloat(capture.image.height)
+ )
+ context.draw(capture.image, in: dest)
+ guard let image = context.makeImage() else { return nil }
+ return Capture(image: image, scale: scale)
+ }
+
+ /// Place `other` into `rect` (Capture points) on `base`.
+ static func combine(base: Capture, other: Capture, into rect: CGRect) -> Capture? {
+ let scale = base.scale
+ guard let context = CGContext(
+ data: nil,
+ width: base.image.width,
+ height: base.image.height,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: base.image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.draw(base.image, in: CGRect(x: 0, y: 0, width: base.image.width, height: base.image.height))
+ let dest = CGRect(
+ x: rect.minX * scale,
+ y: (base.pointSize.height - rect.maxY) * scale,
+ width: rect.width * scale,
+ height: rect.height * scale
+ )
+ context.interpolationQuality = .high
+ context.draw(other.image, in: dest)
+ guard let image = context.makeImage() else { return nil }
+ return Capture(image: image, scale: scale)
+ }
+
+ /// Makes pixels near the corner-sampled backdrop color transparent (window Capture matte).
+ static func removeBackground(_ capture: Capture, tolerance: CGFloat) -> Capture? {
+ let image = capture.image
+ let width = image.width
+ let height = image.height
+ guard width > 2, height > 2 else { return nil }
+ var pixels = [UInt8](repeating: 0, count: width * height * 4)
+ let ok = pixels.withUnsafeMutableBytes { buffer -> Bool in
+ guard let context = CGContext(
+ data: buffer.baseAddress,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
+ ) else { return false }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return true
+ }
+ guard ok else { return nil }
+
+ func color(at x: Int, y: Int) -> (r: Int, g: Int, b: Int) {
+ let i = (y * width + x) * 4
+ return (Int(pixels[i]), Int(pixels[i + 1]), Int(pixels[i + 2]))
+ }
+ let samples = [
+ color(at: 0, y: 0),
+ color(at: width - 1, y: 0),
+ color(at: 0, y: height - 1),
+ color(at: width - 1, y: height - 1),
+ ]
+ let avgR = samples.map(\.r).reduce(0, +) / samples.count
+ let avgG = samples.map(\.g).reduce(0, +) / samples.count
+ let avgB = samples.map(\.b).reduce(0, +) / samples.count
+ let tol = Int(tolerance)
+
+ for y in 0.. [Annotation] {
+ annotations.map { annotation in
+ var next = annotation
+ next.kind = mapKind(annotation.kind) { CGPoint(x: $0.x + delta.x, y: $0.y + delta.y) }
+ return next
+ }
+ }
+
+ static func scaleAnnotations(_ annotations: [Annotation], from oldSize: CGSize, to newSize: CGSize) -> [Annotation] {
+ let sx = newSize.width / max(oldSize.width, 0.001)
+ let sy = newSize.height / max(oldSize.height, 0.001)
+ return annotations.map { annotation in
+ var next = annotation
+ next.kind = mapKind(annotation.kind) { CGPoint(x: $0.x * sx, y: $0.y * sy) }
+ if case let .number(center, radius, value) = next.kind {
+ next.kind = .number(center: center, radius: radius * min(sx, sy), value: value)
+ }
+ return next
+ }
+ }
+
+ static func cropAnnotations(_ annotations: [Annotation], by rect: CGRect) -> [Annotation] {
+ offsetAnnotations(annotations, by: CGPoint(x: -rect.minX, y: -rect.minY))
+ }
+
+ static func rotateAnnotations90CW(_ annotations: [Annotation], canvasSize: CGSize) -> [Annotation] {
+ annotations.map { annotation in
+ var next = annotation
+ next.kind = mapKind(annotation.kind) { point in
+ CGPoint(x: canvasSize.height - point.y, y: point.x)
+ }
+ next.rotation += .pi / 2
+ return next
+ }
+ }
+
+ static func flipAnnotationsH(_ annotations: [Annotation], canvasWidth: CGFloat) -> [Annotation] {
+ annotations.map { annotation in
+ var next = annotation
+ next.kind = mapKind(annotation.kind) { CGPoint(x: canvasWidth - $0.x, y: $0.y) }
+ return next
+ }
+ }
+
+ static func flipAnnotationsV(_ annotations: [Annotation], canvasHeight: CGFloat) -> [Annotation] {
+ annotations.map { annotation in
+ var next = annotation
+ next.kind = mapKind(annotation.kind) { CGPoint(x: $0.x, y: canvasHeight - $0.y) }
+ return next
+ }
+ }
+
+ private static func mapKind(_ kind: AnnotationKind, _ transform: (CGPoint) -> CGPoint) -> AnnotationKind {
+ switch kind {
+ case let .arrow(start, end):
+ return .arrow(start: transform(start), end: transform(end))
+ case let .rectangle(rect):
+ return .rectangle(rect: transformRect(rect, transform))
+ case let .ellipse(rect):
+ return .ellipse(rect: transformRect(rect, transform))
+ case let .censor(rect):
+ return .censor(rect: transformRect(rect, transform))
+ case let .text(rect, string):
+ return .text(rect: transformRect(rect, transform), string: string)
+ case let .pencil(points):
+ return .pencil(points: points.map(transform))
+ case let .number(center, radius, value):
+ return .number(center: transform(center), radius: radius, value: value)
+ case let .stamp(rect, emoji):
+ return .stamp(rect: transformRect(rect, transform), emoji: emoji)
+ case let .highlight(points):
+ return .highlight(points: points.map(transform))
+ case let .measure(start, end, showsSize):
+ return .measure(start: transform(start), end: transform(end), showsSize: showsSize)
+ case let .spotlight(rect, shape):
+ return .spotlight(rect: transformRect(rect, transform), shape: shape)
+ }
+ }
+
+ private static func transformRect(_ rect: CGRect, _ transform: (CGPoint) -> CGPoint) -> CGRect {
+ let a = transform(CGPoint(x: rect.minX, y: rect.minY))
+ let b = transform(CGPoint(x: rect.maxX, y: rect.maxY))
+ return CGRect(
+ x: min(a.x, b.x),
+ y: min(a.y, b.y),
+ width: abs(b.x - a.x),
+ height: abs(b.y - a.y)
+ )
+ }
+
+ // MARK: Pixel helpers
+
+ private static func redraw(_ image: CGImage, width: Int, height: Int) -> CGImage? {
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.interpolationQuality = .high
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return context.makeImage()
+ }
+
+ private static func rotate(_ image: CGImage, degrees: CGFloat) -> CGImage? {
+ let radians = degrees * .pi / 180
+ let width = CGFloat(image.width)
+ let height = CGFloat(image.height)
+ let newWidth = abs(cos(radians) * width) + abs(sin(radians) * height)
+ let newHeight = abs(sin(radians) * width) + abs(cos(radians) * height)
+ guard let context = CGContext(
+ data: nil,
+ width: Int(newWidth.rounded()),
+ height: Int(newHeight.rounded()),
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.translateBy(x: newWidth / 2, y: newHeight / 2)
+ context.rotate(by: radians)
+ context.draw(image, in: CGRect(x: -width / 2, y: -height / 2, width: width, height: height))
+ return context.makeImage()
+ }
+
+ private static func flip(_ image: CGImage, horizontal: Bool, vertical: Bool) -> CGImage? {
+ let width = image.width
+ let height = image.height
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.translateBy(
+ x: horizontal ? CGFloat(width) : 0,
+ y: vertical ? CGFloat(height) : 0
+ )
+ context.scaleBy(x: horizontal ? -1 : 1, y: vertical ? -1 : 1)
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return context.makeImage()
+ }
+}
diff --git a/Sources/Parcel/Editor/CensorEraseSampler.swift b/Sources/Parcel/Editor/CensorEraseSampler.swift
index 8243b65..686da17 100644
--- a/Sources/Parcel/Editor/CensorEraseSampler.swift
+++ b/Sources/Parcel/Editor/CensorEraseSampler.swift
@@ -3,12 +3,18 @@ import CoreGraphics
/// Samples surrounding Capture pixels to fill erase-mode Censors locally.
enum CensorEraseSampler {
- static func averageBorderColor(in rect: CGRect, image: CGImage) -> RGBAColor? {
- let r = rect.standardized.integral
+ static func averageSurroundingColor(
+ in rect: CGRect,
+ image: CGImage,
+ pointSize: CGSize? = nil
+ ) -> RGBAColor? {
+ let r = rect.standardized
guard r.width > 0, r.height > 0 else { return nil }
let width = image.width
let height = image.height
+ let pointSize = pointSize ?? CGSize(width: width, height: height)
+ guard pointSize.width > 0, pointSize.height > 0 else { return nil }
guard let data = image.dataProvider?.data,
let ptr = CFDataGetBytePtr(data) else { return nil }
@@ -31,19 +37,21 @@ enum CensorEraseSampler {
count += 1
}
- let minX = max(0, Int(r.minX))
- let maxX = min(width - 1, Int(r.maxX))
- let minY = max(0, Int(r.minY))
- let maxY = min(height - 1, Int(r.maxY))
- guard minX <= maxX, minY <= maxY else { return nil }
+ let scaleX = CGFloat(width) / pointSize.width
+ let scaleY = CGFloat(height) / pointSize.height
+ let minX = Int(floor(r.minX * scaleX))
+ let maxX = Int(ceil(r.maxX * scaleX))
+ let minY = Int(floor(r.minY * scaleY))
+ let maxY = Int(ceil(r.maxY * scaleY))
+ guard minX < maxX, minY < maxY else { return nil }
- for x in minX...maxX {
- sample(x: x, y: minY)
- if maxY != minY { sample(x: x, y: maxY) }
+ for x in (minX - 1)...maxX {
+ sample(x: x, y: minY - 1)
+ sample(x: x, y: maxY)
}
- for y in minY...maxY {
- sample(x: minX, y: y)
- if maxX != minX { sample(x: maxX, y: y) }
+ for y in minY.. 0 else { return nil }
diff --git a/Sources/Parcel/Editor/EditorCanvasView.swift b/Sources/Parcel/Editor/EditorCanvasView.swift
index e2d49a8..c08c8d6 100644
--- a/Sources/Parcel/Editor/EditorCanvasView.swift
+++ b/Sources/Parcel/Editor/EditorCanvasView.swift
@@ -16,6 +16,9 @@ struct EditorCanvasView: View {
@State private var lastClick: (time: Date, point: CGPoint)?
@State private var hoverLocation: CGPoint? // canvas-local view points, same space as the drag
@State private var rotationDragStartAngle: CGFloat = 0
+ @State private var shiftHeld = false
+ @State private var spaceHeld = false
+ @State private var draftRepositionAnchor: CGPoint?
@FocusState private var textFocused: Bool
private enum DragOp {
@@ -99,6 +102,15 @@ struct EditorCanvasView: View {
model.selectedID = nil
}
}
+ .background(
+ ModifierKeyTracker(
+ onShiftChange: { shiftHeld = $0 },
+ onSpaceChange: { held in
+ spaceHeld = held
+ if !held { draftRepositionAnchor = nil }
+ }
+ )
+ )
}
}
@@ -220,7 +232,16 @@ struct EditorCanvasView: View {
if case .none = op { beginOp(at: start, scale: scale) }
switch op {
case .creating:
- updateDraft(start: start, current: current)
+ if spaceHeld {
+ if let anchor = draftRepositionAnchor {
+ let delta = CGPoint(x: current.x - anchor.x, y: current.y - anchor.y)
+ translateDraft(by: delta)
+ }
+ draftRepositionAnchor = current
+ } else {
+ draftRepositionAnchor = nil
+ updateDraft(start: start, current: constrainedEnd(start: start, end: current))
+ }
case let .moving(_, original):
model.updateSelected(kind: original.translated(dx: current.x - start.x, dy: current.y - start.y))
case let .resizing(_, handle, original):
@@ -240,7 +261,7 @@ struct EditorCanvasView: View {
switch op {
case .creating:
- finalizeDraft(start: start, current: current)
+ finalizeDraft(start: start, current: constrainedEnd(start: start, end: current))
case let .moving(before, _):
model.commitInteractive(before: before)
case let .resizing(before, _, _):
@@ -362,6 +383,39 @@ struct EditorCanvasView: View {
self.draft = draft
}
+ private func translateDraft(by delta: CGPoint) {
+ guard var draft else { return }
+ draft.kind = draft.kind.translated(dx: delta.x, dy: delta.y)
+ self.draft = draft
+ }
+
+ private func constrainedEnd(start: CGPoint, end: CGPoint) -> CGPoint {
+ guard shiftHeld else { return end }
+ let dx = end.x - start.x
+ let dy = end.y - start.y
+ switch model.activeTool {
+ case .arrow, .measure:
+ let length = hypot(dx, dy)
+ guard length > 0 else { return end }
+ let angle = atan2(dy, dx)
+ let snap = (angle / (.pi / 4)).rounded() * (.pi / 4)
+ return CGPoint(x: start.x + cos(snap) * length, y: start.y + sin(snap) * length)
+ case .rectangle, .ellipse, .censor, .spotlight, .stamp:
+ let side = max(abs(dx), abs(dy))
+ return CGPoint(
+ x: start.x + (dx >= 0 ? side : -side),
+ y: start.y + (dy >= 0 ? side : -side)
+ )
+ case .pencil, .highlighter:
+ if abs(dx) >= abs(dy) {
+ return CGPoint(x: end.x, y: start.y)
+ }
+ return CGPoint(x: start.x, y: end.y)
+ case .select, .text, .number, .loupe, .eyedropper:
+ return end
+ }
+ }
+
private func finalizeDraft(start: CGPoint, current: CGPoint) {
guard let draft else { return }
defer { self.draft = nil }
@@ -395,7 +449,11 @@ struct EditorCanvasView: View {
}
model.add(annotation)
case .highlighter:
- if case let .highlight(points) = draft.kind, points.count >= 2 { model.add(draft) }
+ if case let .highlight(points) = draft.kind, points.count >= 2 {
+ var snapped = draft
+ snapped.kind = .highlight(points: model.smartSnapHighlighter(points))
+ model.add(snapped)
+ }
case .measure:
if case let .measure(s, e, _) = draft.kind, s.distance(to: e) >= 4 { model.add(draft) }
case .spotlight:
diff --git a/Sources/Parcel/Editor/EditorModel.swift b/Sources/Parcel/Editor/EditorModel.swift
index 2d0d87a..44ccbed 100644
--- a/Sources/Parcel/Editor/EditorModel.swift
+++ b/Sources/Parcel/Editor/EditorModel.swift
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
@MainActor
final class EditorModel: ObservableObject {
- let capture: Capture
+ @Published private(set) var capture: Capture
// MARK: Annotation state
@@ -51,8 +51,10 @@ final class EditorModel: ObservableObject {
@Published private(set) var blurredImage: CGImage
@Published private(set) var pixelatedImage: CGImage
- private let adjuster: CaptureAdjuster
+ private var adjuster: CaptureAdjuster
private var censorSourcesReady = false
+ /// Custom color swatches persisted across Editor sessions.
+ @Published var savedColors: [RGBAColor] = ColorSwatchStore.load()
/// The Adjustments the published images currently reflect (display can lag `adjustments`
/// by the render debounce; export syncs the two).
private var displayedAdjustments: Adjustments = .neutral
@@ -77,7 +79,7 @@ final class EditorModel: ObservableObject {
@Published private(set) var isUploading = false
@Published var statusMessage: String?
- // MARK: Translation (on-device, macOS 15+)
+ // MARK: Translation (on-device, macOS 26+)
@Published private(set) var translatedText: String?
@Published private(set) var isTranslating = false
@@ -524,7 +526,7 @@ final class EditorModel: ObservableObject {
if let result, !result.isEmpty {
statusMessage = "Translation ready — copy from Vision panel."
} else if !TranslationService.isAvailable {
- statusMessage = "On-device translation requires macOS 15 or later."
+ statusMessage = "On-device translation requires macOS 26 or later."
}
}
}
@@ -567,6 +569,129 @@ final class EditorModel: ObservableObject {
ensureCensorSourcesReady()
}
+ // MARK: Capture transforms (document-level, undoable as annotation snapshots)
+
+ func cropCapture(toPoints rect: CGRect) {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.crop(capture, toPoints: rect) else { return }
+ pushUndo()
+ annotations = CaptureTransform.cropAnnotations(annotations, by: rect)
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func resizeCapture(toPointSize size: CGSize) {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.resize(capture, toPointSize: size) else { return }
+ pushUndo()
+ annotations = CaptureTransform.scaleAnnotations(annotations, from: oldSize, to: size)
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func rotateCapture90CW() {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.rotate90CW(capture) else { return }
+ pushUndo()
+ annotations = CaptureTransform.rotateAnnotations90CW(annotations, canvasSize: oldSize)
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func flipCaptureHorizontal() {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.flipHorizontal(capture) else { return }
+ pushUndo()
+ annotations = CaptureTransform.flipAnnotationsH(annotations, canvasWidth: oldSize.width)
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func flipCaptureVertical() {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.flipVertical(capture) else { return }
+ pushUndo()
+ annotations = CaptureTransform.flipAnnotationsV(annotations, canvasHeight: oldSize.height)
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func expandCanvas(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat, fill: NSColor = .white) {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.expand(
+ capture, top: top, left: left, bottom: bottom, right: right, fill: fill
+ ) else { return }
+ pushUndo()
+ annotations = CaptureTransform.offsetAnnotations(
+ annotations,
+ by: CGPoint(x: left, y: top)
+ )
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func combineCapture(_ other: Capture, into rect: CGRect) {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.combine(base: capture, other: other, into: rect) else { return }
+ pushUndo()
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ func removeOpaqueBackground(tolerance: CGFloat = 28) {
+ let oldSize = capture.pointSize
+ guard let next = CaptureTransform.removeBackground(capture, tolerance: tolerance) else { return }
+ pushUndo()
+ replaceCapture(next, remappedFrom: oldSize)
+ }
+
+ /// Snaps highlighter stroke endpoints toward nearby OCR word boxes (on-device Vision).
+ func smartSnapHighlighter(_ points: [CGPoint]) -> [CGPoint] {
+ HighlighterSnapper.snappedPoints(points, to: visionAnalysis.text.map(\.rect))
+ }
+
+ func saveCurrentColorSwatch() {
+ ColorSwatchStore.add(toolColor)
+ savedColors = ColorSwatchStore.load()
+ }
+
+ func printCapture() {
+ guard let image = renderedNSImage() else { return }
+ CapturePrintPayload.printOperation(for: image).run()
+ }
+
+ func shareCapture() {
+ guard let image = renderedNSImage() else { return }
+ let picker = CaptureSharePayload.picker(for: image)
+ if let anchor = CaptureSharePayload.anchor(in: NSApp.keyWindow) {
+ picker.show(relativeTo: anchor.rect, of: anchor.view, preferredEdge: anchor.preferredEdge)
+ }
+ }
+
+ func saveParcelProject() {
+ endEditingText()
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [UTType(filenameExtension: "parcel") ?? .data]
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = CaptureFileName.make(extension: "parcel")
+ panel.begin { [weak self] response in
+ guard response == .OK, let url = panel.url, let self else { return }
+ ParcelProjectIO.save(model: self, to: url)
+ }
+ }
+
+ private func replaceCapture(_ next: Capture, remappedFrom _: CGSize) {
+ capture = next
+ adjuster = CaptureAdjuster(capture: next)
+ censorSourcesReady = annotations.contains {
+ if case .censor = $0.kind { return true }
+ return false
+ }
+ displayedAdjustments = .neutral
+ adjustments = adjustments // trigger refresh path
+ let adjusted = adjuster.makeAdjusted(adjustments)
+ adjustedImage = adjusted
+ baseImage = NSImage(cgImage: adjusted, size: next.pointSize)
+ blurredImage = censorSourcesReady ? adjuster.makeBlurred(adjustments) : adjusted
+ pixelatedImage = censorSourcesReady ? adjuster.makePixelated(adjustments) : adjusted
+ displayedAdjustments = adjustments
+ _pixelSampler = nil
+ objectWillChange.send()
+ }
+
// MARK: Output
func copyToClipboard() {
@@ -581,7 +706,7 @@ final class EditorModel: ObservableObject {
let panel = NSSavePanel()
panel.allowedContentTypes = [outputFormat.contentType]
panel.canCreateDirectories = true
- panel.nameFieldStringValue = Self.defaultFileName(format: outputFormat)
+ panel.nameFieldStringValue = CaptureFileName.make(extension: outputFormat.fileExtension)
if let dir = Self.lastSaveDirectory { panel.directoryURL = dir }
panel.begin { [weak self] response in
@@ -609,7 +734,7 @@ final class EditorModel: ObservableObject {
isUploading = true
statusMessage = "Uploading…"
- let fileName = Self.defaultFileName(format: .png)
+ let fileName = CaptureFileName.make(extension: "png")
Task { [weak self] in
do {
let url = try await UploadService.uploadPNG(data: data, fileName: fileName)
@@ -687,38 +812,50 @@ final class EditorModel: ObservableObject {
}
private func writeImage(to url: URL) {
- guard let cgImage = renderedCGImage() else {
+ guard var cgImage = renderedCGImage() else {
NSLog("Parcel: failed to render Capture for save")
return
}
- let data: Data?
- if outputFormat == .heic {
+ if CapturePreferences.convertToSRGB {
+ cgImage = ImageColorSpace.convertToSRGB(cgImage) ?? cgImage
+ }
+ let data = Self.encodeImage(
+ cgImage,
+ as: outputFormat,
+ outputSize: effectiveSettings.outerSize(for: pointSize)
+ )
+ guard let data else {
+ NSLog("Parcel: failed to encode \(outputFormat.label)")
+ return
+ }
+ do {
+ try data.write(to: url)
+ } catch {
+ NSLog("Parcel: failed to write Capture — \(error)")
+ }
+ }
+
+ static func encodeImage(_ cgImage: CGImage, as outputFormat: OutputFormat, outputSize: CGSize) -> Data? {
+ if outputFormat == .webp {
+ return ParcelWebPEncoder.encode(cgImage)
+ } else if outputFormat.usesImageIO {
let destinationData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
destinationData, outputFormat.contentType.identifier as CFString, 1, nil
) else {
- NSLog("Parcel: failed to create HEIC destination")
- return
+ NSLog("Parcel: failed to create \(outputFormat.label) destination")
+ return nil
}
CGImageDestinationAddImage(destination, cgImage, nil)
- data = CGImageDestinationFinalize(destination) ? destinationData as Data : nil
+ return CGImageDestinationFinalize(destination) ? destinationData as Data : nil
} else if let bitmapType = outputFormat.bitmapType {
let rep = NSBitmapImageRep(cgImage: cgImage)
- rep.size = effectiveSettings.outerSize(for: pointSize)
+ rep.size = outputSize
var properties: [NSBitmapImageRep.PropertyKey: Any] = [:]
if outputFormat == .jpeg { properties[.compressionFactor] = 0.92 }
- data = rep.representation(using: bitmapType, properties: properties)
+ return rep.representation(using: bitmapType, properties: properties)
} else {
- data = nil
- }
- guard let data else {
- NSLog("Parcel: failed to encode \(outputFormat.label)")
- return
- }
- do {
- try data.write(to: url)
- } catch {
- NSLog("Parcel: failed to write Capture — \(error)")
+ return nil
}
}
@@ -751,9 +888,62 @@ final class EditorModel: ObservableObject {
set { UserDefaults.standard.set(newValue?.path, forKey: lastSaveDirectoryKey) }
}
- private static func defaultFileName(format: OutputFormat) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss"
- return "Parcel \(formatter.string(from: Date())).\(format.fileExtension)"
+}
+
+enum HighlighterSnapper {
+ static func snappedPoints(_ points: [CGPoint], to boxes: [CGRect], threshold: CGFloat = 24) -> [CGPoint] {
+ guard !boxes.isEmpty else { return points }
+ return points.map { point in
+ let nearest = boxes.min {
+ hypot($0.midX - point.x, $0.midY - point.y) < hypot($1.midX - point.x, $1.midY - point.y)
+ }
+ guard let box = nearest,
+ hypot(box.midX - point.x, box.midY - point.y) < threshold
+ else { return point }
+ return CGPoint(x: box.midX, y: box.midY)
+ }
+ }
+}
+
+// MARK: - Color swatches
+
+enum ColorSwatchStore {
+ private static let key = "\(AppIdentity.defaultsPrefix).editor.colorSwatches"
+
+ static func load() -> [RGBAColor] {
+ guard let data = UserDefaults.standard.data(forKey: key),
+ let colors = try? JSONDecoder().decode([RGBAColor].self, from: data)
+ else { return [] }
+ return colors
+ }
+
+ static func add(_ color: RGBAColor) {
+ var colors = load()
+ if !colors.contains(color) {
+ colors.insert(color, at: 0)
+ if colors.count > 12 { colors = Array(colors.prefix(12)) }
+ if let data = try? JSONEncoder().encode(colors) {
+ UserDefaults.standard.set(data, forKey: key)
+ }
+ }
+ }
+}
+
+// MARK: - sRGB conversion
+
+enum ImageColorSpace {
+ static func convertToSRGB(_ image: CGImage) -> CGImage? {
+ let srgb = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+ guard let context = CGContext(
+ data: nil,
+ width: image.width,
+ height: image.height,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: srgb,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
+ return context.makeImage()
}
}
diff --git a/Sources/Parcel/Editor/EditorView.swift b/Sources/Parcel/Editor/EditorView.swift
index a45daad..51c2580 100644
--- a/Sources/Parcel/Editor/EditorView.swift
+++ b/Sources/Parcel/Editor/EditorView.swift
@@ -1,3 +1,4 @@
+import AppKit
import SwiftUI
/// The Editor's SwiftUI surface: a two-row Tool/style toolbar over the interactive Canvas.
@@ -66,6 +67,8 @@ struct EditorView: View {
.fill(model.activeTool == tool ? Color.accentColor.opacity(0.22) : .clear)
)
.foregroundStyle(model.activeTool == tool ? Color.accentColor : Color.primary)
+ .accessibilityLabel(tool.label)
+ .accessibilityIdentifier("tool-\(tool.rawValue)")
.help(tool.label)
}
@@ -80,17 +83,28 @@ struct EditorView: View {
Image(systemName: model.displayArrowStyle.symbol)
}
.menuStyle(.borderlessButton)
- .frame(width: 46)
+ .frame(width: 48, height: 28)
+ .accessibilityLabel("Arrow style")
+ .accessibilityValue(model.displayArrowStyle.label)
+ .accessibilityIdentifier("arrow-style-menu")
.help("Arrow style")
}
if showsCensorControls {
Picker("", selection: censorModeBinding) {
- ForEach(CensorMode.allCases) { mode in Image(systemName: mode.symbol).tag(mode) }
+ ForEach(CensorMode.allCases) { mode in
+ Label(mode.label, systemImage: mode.symbol)
+ .labelStyle(.iconOnly)
+ .tag(mode)
+ .accessibilityLabel(mode.label)
+ }
}
.pickerStyle(.segmented)
.labelsHidden()
- .frame(width: 142)
+ .frame(width: 164)
+ .accessibilityLabel("Censor mode")
+ .accessibilityValue(model.displayCensorMode.label)
+ .accessibilityIdentifier("censor-mode-picker")
.help("Censor mode")
}
@@ -150,6 +164,27 @@ struct EditorView: View {
.labelsHidden()
.help("Color")
+ if !model.savedColors.isEmpty {
+ ForEach(Array(model.savedColors.prefix(6).enumerated()), id: \.offset) { _, swatch in
+ Button {
+ model.toolColor = swatch
+ } label: {
+ Circle()
+ .fill(swatch.color)
+ .frame(width: 14, height: 14)
+ .overlay(Circle().strokeBorder(Color.primary.opacity(0.2), lineWidth: 0.5))
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ Button {
+ model.saveCurrentColorSwatch()
+ } label: {
+ Image(systemName: "plus.circle")
+ }
+ .buttonStyle(.borderless)
+ .help("Save color swatch")
+
HStack(spacing: 4) {
Image(systemName: "lineweight").foregroundStyle(.secondary).font(.system(size: 11))
Slider(
@@ -255,6 +290,46 @@ struct EditorView: View {
private var output: some View {
HStack(spacing: 8) {
+ Menu {
+ Button("Crop to Selection Bounds") {
+ if let selected = model.selectedAnnotation {
+ model.cropCapture(toPoints: selected.kind.boundingBox.insetBy(dx: -4, dy: -4))
+ }
+ }
+ .disabled(model.selectedID == nil)
+ Button("Resize to 50%") {
+ let size = model.pointSize
+ model.resizeCapture(toPointSize: CGSize(width: size.width * 0.5, height: size.height * 0.5))
+ }
+ Button("Rotate 90°") { model.rotateCapture90CW() }
+ Button("Flip Horizontal") { model.flipCaptureHorizontal() }
+ Button("Flip Vertical") { model.flipCaptureVertical() }
+ Button("Expand Canvas +40pt") {
+ model.expandCanvas(top: 40, left: 40, bottom: 40, right: 40)
+ }
+ Button("Remove Backdrop") {
+ model.removeOpaqueBackground()
+ }
+ Divider()
+ Button("Combine from Clipboard…") {
+ guard let image = NSImage(pasteboard: NSPasteboard.general),
+ let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
+ else { return }
+ let other = Capture(image: cg, scale: model.capture.scale)
+ let rect = CGRect(
+ x: model.pointSize.width * 0.1,
+ y: model.pointSize.height * 0.1,
+ width: model.pointSize.width * 0.35,
+ height: model.pointSize.height * 0.35
+ )
+ model.combineCapture(other, into: rect)
+ }
+ } label: {
+ Image(systemName: "crop.rotate")
+ }
+ .menuStyle(.borderlessButton)
+ .help("Capture transforms")
+
Button {
model.copyToClipboard()
} label: {
@@ -278,6 +353,10 @@ struct EditorView: View {
Text(model.outputFormat.label)
}
.menuStyle(.borderlessButton)
+ .frame(minWidth: 56)
+ .accessibilityLabel("Output format")
+ .accessibilityValue(model.outputFormat.label)
+ .accessibilityIdentifier("output-format-menu")
.help("Output format")
Button {
@@ -287,6 +366,28 @@ struct EditorView: View {
}
.keyboardShortcut("s", modifiers: .command)
+ Button {
+ model.saveParcelProject()
+ } label: {
+ Image(systemName: "doc.badge.gearshape")
+ }
+ .help("Save editable .parcel project")
+
+ Button {
+ model.printCapture()
+ } label: {
+ Image(systemName: "printer")
+ }
+ .keyboardShortcut("p", modifiers: .command)
+ .help("Print")
+
+ Button {
+ model.shareCapture()
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
+ .help("Share")
+
Button {
model.uploadCapture()
} label: {
diff --git a/Sources/Parcel/Editor/ModifierKeyTracker.swift b/Sources/Parcel/Editor/ModifierKeyTracker.swift
new file mode 100644
index 0000000..1460d2c
--- /dev/null
+++ b/Sources/Parcel/Editor/ModifierKeyTracker.swift
@@ -0,0 +1,63 @@
+import AppKit
+import SwiftUI
+
+/// Tracks Shift and Space key state for Canvas drawing modifiers (constrain / reposition).
+struct ModifierKeyTracker: NSViewRepresentable {
+ var onShiftChange: (Bool) -> Void
+ var onSpaceChange: (Bool) -> Void
+
+ func makeNSView(context: Context) -> ModifierKeyView {
+ let view = ModifierKeyView()
+ view.onShiftChange = onShiftChange
+ view.onSpaceChange = onSpaceChange
+ return view
+ }
+
+ func updateNSView(_ nsView: ModifierKeyView, context: Context) {
+ nsView.onShiftChange = onShiftChange
+ nsView.onSpaceChange = onSpaceChange
+ }
+}
+
+final class ModifierKeyView: NSView {
+ var onShiftChange: ((Bool) -> Void)?
+ var onSpaceChange: ((Bool) -> Void)?
+ private var monitor: Any?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard window != nil, monitor == nil else {
+ if window == nil, let monitor {
+ NSEvent.removeMonitor(monitor)
+ self.monitor = nil
+ }
+ return
+ }
+ monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in
+ self?.handle(event)
+ return event
+ }
+ publishShift(from: NSEvent.modifierFlags)
+ }
+
+ deinit {
+ if let monitor { NSEvent.removeMonitor(monitor) }
+ }
+
+ private func handle(_ event: NSEvent) {
+ switch event.type {
+ case .flagsChanged:
+ publishShift(from: event.modifierFlags)
+ case .keyDown where event.keyCode == 49: // Space
+ onSpaceChange?(true)
+ case .keyUp where event.keyCode == 49:
+ onSpaceChange?(false)
+ default:
+ break
+ }
+ }
+
+ private func publishShift(from flags: NSEvent.ModifierFlags) {
+ onShiftChange?(flags.intersection(.deviceIndependentFlagsMask).contains(.shift))
+ }
+}
diff --git a/Sources/Parcel/Editor/OutputFormat.swift b/Sources/Parcel/Editor/OutputFormat.swift
index e56c74f..8884478 100644
--- a/Sources/Parcel/Editor/OutputFormat.swift
+++ b/Sources/Parcel/Editor/OutputFormat.swift
@@ -1,27 +1,41 @@
import AppKit
import UniformTypeIdentifiers
-/// Native image formats macOS can encode without bundling a third-party codec.
+/// Image formats Parcel can export. WebP uses bundled libwebp because ImageIO
+/// does not expose a WebP destination on every supported macOS version.
enum OutputFormat: String, CaseIterable, Identifiable, Codable {
- case png, jpeg, heic, tiff
+ case png, jpeg, heic, tiff, webp
var id: String { rawValue }
var label: String { rawValue.uppercased() }
- var fileExtension: String { rawValue == "jpeg" ? "jpg" : rawValue }
+ var fileExtension: String {
+ switch self {
+ case .jpeg: return "jpg"
+ default: return rawValue
+ }
+ }
+
var contentType: UTType {
switch self {
case .png: return .png
case .jpeg: return .jpeg
case .heic: return .heic
case .tiff: return .tiff
+ case .webp: return UTType(filenameExtension: "webp") ?? .data
}
}
+
+ /// HEIC goes through ImageIO; WebP is handled by ParcelWebPEncoder.
+ var usesImageIO: Bool {
+ self == .heic
+ }
+
var bitmapType: NSBitmapImageRep.FileType? {
switch self {
case .png: return .png
case .jpeg: return .jpeg
- case .heic: return nil
case .tiff: return .tiff
+ case .heic, .webp: return nil
}
}
}
diff --git a/Sources/Parcel/Editor/ParcelProjectIO.swift b/Sources/Parcel/Editor/ParcelProjectIO.swift
new file mode 100644
index 0000000..59c58ee
--- /dev/null
+++ b/Sources/Parcel/Editor/ParcelProjectIO.swift
@@ -0,0 +1,60 @@
+import AppKit
+import Foundation
+import UniformTypeIdentifiers
+
+/// Saves / opens editable `.parcel` project bundles (Capture PNG + document JSON).
+enum ParcelProjectIO {
+ @MainActor
+ static func save(model: EditorModel, to url: URL) {
+ let fileManager = FileManager.default
+ let temp = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ do {
+ try fileManager.createDirectory(at: temp, withIntermediateDirectories: true)
+ let captureURL = temp.appendingPathComponent("capture.png")
+ let rep = NSBitmapImageRep(cgImage: model.capture.image)
+ guard let data = rep.representation(using: .png, properties: [:]) else { return }
+ try data.write(to: captureURL, options: .atomic)
+
+ let document = model.historyDocument(
+ id: UUID(),
+ createdAt: Date(),
+ captureFileName: "capture.png"
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ try encoder.encode(document).write(
+ to: temp.appendingPathComponent("document.json"),
+ options: .atomic
+ )
+
+ if fileManager.fileExists(atPath: url.path) {
+ try fileManager.removeItem(at: url)
+ }
+ try fileManager.copyItem(at: temp, to: url)
+ try? fileManager.removeItem(at: temp)
+ } catch {
+ NSLog("Parcel: failed to write .parcel project — \(error)")
+ }
+ }
+
+ @MainActor
+ static func open(from url: URL) -> RestoredCaptureDocument? {
+ let documentURL = url.appendingPathComponent("document.json")
+ let captureURL = url.appendingPathComponent("capture.png")
+ do {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let document = try decoder.decode(CaptureDocument.self, from: Data(contentsOf: documentURL))
+ let data = try Data(contentsOf: captureURL)
+ guard let rep = NSBitmapImageRep(data: data), let image = rep.cgImage else { return nil }
+ return RestoredCaptureDocument(
+ capture: Capture(image: image, scale: document.captureScale),
+ document: document
+ )
+ } catch {
+ NSLog("Parcel: failed to open .parcel project — \(error)")
+ return nil
+ }
+ }
+}
diff --git a/Sources/Parcel/Editor/WebPEncoder.swift b/Sources/Parcel/Editor/WebPEncoder.swift
new file mode 100644
index 0000000..c4c87ce
--- /dev/null
+++ b/Sources/Parcel/Editor/WebPEncoder.swift
@@ -0,0 +1,43 @@
+import CoreGraphics
+import Foundation
+import WebP
+
+enum ParcelWebPEncoder {
+ static func encode(_ image: CGImage, quality: Float = 92) -> Data? {
+ guard let rgba = rgbaBytes(from: image) else { return nil }
+ do {
+ let encoded = try WebP(width: rgba.width, height: rgba.height, rgba: [UInt8](rgba.data))
+ .encode(quality: quality)
+ return Data(encoded)
+ } catch {
+ NSLog("Parcel: failed to encode WEBP — \(error)")
+ return nil
+ }
+ }
+
+ private static func rgbaBytes(from image: CGImage) -> (data: Data, width: Int, height: Int, bytesPerRow: Int)? {
+ let width = image.width
+ let height = image.height
+ let bytesPerRow = width * 4
+ var data = Data(count: bytesPerRow * height)
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
+
+ let rendered = data.withUnsafeMutableBytes { buffer -> Bool in
+ guard let base = buffer.baseAddress,
+ let context = CGContext(
+ data: base,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: colorSpace,
+ bitmapInfo: bitmapInfo
+ )
+ else { return false }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return true
+ }
+ return rendered ? (data, width, height, bytesPerRow) : nil
+ }
+}
diff --git a/Sources/Parcel/History/HistoryStore.swift b/Sources/Parcel/History/HistoryStore.swift
index 77329a0..e60e142 100644
--- a/Sources/Parcel/History/HistoryStore.swift
+++ b/Sources/Parcel/History/HistoryStore.swift
@@ -73,6 +73,17 @@ struct HistoryEntry: Codable, Identifiable, Equatable {
Self.displayFormatter.string(from: updatedAt)
}
+ func matchesFilter(_ filter: String) -> Bool {
+ let query = filter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !query.isEmpty else { return true }
+ let compactDimensions = "\(pixelWidth)x\(pixelHeight)"
+ let displayDimensions = "\(pixelWidth) × \(pixelHeight)"
+ return captureFileName.lowercased().contains(query)
+ || displayDate.lowercased().contains(query)
+ || compactDimensions.contains(query)
+ || displayDimensions.contains(query)
+ }
+
private static let displayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
@@ -81,6 +92,40 @@ struct HistoryEntry: Codable, Identifiable, Equatable {
}()
}
+enum HistoryRetention: String, CaseIterable, Identifiable {
+ case day, week, month, forever
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .day: return "1 day"
+ case .week: return "1 week"
+ case .month: return "1 month"
+ case .forever: return "Forever"
+ }
+ }
+
+ var maxAge: TimeInterval? {
+ switch self {
+ case .day: return 24 * 60 * 60
+ case .week: return 7 * 24 * 60 * 60
+ case .month: return 30 * 24 * 60 * 60
+ case .forever: return nil
+ }
+ }
+
+ private static let key = "\(AppIdentity.defaultsPrefix).history.retention"
+
+ static var current: HistoryRetention {
+ get {
+ let raw = UserDefaults.standard.string(forKey: key) ?? ""
+ return HistoryRetention(rawValue: raw) ?? .forever
+ }
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
+ }
+}
+
struct RestoredCaptureDocument {
let capture: Capture
let document: CaptureDocument
@@ -97,15 +142,29 @@ final class HistoryStore: ObservableObject {
private let rootURL: URL
private let indexURL: URL
- init(fileManager: FileManager = .default) {
+ init(fileManager: FileManager = .default, rootURL overrideRootURL: URL? = nil) {
self.fileManager = fileManager
- let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
- rootURL = appSupport
- .appendingPathComponent(AppIdentity.appSupportComponent, isDirectory: true)
- .appendingPathComponent("History", isDirectory: true)
+ if let overrideRootURL {
+ rootURL = overrideRootURL
+ } else {
+ let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ rootURL = appSupport
+ .appendingPathComponent(AppIdentity.appSupportComponent, isDirectory: true)
+ .appendingPathComponent("History", isDirectory: true)
+ }
indexURL = rootURL.appendingPathComponent("index.json")
createRootIfNeeded()
loadIndex()
+ pruneExpiredEntries()
+ }
+
+ func pruneExpiredEntries() {
+ guard let maxAge = HistoryRetention.current.maxAge else { return }
+ let cutoff = Date().addingTimeInterval(-maxAge)
+ let expired = entries.filter { $0.updatedAt < cutoff }
+ for entry in expired {
+ remove(entry.id)
+ }
}
@discardableResult
@@ -271,10 +330,16 @@ final class BrandKitStore: ObservableObject {
static let shared = BrandKitStore()
@Published private(set) var kits: [BrandKit] = []
- private let defaultsKey = "\(AppIdentity.defaultsPrefix).brandKits"
+ private let userDefaults: UserDefaults
+ private let defaultsKey: String
- private init() {
- guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return }
+ init(
+ userDefaults: UserDefaults = .standard,
+ defaultsKey: String = "\(AppIdentity.defaultsPrefix).brandKits"
+ ) {
+ self.userDefaults = userDefaults
+ self.defaultsKey = defaultsKey
+ guard let data = userDefaults.data(forKey: defaultsKey) else { return }
kits = (try? JSONDecoder().decode([BrandKit].self, from: data)) ?? []
}
@@ -291,6 +356,6 @@ final class BrandKitStore: ObservableObject {
}
private func persist() {
- UserDefaults.standard.set(try? JSONEncoder().encode(kits), forKey: defaultsKey)
+ userDefaults.set(try? JSONEncoder().encode(kits), forKey: defaultsKey)
}
}
diff --git a/Sources/Parcel/History/HistoryWindowController.swift b/Sources/Parcel/History/HistoryWindowController.swift
index 13a9715..08a7bc6 100644
--- a/Sources/Parcel/History/HistoryWindowController.swift
+++ b/Sources/Parcel/History/HistoryWindowController.swift
@@ -5,9 +5,13 @@ import SwiftUI
final class HistoryWindowController: NSObject, NSWindowDelegate {
private let window: NSWindow
- init(store: HistoryStore, onOpen: @escaping (UUID) -> Void) {
+ init(
+ store: HistoryStore,
+ onOpen: @escaping (UUID) -> Void,
+ onPin: @escaping (UUID) -> Void = { _ in }
+ ) {
window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 680, height: 480),
+ contentRect: NSRect(x: 0, y: 0, width: 720, height: 520),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
@@ -17,7 +21,9 @@ final class HistoryWindowController: NSObject, NSWindowDelegate {
window.minSize = NSSize(width: 520, height: 320)
window.tabbingMode = .disallowed
window.isReleasedWhenClosed = false
- window.contentView = NSHostingView(rootView: HistoryView(store: store, onOpen: onOpen))
+ window.contentView = NSHostingView(
+ rootView: HistoryView(store: store, onOpen: onOpen, onPin: onPin)
+ )
}
func show() {
@@ -29,31 +35,45 @@ final class HistoryWindowController: NSObject, NSWindowDelegate {
private struct HistoryView: View {
@ObservedObject var store: HistoryStore
let onOpen: (UUID) -> Void
+ let onPin: (UUID) -> Void
+ @State private var filter = ""
+
+ private var filtered: [HistoryEntry] {
+ store.entries.filter { $0.matchesFilter(filter) }
+ }
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Capture History").font(.title3.weight(.semibold))
Spacer()
+ TextField("Filter", text: $filter)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 220)
if !store.entries.isEmpty {
Button("Clear All", role: .destructive) { store.removeAll() }
}
}
.padding(16)
- if store.entries.isEmpty {
+ if filtered.isEmpty {
VStack(spacing: 10) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 32))
.foregroundStyle(.secondary)
- Text("No Captures Yet").font(.headline)
- Text("New Captures are kept here for local re-editing.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ Text(store.entries.isEmpty ? "No Captures Yet" : "No Matches")
+ .font(.headline)
+ Text(
+ store.entries.isEmpty
+ ? "New Captures are kept here for local re-editing."
+ : "Try a different filter."
+ )
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
- List(store.entries) { entry in
+ List(filtered) { entry in
HStack(spacing: 12) {
if let preview = store.preview(for: entry) {
Image(nsImage: preview)
@@ -71,6 +91,7 @@ private struct HistoryView: View {
}
Spacer()
Button("Open") { onOpen(entry.id) }
+ Button("Pin") { onPin(entry.id) }
Button(role: .destructive) { store.remove(entry.id) } label: {
Image(systemName: "trash")
}
@@ -78,9 +99,18 @@ private struct HistoryView: View {
.help("Remove from local history")
}
.padding(.vertical, 4)
+ .contentShape(Rectangle())
+ .onTapGesture(count: 2) { onOpen(entry.id) }
+ .contextMenu {
+ Button("Open in Editor") { onOpen(entry.id) }
+ Button("Pin to Screen") { onPin(entry.id) }
+ Divider()
+ Button("Delete", role: .destructive) { store.remove(entry.id) }
+ }
}
.listStyle(.inset(alternatesRowBackgrounds: true))
}
}
+ .onAppear { store.pruneExpiredEntries() }
}
}
diff --git a/Sources/Parcel/Hotkeys/HotKeyManager.swift b/Sources/Parcel/Hotkeys/HotKeyManager.swift
index da0ff77..9c989a8 100644
--- a/Sources/Parcel/Hotkeys/HotKeyManager.swift
+++ b/Sources/Parcel/Hotkeys/HotKeyManager.swift
@@ -1,44 +1,57 @@
import AppKit
import Carbon.HIToolbox
-/// Registers a single global hotkey via the Carbon Hot Key API.
+/// Registers multiple global hotkeys via the Carbon Hot Key API.
///
-/// `RegisterEventHotKey` is the mechanism used by KeyboardShortcuts/HotKey. Unlike a `CGEventTap`
-/// it requires **no** Accessibility/Input Monitoring permission for a plain modifier+key combo,
-/// which removes a whole class of first-run permission dead-ends. The Carbon callback is a C
-/// function pointer that can't capture context, so we thread `self` through `userData`.
+/// `RegisterEventHotKey` needs **no** Accessibility permission for plain modifier+key combos.
final class HotKeyManager {
- /// Fires on the main queue when the hotkey is pressed.
- var onHotKey: (() -> Void)?
+ enum Action: UInt32 {
+ case captureRegion = 1
+ case captureCopy = 2
+ case captureAnnotate = 3
+ case capturePin = 4
+ case captureSave = 5
+ case capturePrevious = 6
+ case openClipboard = 7
+ case restoreClosed = 8
+ case hideOverlays = 9
+ case annotateLast = 10
+ case ocr = 11
+ }
- private var hotKeyRef: EventHotKeyRef?
- private var handlerRef: EventHandlerRef?
- private let signature: OSType = 0x4E4F5442 // 'NOTB'
+ /// Fires on the main queue when a registered hotkey is pressed.
+ var onAction: ((Action) -> Void)?
- /// Default capture shortcut: ⌘⇧2 (⌘⇧3/4/5 belong to the macOS screenshot service).
- func registerDefault() {
- register(keyCode: HotKeyPreferences.keyCode, modifiers: HotKeyPreferences.modifiers)
+ /// Legacy single-callback used when only the primary Capture hotkey matters.
+ var onHotKey: (() -> Void)? {
+ didSet {
+ // Keep backward compatibility: primary action also invokes onHotKey.
+ }
}
- func register(keyCode: UInt32, modifiers: UInt32) {
+ private var hotKeyRefs: [Action: EventHotKeyRef] = [:]
+ private var handlerRef: EventHandlerRef?
+ private let signature: OSType = 0x5052434C // 'PRCL'
+
+ func registerDefault() {
installHandlerIfNeeded()
- unregisterHotKey()
+ unregisterHotKeys()
- var ref: EventHotKeyRef?
- let hotKeyID = EventHotKeyID(signature: signature, id: 1)
- let status = RegisterEventHotKey(
- keyCode, modifiers, hotKeyID, GetEventDispatcherTarget(), 0, &ref
+ register(
+ action: .captureRegion,
+ keyCode: HotKeyPreferences.keyCode,
+ modifiers: HotKeyPreferences.modifiers
)
- if status == noErr {
- hotKeyRef = ref
- } else {
- NSLog("Parcel: RegisterEventHotKey failed (status \(status))")
+
+ for binding in HotKeyPreferences.extraBindings {
+ guard binding.isEnabled else { continue }
+ register(action: binding.action, keyCode: binding.keyCode, modifiers: binding.modifiers)
}
}
func unregisterAll() {
- unregisterHotKey()
+ unregisterHotKeys()
if let handlerRef {
RemoveEventHandler(handlerRef)
self.handlerRef = nil
@@ -47,6 +60,19 @@ final class HotKeyManager {
// MARK: Private
+ private func register(action: Action, keyCode: UInt32, modifiers: UInt32) {
+ var ref: EventHotKeyRef?
+ let hotKeyID = EventHotKeyID(signature: signature, id: action.rawValue)
+ let status = RegisterEventHotKey(
+ keyCode, modifiers, hotKeyID, GetEventDispatcherTarget(), 0, &ref
+ )
+ if status == noErr, let ref {
+ hotKeyRefs[action] = ref
+ } else {
+ NSLog("Parcel: RegisterEventHotKey failed for \(action) (status \(status))")
+ }
+ }
+
private func installHandlerIfNeeded() {
guard handlerRef == nil else { return }
var eventType = EventTypeSpec(
@@ -56,20 +82,36 @@ final class HotKeyManager {
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
InstallEventHandler(
GetEventDispatcherTarget(),
- { _, _, userData -> OSStatus in
- guard let userData else { return noErr }
+ { _, event, userData -> OSStatus in
+ guard let userData, let event else { return noErr }
+ var hotKeyID = EventHotKeyID()
+ GetEventParameter(
+ event,
+ EventParamName(kEventParamDirectObject),
+ EventParamType(typeEventHotKeyID),
+ nil,
+ MemoryLayout.size,
+ nil,
+ &hotKeyID
+ )
let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue()
- DispatchQueue.main.async { manager.onHotKey?() }
+ let action = Action(rawValue: hotKeyID.id) ?? .captureRegion
+ DispatchQueue.main.async {
+ manager.onAction?(action)
+ if action == .captureRegion {
+ manager.onHotKey?()
+ }
+ }
return noErr
},
1, &eventType, selfPtr, &handlerRef
)
}
- private func unregisterHotKey() {
- if let hotKeyRef {
- UnregisterEventHotKey(hotKeyRef)
- self.hotKeyRef = nil
+ private func unregisterHotKeys() {
+ for (_, ref) in hotKeyRefs {
+ UnregisterEventHotKey(ref)
}
+ hotKeyRefs.removeAll()
}
}
diff --git a/Sources/Parcel/Hotkeys/HotKeyPreferences.swift b/Sources/Parcel/Hotkeys/HotKeyPreferences.swift
index 4421f38..89e3135 100644
--- a/Sources/Parcel/Hotkeys/HotKeyPreferences.swift
+++ b/Sources/Parcel/Hotkeys/HotKeyPreferences.swift
@@ -1,11 +1,18 @@
import Carbon.HIToolbox
import Foundation
-/// Persisted global Capture hotkey. Defaults to ⌘⇧2.
+/// Persisted global Capture hotkeys. Primary default: ⌘⇧2.
enum HotKeyPreferences {
private static let keyCodeKey = "\(AppIdentity.defaultsPrefix).hotkey.keyCode"
private static let modifiersKey = "\(AppIdentity.defaultsPrefix).hotkey.modifiers"
+ struct Binding {
+ let action: HotKeyManager.Action
+ let keyCode: UInt32
+ let modifiers: UInt32
+ let isEnabled: Bool
+ }
+
static var keyCode: UInt32 {
get {
let stored = UserDefaults.standard.integer(forKey: keyCodeKey)
@@ -28,10 +35,100 @@ enum HotKeyPreferences {
HotKeyDisplay.string(keyCode: keyCode, modifiers: modifiers)
}
+ /// Extra Capture Area & … shortcuts registered alongside the primary Capture shortcut.
+ static var extraBindings: [Binding] {
+ [
+ Binding(
+ action: .captureCopy,
+ keyCode: UInt32(stored("copy.keyCode", default: kVK_ANSI_C)),
+ modifiers: UInt32(stored("copy.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("copy.enabled", default: true)
+ ),
+ Binding(
+ action: .captureAnnotate,
+ keyCode: UInt32(stored("annotate.keyCode", default: kVK_ANSI_A)),
+ modifiers: UInt32(stored("annotate.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("annotate.enabled", default: true)
+ ),
+ Binding(
+ action: .capturePin,
+ keyCode: UInt32(stored("pin.keyCode", default: kVK_ANSI_P)),
+ modifiers: UInt32(stored("pin.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("pin.enabled", default: true)
+ ),
+ Binding(
+ action: .captureSave,
+ keyCode: UInt32(stored("save.keyCode", default: kVK_ANSI_S)),
+ modifiers: UInt32(stored("save.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("save.enabled", default: false)
+ ),
+ Binding(
+ action: .capturePrevious,
+ keyCode: UInt32(stored("previous.keyCode", default: kVK_ANSI_5)),
+ modifiers: UInt32(stored("previous.modifiers", default: Int(cmdKey | shiftKey))),
+ isEnabled: bool("previous.enabled", default: true)
+ ),
+ Binding(
+ action: .openClipboard,
+ keyCode: UInt32(stored("clipboard.keyCode", default: kVK_ANSI_V)),
+ modifiers: UInt32(stored("clipboard.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("clipboard.enabled", default: false)
+ ),
+ Binding(
+ action: .restoreClosed,
+ keyCode: UInt32(stored("restore.keyCode", default: kVK_ANSI_Z)),
+ modifiers: UInt32(stored("restore.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("restore.enabled", default: true)
+ ),
+ Binding(
+ action: .hideOverlays,
+ keyCode: UInt32(stored("hide.keyCode", default: kVK_ANSI_H)),
+ modifiers: UInt32(stored("hide.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("hide.enabled", default: true)
+ ),
+ Binding(
+ action: .annotateLast,
+ keyCode: UInt32(stored("last.keyCode", default: kVK_ANSI_E)),
+ modifiers: UInt32(stored("last.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("last.enabled", default: false)
+ ),
+ Binding(
+ action: .ocr,
+ keyCode: UInt32(stored("ocr.keyCode", default: kVK_ANSI_T)),
+ modifiers: UInt32(stored("ocr.modifiers", default: Int(cmdKey | shiftKey | optionKey))),
+ isEnabled: bool("ocr.enabled", default: false)
+ ),
+ ]
+ }
+
static func resetToDefault() {
keyCode = UInt32(kVK_ANSI_2)
modifiers = UInt32(cmdKey | shiftKey)
}
+
+ /// Shortcuts that would steal macOS window / app chrome if rebound as Capture.
+ static func isReservedSystemShortcut(keyCode: UInt32, modifiers: UInt32) -> Bool {
+ let mods = modifiers & UInt32(cmdKey | shiftKey | optionKey | controlKey)
+ guard mods == UInt32(cmdKey) else { return false }
+ switch Int(keyCode) {
+ case kVK_ANSI_W, kVK_ANSI_Q, kVK_ANSI_H, kVK_ANSI_M, kVK_ANSI_Comma:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private static func stored(_ suffix: String, default defaultValue: Int) -> Int {
+ let key = "\(AppIdentity.defaultsPrefix).hotkey.\(suffix)"
+ if UserDefaults.standard.object(forKey: key) == nil { return defaultValue }
+ return UserDefaults.standard.integer(forKey: key)
+ }
+
+ private static func bool(_ suffix: String, default defaultValue: Bool) -> Bool {
+ let key = "\(AppIdentity.defaultsPrefix).hotkey.\(suffix)"
+ if UserDefaults.standard.object(forKey: key) == nil { return defaultValue }
+ return UserDefaults.standard.bool(forKey: key)
+ }
}
/// Human-readable shortcut labels for Preferences and the marketing site.
diff --git a/Sources/Parcel/MenuBar/MenuBarContent.swift b/Sources/Parcel/MenuBar/MenuBarContent.swift
index c6ad117..0441e3a 100644
--- a/Sources/Parcel/MenuBar/MenuBarContent.swift
+++ b/Sources/Parcel/MenuBar/MenuBarContent.swift
@@ -11,10 +11,28 @@ struct MenuBarContent: View {
}
.keyboardShortcut("2", modifiers: [.command, .shift])
+ Button("Capture Previous Area") {
+ coordinator.beginPreviousAreaCapture()
+ }
+ .keyboardShortcut("5", modifiers: [.command, .shift])
+ .disabled(!CapturePreferences.hasPreviousArea)
+
+ Button("Capture Window") {
+ coordinator.beginWindowCapture()
+ }
+
+ Button("Capture Display") {
+ coordinator.beginFullscreenCapture()
+ }
+
Button("Capture All Displays") {
coordinator.beginAllDisplaysCapture()
}
+ Button("Copy Text (OCR)") {
+ coordinator.beginOCRCapture()
+ }
+
Menu("Capture with Delay") {
Button("3 Seconds") { coordinator.beginRegionCapture(after: 3) }
Button("5 Seconds") { coordinator.beginRegionCapture(after: 5) }
@@ -23,7 +41,21 @@ struct MenuBarContent: View {
.disabled(coordinator.isCapturing && coordinator.captureDelayRemaining == nil)
if let remaining = coordinator.captureDelayRemaining {
- Text("Capturing in \(Int(ceil(remaining)))s…")
+ Text(CountdownDisplay.captureLabel(remaining: remaining))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .disabled(true)
+ }
+
+ if let remaining = coordinator.recordingCountdownRemaining {
+ Text(CountdownDisplay.recordingLabel(remaining: remaining))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .disabled(true)
+ }
+
+ if let banner = coordinator.statusBanner {
+ Text(banner)
.font(.caption)
.foregroundStyle(.secondary)
.disabled(true)
@@ -55,12 +87,33 @@ struct MenuBarContent: View {
coordinator.toggleRecording()
} label: {
Label(
- coordinator.isRecording ? "Stop Recording" : "Record Display…",
+ coordinator.isRecording
+ ? (coordinator.isPausedRecording ? "Resume / Stop Recording" : "Stop Recording")
+ : "Record Region…",
systemImage: coordinator.isRecording ? "stop.fill" : "record.circle"
)
}
.disabled(coordinator.isStartingRecording || coordinator.isCapturing)
+ Button("Record Previous Area") {
+ coordinator.beginPreviousRecordingArea()
+ }
+ .disabled(
+ coordinator.isStartingRecording
+ || coordinator.isCapturing
+ || coordinator.isRecording
+ || !CapturePreferences.hasPreviousRecordingArea
+ )
+
+ if coordinator.isRecording {
+ Button(coordinator.isPausedRecording ? "Resume Recording" : "Pause Recording") {
+ coordinator.pauseOrResumeRecording()
+ }
+ Button("Restart Recording") {
+ coordinator.restartRecording()
+ }
+ }
+
Menu("Recording FPS") {
ForEach(RecordingFPS.allCases) { fps in
Button {
@@ -77,6 +130,28 @@ struct MenuBarContent: View {
Divider()
+ Button("Open from Clipboard") {
+ coordinator.openFromClipboard()
+ }
+
+ Button("Restore Recently Closed") {
+ coordinator.restoreRecentlyClosed()
+ }
+
+ Button(coordinator.pinsHidden ? "Show Overlays" : "Hide Overlays") {
+ coordinator.hideAllOverlays()
+ }
+
+ Button("Close All Pins", role: .destructive) {
+ coordinator.closeAllPins()
+ }
+
+ Button("Annotate Last Capture") {
+ coordinator.annotateLastCapture()
+ }
+
+ Divider()
+
Button("Preferences…") {
coordinator.openPreferences()
}
diff --git a/Sources/Parcel/Onboarding/WelcomeWindowController.swift b/Sources/Parcel/Onboarding/WelcomeWindowController.swift
index b762e77..8b613a3 100644
--- a/Sources/Parcel/Onboarding/WelcomeWindowController.swift
+++ b/Sources/Parcel/Onboarding/WelcomeWindowController.swift
@@ -17,8 +17,8 @@ final class WelcomeWindowController {
if window == nil {
let view = WelcomeView(
onGrantPermission: {
- ScreenRecordingPermission.request()
ScreenRecordingPermission.openSystemSettings()
+ ScreenRecordingPermission.revealAppInFinder()
},
onOpenPreferences: { [weak self] in
self?.onOpenPreferences()
@@ -93,7 +93,7 @@ struct WelcomeView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onReceive(poll) { _ in
Task { @MainActor in
- hasPermission = await ScreenRecordingPermission.hasEffectiveAccess()
+ hasPermission = ScreenRecordingPermission.isGranted
}
}
}
diff --git a/Sources/Parcel/Permissions/ScreenRecordingPermission.swift b/Sources/Parcel/Permissions/ScreenRecordingPermission.swift
index 6a00e2d..73e6830 100644
--- a/Sources/Parcel/Permissions/ScreenRecordingPermission.swift
+++ b/Sources/Parcel/Permissions/ScreenRecordingPermission.swift
@@ -4,32 +4,25 @@ import ScreenCaptureKit
/// Thin wrapper over the Screen Recording TCC permission that ScreenCaptureKit requires.
///
-/// macOS quirk: the very first `CGRequestScreenCaptureAccess()` adds the app to the Screen
-/// Recording list and shows the system prompt, but the running process usually keeps reading
-/// `false` until it is relaunched. On recent macOS releases it can also lag behind the actual
-/// ScreenCaptureKit grant for an ad-hoc dev build, so Capture startup treats this as advisory.
+/// Important: `CGRequestScreenCaptureAccess()` shows a system dialog that is useless when Parcel
+/// is already listed in System Settings with the toggle ON but the **running binary's code
+/// signature** no longer matches that grant (ad-hoc rebuilds, sandbox on/off, multiple .app
+/// copies). Capture must **not** call that API on every attempt — try ScreenCaptureKit first,
+/// then guide the user to remove/re-add the app in Settings.
enum ScreenRecordingPermission {
static var isGranted: Bool {
CGPreflightScreenCaptureAccess()
}
- /// Trigger the system prompt / add the app to the list. Returns the (often still-false on
- /// first run) status immediately afterward.
+ /// Explicit user action only (Preferences “Grant…”). Do not call from the Capture path.
@discardableResult
static func request() -> Bool {
CGRequestScreenCaptureAccess()
return CGPreflightScreenCaptureAccess()
}
- /// Requests access when the preflight probe says it is missing, but intentionally does not
- /// require the immediate result to be true. The capture attempt itself is the source of truth.
- static func requestIfNeeded() {
- if !isGranted { CGRequestScreenCaptureAccess() }
- }
-
- /// Uses the same framework as Capture/recording to verify the grant. This is intentionally
- /// separate from `isGranted` because TCC preflight can be stale for local dev builds.
+ /// Lists shareable displays via ScreenCaptureKit.
static func canUseCaptureAPI() async -> Bool {
do {
let content = try await SCShareableContent.excludingDesktopWindows(
@@ -37,22 +30,60 @@ enum ScreenRecordingPermission {
)
return !content.displays.isEmpty
} catch {
+ NSLog("Parcel: SCShareableContent probe failed — \(error)")
return false
}
}
- /// Best-effort permission probe for UI and preflight checks. Preflight is fast but can lag
- /// behind the actual ScreenCaptureKit grant; the capture API probe is the fallback.
+ /// Real capture probe — source of truth for whether this binary can Capture.
+ static func canCaptureDisplay() async -> Bool {
+ do {
+ let content = try await SCShareableContent.excludingDesktopWindows(
+ false, onScreenWindowsOnly: true
+ )
+ guard let display = content.displays.first else {
+ NSLog("Parcel: capture probe — no displays in shareable content")
+ return false
+ }
+ if #available(macOS 14.0, *) {
+ let filter = SCContentFilter(display: display, excludingWindows: [])
+ let config = SCStreamConfiguration()
+ config.width = 2
+ config.height = 2
+ config.showsCursor = false
+ config.scalesToFit = false
+ _ = try await SCScreenshotManager.captureImage(
+ contentFilter: filter, configuration: config
+ )
+ }
+ return true
+ } catch {
+ NSLog("Parcel: capture probe failed — \(error)")
+ return false
+ }
+ }
+
+ /// True only when this process can actually capture pixels.
+ /// Prefer `isGranted` for UI polling — calling ScreenCaptureKit while denied shows the
+ /// system permission sheet on recent macOS.
static func hasEffectiveAccess() async -> Bool {
- if isGranted { return true }
- return await canUseCaptureAPI()
+ if isGranted { return await canCaptureDisplay() }
+ return false
+ }
+
+ /// Preflight false while Settings may still show Parcel ON → signature mismatch.
+ static var likelyNeedsRebuildRegrant: Bool {
+ !isGranted
}
- /// Deep-link to the Screen Recording pane in System Settings.
static func openSystemSettings() {
let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
+
+ static func revealAppInFinder() {
+ NSWorkspace.shared.activateFileViewerSelecting([Bundle.main.bundleURL])
+ }
}
diff --git a/Sources/Parcel/Preferences/HotKeyRecorder.swift b/Sources/Parcel/Preferences/HotKeyRecorder.swift
index 9560ab5..f064388 100644
--- a/Sources/Parcel/Preferences/HotKeyRecorder.swift
+++ b/Sources/Parcel/Preferences/HotKeyRecorder.swift
@@ -42,7 +42,13 @@ struct HotKeyRecorder: View {
if flags.contains(.option) { carbonMods |= UInt32(optionKey) }
if flags.contains(.shift) { carbonMods |= UInt32(shiftKey) }
if flags.contains(.command) { carbonMods |= UInt32(cmdKey) }
- keyCode = UInt32(event.keyCode)
+ let code = UInt32(event.keyCode)
+ // Reject bare ⌘W / ⌘Q / ⌘, — they collide with Close / Quit / Preferences.
+ if HotKeyPreferences.isReservedSystemShortcut(keyCode: code, modifiers: carbonMods) {
+ NSSound.beep()
+ return nil
+ }
+ keyCode = code
modifiers = carbonMods
HotKeyPreferences.keyCode = keyCode
HotKeyPreferences.modifiers = modifiers
diff --git a/Sources/Parcel/Preferences/PreferencesView.swift b/Sources/Parcel/Preferences/PreferencesView.swift
index c1f7cc0..e06e462 100644
--- a/Sources/Parcel/Preferences/PreferencesView.swift
+++ b/Sources/Parcel/Preferences/PreferencesView.swift
@@ -1,6 +1,6 @@
import SwiftUI
-/// Preferences: permissions, configurable Capture hotkey, and Supabase upload settings.
+/// Preferences: permissions, Capture Overlay, recording, hotkey, and Supabase upload.
struct PreferencesView: View {
@State private var hasScreenPermission = ScreenRecordingPermission.isGranted
@State private var keyCode = HotKeyPreferences.keyCode
@@ -10,17 +10,51 @@ struct PreferencesView: View {
@State private var bucketName = UploadPreferences.bucketName
@State private var publicBaseURL = UploadPreferences.publicBaseURL
+ @State private var useQuickAccess = CapturePreferences.useQuickAccess
+ @State private var quickAccessAutoClose = CapturePreferences.quickAccessAutoCloseSeconds
+ @State private var askForName = CapturePreferences.askForName
+ @State private var afterCopy = CapturePreferences.afterCaptureCopy
+ @State private var afterEditor = CapturePreferences.afterCaptureOpenEditor
+ @State private var afterPin = CapturePreferences.afterCapturePin
+ @State private var afterUpload = CapturePreferences.afterCaptureUpload
+ @State private var afterSave = CapturePreferences.afterCaptureSave
+ @State private var playShutter = CapturePreferences.playShutterSound
+ @State private var convertSRGB = CapturePreferences.convertToSRGB
+ @State private var urlSchemeEnabled = CapturePreferences.urlSchemeEnabled
+ @State private var fileNameTemplate = CapturePreferences.fileNameTemplate
+ @State private var scaleDownRetina = CapturePreferences.scaleDownRetina
+ @State private var showCrosshair = CapturePreferences.showCrosshair
+ @State private var showMagnifier = CapturePreferences.showMagnifier
+ @State private var hideDesktopIcons = CapturePreferences.hideDesktopIcons
+ @State private var showAllInOneBar = CapturePreferences.showAllInOneBar
+ @State private var ocrStripLineBreaks = CapturePreferences.ocrStripLineBreaks
+
+ @State private var showsCursor = RecordingPreferences.showsCursor
+ @State private var capturesMicrophone = RecordingPreferences.capturesMicrophone
+ @State private var showsMouseClicks = RecordingPreferences.showsMouseClicks
+ @State private var showsKeystrokes = RecordingPreferences.showsKeystrokes
+ @State private var keystrokesCommandOnly = RecordingPreferences.keystrokesCommandOnly
+ @State private var showsWebcam = RecordingPreferences.showsWebcam
+ @State private var enableDND = RecordingPreferences.enableDoNotDisturb
+ @State private var countdown = RecordingPreferences.countdownSeconds
+ @State private var monoAudio = RecordingPreferences.recordMonoAudio
+ @State private var recordingFPS = RecordingFPS.current
+ @State private var maxResolution = RecordingMaxResolution.current
+ @State private var hudPosition = RecordingHUDPosition.current
+ @State private var historyRetention = HistoryRetention.current
+
private let poll = Timer.publish(every: 1.5, on: .main, in: .common).autoconnect()
var body: some View {
Form {
Section("Permissions") {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
Text("Screen Recording")
- Text("Required to capture your screen. After granting, quit and reopen Parcel.")
+ Text(permissionFootnote)
.font(.footnote)
.foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
}
Spacer()
if hasScreenPermission {
@@ -28,9 +62,21 @@ struct PreferencesView: View {
.foregroundStyle(.green)
.labelStyle(.titleAndIcon)
} else {
- Button("Grant…") {
- ScreenRecordingPermission.request()
- ScreenRecordingPermission.openSystemSettings()
+ VStack(alignment: .trailing, spacing: 6) {
+ Button("Grant Access…") {
+ _ = ScreenRecordingPermission.request()
+ hasScreenPermission = ScreenRecordingPermission.isGranted
+ if !hasScreenPermission {
+ ScreenRecordingPermission.openSystemSettings()
+ }
+ }
+ .accessibilityIdentifier("preferences.grantScreenRecording")
+ Button("Open System Settings…") {
+ ScreenRecordingPermission.openSystemSettings()
+ }
+ Button("Show App in Finder…") {
+ ScreenRecordingPermission.revealAppInFinder()
+ }
}
}
}
@@ -38,6 +84,83 @@ struct PreferencesView: View {
Section("Shortcut") {
HotKeyRecorder(keyCode: $keyCode, modifiers: $modifiers)
+ Text("Additional Capture Area shortcuts are registered automatically (Copy ⌥⌘⇧C, Annotate ⌥⌘⇧A, Pin ⌥⌘⇧P, Previous ⌘⇧5).")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("After Capture") {
+ Toggle("Show Quick Access panel", isOn: $useQuickAccess)
+ if useQuickAccess {
+ Picker("Auto-close", selection: $quickAccessAutoClose) {
+ Text("Stay open").tag(0.0)
+ Text("5 seconds").tag(5.0)
+ Text("10 seconds").tag(10.0)
+ Text("30 seconds").tag(30.0)
+ }
+ }
+ Toggle("Ask for name before Save", isOn: $askForName)
+ Toggle("Copy to clipboard", isOn: $afterCopy)
+ Toggle("Open Editor", isOn: $afterEditor)
+ Toggle("Pin to screen", isOn: $afterPin)
+ Toggle("Upload", isOn: $afterUpload)
+ Toggle("Save to disk", isOn: $afterSave)
+ Toggle("Play shutter sound", isOn: $playShutter)
+ Toggle("Convert exports to sRGB", isOn: $convertSRGB)
+ TextField("File name template", text: $fileNameTemplate)
+ .textFieldStyle(.roundedBorder)
+ Text("Tokens: {date} {time} {month} {index} {app} {window}")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Capture Overlay") {
+ Toggle("All-in-One mode bar", isOn: $showAllInOneBar)
+ Toggle("Show crosshair", isOn: $showCrosshair)
+ Toggle("Show magnifier", isOn: $showMagnifier)
+ Toggle("Scale down Retina Captures", isOn: $scaleDownRetina)
+ Toggle("Hide desktop icons while capturing", isOn: $hideDesktopIcons)
+ Toggle("Enable parcel:// URL scheme", isOn: $urlSchemeEnabled)
+ Toggle("OCR without line breaks", isOn: $ocrStripLineBreaks)
+ Text("Hold ⇧ while dragging to temporarily ignore aspect presets.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Recording") {
+ Picker("Frame rate", selection: $recordingFPS) {
+ ForEach(RecordingFPS.allCases) { fps in
+ Text(fps.label).tag(fps)
+ }
+ }
+ Picker("Max resolution", selection: $maxResolution) {
+ ForEach(RecordingMaxResolution.allCases) { Text($0.label).tag($0) }
+ }
+ Picker("Countdown", selection: $countdown) {
+ Text("None").tag(0.0)
+ Text("3 seconds").tag(3.0)
+ Text("5 seconds").tag(5.0)
+ Text("10 seconds").tag(10.0)
+ }
+ Picker("Controls position", selection: $hudPosition) {
+ ForEach(RecordingHUDPosition.allCases) { Text($0.label).tag($0) }
+ }
+ Toggle("Show cursor", isOn: $showsCursor)
+ Toggle("Capture microphone (macOS 15+)", isOn: $capturesMicrophone)
+ Toggle("Record mono audio", isOn: $monoAudio)
+ Toggle("Highlight mouse clicks (macOS 15+)", isOn: $showsMouseClicks)
+ Toggle("Show keystroke HUD", isOn: $showsKeystrokes)
+ if showsKeystrokes {
+ Toggle("Only modifier shortcuts", isOn: $keystrokesCommandOnly)
+ }
+ Toggle("Show webcam (PiP)", isOn: $showsWebcam)
+ Toggle("Do Not Disturb while recording", isOn: $enableDND)
+ }
+
+ Section("Capture History") {
+ Picker("Retention", selection: $historyRetention) {
+ ForEach(HistoryRetention.allCases) { Text($0.label).tag($0) }
+ }
}
Section("Upload (Supabase)") {
@@ -49,19 +172,91 @@ struct PreferencesView: View {
.textFieldStyle(.roundedBorder)
TextField("Public base URL (optional)", text: $publicBaseURL)
.textFieldStyle(.roundedBorder)
- Text("Configure a public Supabase Storage bucket. Upload copies the object URL to your clipboard.")
- .font(.footnote)
- .foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
- .frame(width: 480, height: 420)
+ .frame(width: 540, height: 760)
.task { await refreshPermissionStatus() }
.onReceive(poll) { _ in Task { await refreshPermissionStatus() } }
- .onDisappear { persistUploadSettings() }
+ .onDisappear { persistSettings() }
+ .onChange(of: useQuickAccess) { CapturePreferences.useQuickAccess = $0 }
+ .onChange(of: quickAccessAutoClose) { CapturePreferences.quickAccessAutoCloseSeconds = $0 }
+ .onChange(of: askForName) { CapturePreferences.askForName = $0 }
+ .onChange(of: afterCopy) { CapturePreferences.afterCaptureCopy = $0 }
+ .onChange(of: afterEditor) { CapturePreferences.afterCaptureOpenEditor = $0 }
+ .onChange(of: afterPin) { CapturePreferences.afterCapturePin = $0 }
+ .onChange(of: afterUpload) { CapturePreferences.afterCaptureUpload = $0 }
+ .onChange(of: afterSave) { CapturePreferences.afterCaptureSave = $0 }
+ .onChange(of: playShutter) { CapturePreferences.playShutterSound = $0 }
+ .onChange(of: convertSRGB) { CapturePreferences.convertToSRGB = $0 }
+ .onChange(of: urlSchemeEnabled) { CapturePreferences.urlSchemeEnabled = $0 }
+ .onChange(of: fileNameTemplate) { CapturePreferences.fileNameTemplate = $0 }
+ .onChange(of: scaleDownRetina) { CapturePreferences.scaleDownRetina = $0 }
+ .onChange(of: showCrosshair) { CapturePreferences.showCrosshair = $0 }
+ .onChange(of: showMagnifier) { CapturePreferences.showMagnifier = $0 }
+ .onChange(of: hideDesktopIcons) { CapturePreferences.hideDesktopIcons = $0 }
+ .onChange(of: showAllInOneBar) { CapturePreferences.showAllInOneBar = $0 }
+ .onChange(of: ocrStripLineBreaks) { CapturePreferences.ocrStripLineBreaks = $0 }
+ .onChange(of: showsCursor) { RecordingPreferences.showsCursor = $0 }
+ .onChange(of: capturesMicrophone) { RecordingPreferences.capturesMicrophone = $0 }
+ .onChange(of: showsMouseClicks) { RecordingPreferences.showsMouseClicks = $0 }
+ .onChange(of: showsKeystrokes) { RecordingPreferences.showsKeystrokes = $0 }
+ .onChange(of: keystrokesCommandOnly) { RecordingPreferences.keystrokesCommandOnly = $0 }
+ .onChange(of: showsWebcam) { RecordingPreferences.showsWebcam = $0 }
+ .onChange(of: enableDND) { RecordingPreferences.enableDoNotDisturb = $0 }
+ .onChange(of: countdown) { RecordingPreferences.countdownSeconds = $0 }
+ .onChange(of: monoAudio) { RecordingPreferences.recordMonoAudio = $0 }
+ .onChange(of: recordingFPS) { RecordingFPS.current = $0 }
+ .onChange(of: maxResolution) { RecordingMaxResolution.current = $0 }
+ .onChange(of: hudPosition) { RecordingHUDPosition.current = $0 }
+ .onChange(of: historyRetention) { HistoryRetention.current = $0 }
+ }
+
+ private var permissionFootnote: String {
+ if hasScreenPermission {
+ return "Required to capture your screen. After granting, quit and reopen Parcel."
+ }
+ if ScreenRecordingPermission.likelyNeedsRebuildRegrant {
+ return """
+ macOS ties Screen Recording to each build. If Parcel is already toggled ON in System Settings \
+ but Capture still fails, remove Parcel from the list, click +, and choose this app in Finder.
+ """
+ }
+ return "Required to capture your screen. After granting, quit and reopen Parcel."
}
- private func persistUploadSettings() {
+ private func persistSettings() {
+ CapturePreferences.useQuickAccess = useQuickAccess
+ CapturePreferences.quickAccessAutoCloseSeconds = quickAccessAutoClose
+ CapturePreferences.askForName = askForName
+ CapturePreferences.afterCaptureCopy = afterCopy
+ CapturePreferences.afterCaptureOpenEditor = afterEditor
+ CapturePreferences.afterCapturePin = afterPin
+ CapturePreferences.afterCaptureUpload = afterUpload
+ CapturePreferences.afterCaptureSave = afterSave
+ CapturePreferences.playShutterSound = playShutter
+ CapturePreferences.convertToSRGB = convertSRGB
+ CapturePreferences.urlSchemeEnabled = urlSchemeEnabled
+ CapturePreferences.fileNameTemplate = fileNameTemplate
+ CapturePreferences.scaleDownRetina = scaleDownRetina
+ CapturePreferences.showCrosshair = showCrosshair
+ CapturePreferences.showMagnifier = showMagnifier
+ CapturePreferences.hideDesktopIcons = hideDesktopIcons
+ CapturePreferences.showAllInOneBar = showAllInOneBar
+ CapturePreferences.ocrStripLineBreaks = ocrStripLineBreaks
+ RecordingPreferences.showsCursor = showsCursor
+ RecordingPreferences.capturesMicrophone = capturesMicrophone
+ RecordingPreferences.showsMouseClicks = showsMouseClicks
+ RecordingPreferences.showsKeystrokes = showsKeystrokes
+ RecordingPreferences.keystrokesCommandOnly = keystrokesCommandOnly
+ RecordingPreferences.showsWebcam = showsWebcam
+ RecordingPreferences.enableDoNotDisturb = enableDND
+ RecordingPreferences.countdownSeconds = countdown
+ RecordingPreferences.recordMonoAudio = monoAudio
+ RecordingFPS.current = recordingFPS
+ RecordingMaxResolution.current = maxResolution
+ RecordingHUDPosition.current = hudPosition
+ HistoryRetention.current = historyRetention
UploadPreferences.supabaseURL = supabaseURL
UploadPreferences.anonKey = anonKey
UploadPreferences.bucketName = bucketName
@@ -70,6 +265,6 @@ struct PreferencesView: View {
@MainActor
private func refreshPermissionStatus() async {
- hasScreenPermission = await ScreenRecordingPermission.hasEffectiveAccess()
+ hasScreenPermission = ScreenRecordingPermission.isGranted
}
}
diff --git a/Sources/Parcel/Preferences/PreferencesWindowController.swift b/Sources/Parcel/Preferences/PreferencesWindowController.swift
index a585759..2a2d4a7 100644
--- a/Sources/Parcel/Preferences/PreferencesWindowController.swift
+++ b/Sources/Parcel/Preferences/PreferencesWindowController.swift
@@ -15,8 +15,8 @@ final class PreferencesWindowController {
}
let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 440, height: 260),
- styleMask: [.titled, .closable],
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 720),
+ styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
diff --git a/Sources/Parcel/Recording/KeystrokeHUDController.swift b/Sources/Parcel/Recording/KeystrokeHUDController.swift
new file mode 100644
index 0000000..dba481f
--- /dev/null
+++ b/Sources/Parcel/Recording/KeystrokeHUDController.swift
@@ -0,0 +1,232 @@
+import AppKit
+import Carbon.HIToolbox
+import SwiftUI
+
+/// Floating dark pill that shows the latest keystroke while recording.
+@MainActor
+final class KeystrokeHUDController {
+
+ private var window: NSPanel?
+ private var globalMonitor: Any?
+ private var localMonitor: Any?
+ private var hideWorkItem: DispatchWorkItem?
+ private var hosting: NSHostingView?
+
+ func start() {
+ guard RecordingPreferences.showsKeystrokes else { return }
+ stop()
+ presentWindow()
+ installMonitors()
+ }
+
+ func stop() {
+ hideWorkItem?.cancel()
+ hideWorkItem = nil
+ if let globalMonitor {
+ NSEvent.removeMonitor(globalMonitor)
+ self.globalMonitor = nil
+ }
+ if let localMonitor {
+ NSEvent.removeMonitor(localMonitor)
+ self.localMonitor = nil
+ }
+ window?.orderOut(nil)
+ window = nil
+ hosting = nil
+ }
+
+ // MARK: Private
+
+ private func presentWindow() {
+ let size = CGSize(width: 420, height: 56)
+ let panel = NSPanel(
+ contentRect: NSRect(origin: .zero, size: size),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = false
+ panel.level = .screenSaver
+ panel.ignoresMouseEvents = true
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
+ panel.isReleasedWhenClosed = false
+
+ let root = KeystrokeHUDView(label: "")
+ let view = NSHostingView(rootView: root)
+ view.frame = NSRect(origin: .zero, size: size)
+ panel.contentView = view
+ hosting = view
+ window = panel
+ positionOnScreen()
+ // Stay hidden until the first key.
+ panel.alphaValue = 0
+ panel.orderFrontRegardless()
+ }
+
+ private func installMonitors() {
+ let handler: (NSEvent) -> Void = { [weak self] event in
+ Task { @MainActor in self?.handle(event) }
+ }
+ globalMonitor = NSEvent.addGlobalMonitorForEvents(
+ matching: [.keyDown, .flagsChanged],
+ handler: handler
+ )
+ localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
+ self?.handle(event)
+ return event
+ }
+ }
+
+ private func handle(_ event: NSEvent) {
+ guard let label = KeystrokeHUDLabel.label(
+ for: event,
+ commandOnly: RecordingPreferences.keystrokesCommandOnly
+ ), !label.isEmpty else { return }
+ show(label: label)
+ }
+
+ private func show(label: String) {
+ guard let window, let hosting else { return }
+ hosting.rootView = KeystrokeHUDView(label: label)
+ positionOnScreen()
+ window.alphaValue = 1
+ window.orderFrontRegardless()
+
+ hideWorkItem?.cancel()
+ let work = DispatchWorkItem { [weak self] in
+ NSAnimationContext.runAnimationGroup { ctx in
+ ctx.duration = 0.25
+ self?.window?.animator().alphaValue = 0
+ }
+ }
+ hideWorkItem = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
+ }
+
+ private func positionOnScreen() {
+ guard let window else { return }
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
+ let size = window.frame.size
+ let margin: CGFloat = 28
+ let origin: CGPoint
+ switch RecordingHUDPosition.current {
+ case .bottomCenter:
+ origin = CGPoint(x: screen.midX - size.width / 2, y: screen.minY + margin)
+ case .bottomLeading:
+ origin = CGPoint(x: screen.minX + margin, y: screen.minY + margin)
+ case .bottomTrailing:
+ origin = CGPoint(x: screen.maxX - size.width - margin, y: screen.minY + margin)
+ case .topCenter:
+ origin = CGPoint(x: screen.midX - size.width / 2, y: screen.maxY - size.height - margin)
+ }
+ window.setFrameOrigin(origin)
+ }
+}
+
+/// Builds the keystroke HUD shortcut string (⌘⇧A, Return, etc.) without requiring a live event monitor.
+enum KeystrokeHUDLabel {
+ static func label(for event: NSEvent, commandOnly: Bool) -> String? {
+ label(
+ eventType: event.type,
+ keyCode: event.keyCode,
+ charactersIgnoringModifiers: event.charactersIgnoringModifiers,
+ modifierFlags: event.modifierFlags,
+ commandOnly: commandOnly
+ )
+ }
+
+ static func label(
+ eventType: NSEvent.EventType,
+ keyCode: UInt16,
+ charactersIgnoringModifiers: String?,
+ modifierFlags: NSEvent.ModifierFlags,
+ commandOnly: Bool
+ ) -> String? {
+ if eventType == .flagsChanged {
+ let mods = modifierSymbols(modifierFlags)
+ return mods.isEmpty ? nil : mods
+ }
+
+ guard eventType == .keyDown else { return nil }
+ // Skip pure modifier presses already handled by flagsChanged.
+ if keyCode == UInt16(kVK_Shift)
+ || keyCode == UInt16(kVK_RightShift)
+ || keyCode == UInt16(kVK_Control)
+ || keyCode == UInt16(kVK_RightControl)
+ || keyCode == UInt16(kVK_Option)
+ || keyCode == UInt16(kVK_RightOption)
+ || keyCode == UInt16(kVK_Command)
+ || keyCode == UInt16(kVK_RightCommand)
+ {
+ return nil
+ }
+
+ let mods = modifierSymbols(modifierFlags)
+ let key = keyName(keyCode: keyCode, charactersIgnoringModifiers: charactersIgnoringModifiers)
+ guard !key.isEmpty else { return nil }
+
+ // Prefer showing command-combo shortcuts; still show plain keys for tutorials.
+ if commandOnly {
+ let hasCommand = modifierFlags.contains(.command)
+ || modifierFlags.contains(.control)
+ || modifierFlags.contains(.option)
+ guard hasCommand else { return nil }
+ }
+ return mods + key
+ }
+
+ private static func modifierSymbols(_ flags: NSEvent.ModifierFlags) -> String {
+ var parts = ""
+ if flags.contains(.control) { parts += "⌃" }
+ if flags.contains(.option) { parts += "⌥" }
+ if flags.contains(.shift) { parts += "⇧" }
+ if flags.contains(.command) { parts += "⌘" }
+ return parts
+ }
+
+ private static func keyName(keyCode: UInt16, charactersIgnoringModifiers: String?) -> String {
+ switch Int(keyCode) {
+ case kVK_Return, kVK_ANSI_KeypadEnter: return "↩"
+ case kVK_Escape: return "Esc"
+ case kVK_Delete: return "⌫"
+ case kVK_ForwardDelete: return "⌦"
+ case kVK_Tab: return "⇥"
+ case kVK_Space: return "Space"
+ case kVK_LeftArrow: return "←"
+ case kVK_RightArrow: return "→"
+ case kVK_UpArrow: return "↑"
+ case kVK_DownArrow: return "↓"
+ default:
+ if let chars = charactersIgnoringModifiers?.uppercased(), !chars.isEmpty {
+ return chars
+ }
+ return ""
+ }
+ }
+}
+
+private struct KeystrokeHUDView: View {
+ let label: String
+
+ var body: some View {
+ Group {
+ if label.isEmpty {
+ Color.clear
+ } else {
+ Text(label)
+ .font(.system(size: 22, weight: .semibold, design: .rounded))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 22)
+ .padding(.vertical, 12)
+ .background(
+ Capsule(style: .continuous)
+ .fill(Color.black.opacity(0.78))
+ )
+ .shadow(color: .black.opacity(0.35), radius: 10, y: 4)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Sources/Parcel/Recording/RecordingPreferences.swift b/Sources/Parcel/Recording/RecordingPreferences.swift
index 3d1ac26..aa949fb 100644
--- a/Sources/Parcel/Recording/RecordingPreferences.swift
+++ b/Sources/Parcel/Recording/RecordingPreferences.swift
@@ -1,5 +1,5 @@
-import CoreMedia
import Foundation
+import CoreMedia
enum RecordingFPS: Int, CaseIterable, Identifiable {
case fps30 = 30
@@ -24,3 +24,122 @@ enum RecordingFPS: Int, CaseIterable, Identifiable {
set { UserDefaults.standard.set(newValue.rawValue, forKey: storageKey) }
}
}
+
+enum RecordingMaxResolution: String, CaseIterable, Identifiable {
+ case native
+ case p1080
+ case p720
+ case p480
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .native: return "Native"
+ case .p1080: return "1080p"
+ case .p720: return "720p"
+ case .p480: return "480p"
+ }
+ }
+
+ /// Longest edge cap in pixels; nil = uncapped.
+ var maxLongEdge: Int? {
+ switch self {
+ case .native: return nil
+ case .p1080: return 1920
+ case .p720: return 1280
+ case .p480: return 854
+ }
+ }
+
+ private static let key = "\(AppIdentity.defaultsPrefix).recording.maxResolution"
+
+ static var current: RecordingMaxResolution {
+ get {
+ let raw = UserDefaults.standard.string(forKey: key) ?? ""
+ return RecordingMaxResolution(rawValue: raw) ?? .native
+ }
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
+ }
+}
+
+enum RecordingHUDPosition: String, CaseIterable, Identifiable {
+ case bottomCenter, bottomLeading, bottomTrailing, topCenter
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .bottomCenter: return "Bottom center"
+ case .bottomLeading: return "Bottom left"
+ case .bottomTrailing: return "Bottom right"
+ case .topCenter: return "Top center"
+ }
+ }
+
+ private static let key = "\(AppIdentity.defaultsPrefix).recording.hudPosition"
+
+ static var current: RecordingHUDPosition {
+ get {
+ let raw = UserDefaults.standard.string(forKey: key) ?? ""
+ return RecordingHUDPosition(rawValue: raw) ?? .bottomCenter
+ }
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
+ }
+}
+
+/// Toggleable recording behavior.
+enum RecordingPreferences {
+ private static let prefix = AppIdentity.defaultsPrefix
+
+ static var showsCursor: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).recording.showsCursor") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.showsCursor") }
+ }
+
+ static var capturesMicrophone: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).recording.capturesMicrophone") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.capturesMicrophone") }
+ }
+
+ static var showsMouseClicks: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).recording.showsMouseClicks") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.showsMouseClicks") }
+ }
+
+ /// Floating keystroke HUD while recording (dark pill, bottom-center).
+ static var showsKeystrokes: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).recording.showsKeystrokes") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.showsKeystrokes") }
+ }
+
+ /// When true, only show shortcuts that include ⌘ / ⌃ / ⌥ (not every letter).
+ static var keystrokesCommandOnly: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).recording.keystrokesCommandOnly") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.keystrokesCommandOnly") }
+ }
+
+ /// Circular webcam PiP during recording (off by default).
+ static var showsWebcam: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).recording.showsWebcam") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.showsWebcam") }
+ }
+
+ /// Best-effort Focus / Do Not Disturb while recording.
+ static var enableDoNotDisturb: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).recording.enableDoNotDisturb") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.enableDoNotDisturb") }
+ }
+
+ /// Countdown seconds before recording starts (0 = none).
+ static var countdownSeconds: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).recording.countdown") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.countdown") }
+ }
+
+ /// Record audio as mono.
+ static var recordMonoAudio: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).recording.mono") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).recording.mono") }
+ }
+}
diff --git a/Sources/Parcel/Recording/RecordingWindowController.swift b/Sources/Parcel/Recording/RecordingWindowController.swift
index d964907..e9b82b7 100644
--- a/Sources/Parcel/Recording/RecordingWindowController.swift
+++ b/Sources/Parcel/Recording/RecordingWindowController.swift
@@ -10,9 +10,10 @@ final class RecordingWindowController: NSObject, NSWindowDelegate {
var onClose: (() -> Void)?
private let window: NSWindow
+ private let model: RecordingEditorModel
init(url: URL) {
- let model = RecordingEditorModel(url: url)
+ model = RecordingEditorModel(url: url)
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 860, height: 620),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
@@ -25,14 +26,19 @@ final class RecordingWindowController: NSObject, NSWindowDelegate {
window.tabbingMode = .disallowed
window.isReleasedWhenClosed = false
window.delegate = self
- window.contentView = NSHostingView(rootView: RecordingEditorView(model: model, onClose: { [weak self] in
- self?.window.close()
- }))
+ window.center()
}
func show() {
+ if window.contentView == nil {
+ window.contentView = NSHostingView(rootView: RecordingEditorView(model: model, onClose: { [weak self] in
+ self?.window.close()
+ }))
+ }
NSApp.activate(ignoringOtherApps: true)
+ window.center()
window.makeKeyAndOrderFront(nil)
+ window.orderFrontRegardless()
}
func windowWillClose(_ notification: Notification) {
@@ -41,7 +47,7 @@ final class RecordingWindowController: NSObject, NSWindowDelegate {
}
@MainActor
-private final class RecordingEditorModel: ObservableObject {
+final class RecordingEditorModel: ObservableObject {
let sourceURL: URL
let player: AVPlayer
@@ -54,13 +60,28 @@ private final class RecordingEditorModel: ObservableObject {
private let asset: AVURLAsset
private let minimumDuration = 0.1
- init(url: URL) {
+ init(url: URL, autoloadDuration: Bool = true) {
sourceURL = url
asset = AVURLAsset(url: url)
player = AVPlayer(url: url)
- let seconds = asset.duration.seconds
- duration = seconds.isFinite && seconds > 0 ? seconds : 0
- endTime = duration
+ if autoloadDuration {
+ Task { await self.loadDuration() }
+ }
+ }
+
+ func loadDuration() async {
+ do {
+ let time = try await asset.load(.duration)
+ let seconds = time.seconds
+ let value = seconds.isFinite && seconds > 0 ? seconds : 0
+ duration = value
+ if endTime == 0 || endTime > value {
+ endTime = value
+ }
+ } catch {
+ duration = 0
+ endTime = 0
+ }
}
var trimmedDuration: Double { max(endTime - startTime, 0) }
@@ -68,14 +89,14 @@ private final class RecordingEditorModel: ObservableObject {
func exportMP4() {
savePanel(name: defaultName(extension: "mp4"), type: .mpeg4Movie) { [weak self] url in
guard let self else { return }
- Task { await self.writeMP4(to: url) }
+ Task { await self.writeExport { try await self.writeMP4(to: url) } }
}
}
func exportGIF() {
savePanel(name: defaultName(extension: "gif"), type: .gif) { [weak self] url in
guard let self else { return }
- Task { await self.writeGIF(to: url) }
+ Task { await self.writeExport { try await self.writeGIF(to: url) } }
}
}
@@ -89,37 +110,51 @@ private final class RecordingEditorModel: ObservableObject {
}
}
- private func writeMP4(to url: URL) async {
+ private func writeExport(_ operation: () async throws -> Void) async {
isExporting = true
errorMessage = nil
defer { isExporting = false }
do {
- guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
- throw ExportError.unavailable
- }
- exporter.outputURL = url
- exporter.outputFileType = .mp4
- exporter.timeRange = timeRange
- try await exporter.export(to: url, as: .mp4)
+ try await operation()
} catch {
errorMessage = error.localizedDescription
}
}
- private func writeGIF(to url: URL) async {
- isExporting = true
- errorMessage = nil
- defer { isExporting = false }
- let range = timeRange
- do {
- try await Task.detached(priority: .userInitiated) { [asset] in
- try RecordingGIFExporter.write(asset: asset, timeRange: range, to: url)
- }.value
- } catch {
- errorMessage = error.localizedDescription
+ func writeMP4(to url: URL) async throws {
+ try Self.prepareExportDestination(url)
+ guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
+ throw ExportError.unavailable
+ }
+ exporter.outputURL = url
+ exporter.outputFileType = .mp4
+ exporter.timeRange = timeRange
+ if #available(macOS 15.0, *) {
+ try await exporter.export(to: url, as: .mp4)
+ } else {
+ try await withCheckedThrowingContinuation { continuation in
+ exporter.exportAsynchronously {
+ switch exporter.status {
+ case .completed:
+ continuation.resume()
+ case .cancelled:
+ continuation.resume(throwing: CancellationError())
+ default:
+ continuation.resume(throwing: exporter.error ?? ExportError.failed)
+ }
+ }
+ }
}
}
+ func writeGIF(to url: URL) async throws {
+ try Self.prepareExportDestination(url)
+ let range = timeRange
+ try await Task.detached(priority: .userInitiated) { [asset] in
+ try RecordingGIFExporter.write(asset: asset, timeRange: range, to: url)
+ }.value
+ }
+
private var timeRange: CMTimeRange {
CMTimeRange(
start: CMTime(seconds: startTime, preferredTimescale: 600),
@@ -130,9 +165,17 @@ private final class RecordingEditorModel: ObservableObject {
private func normalizeRange(changedStart: Bool) {
guard duration > 0 else { return }
if changedStart {
- startTime = min(max(startTime, 0), max(endTime - minimumDuration, 0))
+ let nextStart = min(max(startTime, 0), max(endTime - minimumDuration, 0))
+ if startTime != nextStart {
+ startTime = nextStart
+ return
+ }
} else {
- endTime = max(min(endTime, duration), min(startTime + minimumDuration, duration))
+ let nextEnd = max(min(endTime, duration), min(startTime + minimumDuration, duration))
+ if endTime != nextEnd {
+ endTime = nextEnd
+ return
+ }
}
player.seek(to: CMTime(seconds: startTime, preferredTimescale: 600))
}
@@ -143,9 +186,26 @@ private final class RecordingEditorModel: ObservableObject {
return "Parcel Recording \(formatter.string(from: Date())).\(fileExtension)"
}
+ private static func prepareExportDestination(_ url: URL) throws {
+ let fileManager = FileManager.default
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ if fileManager.fileExists(atPath: url.path) {
+ try fileManager.removeItem(at: url)
+ }
+ }
+
private enum ExportError: LocalizedError {
case unavailable
- var errorDescription: String? { "This recording cannot be exported on this Mac." }
+ case failed
+
+ var errorDescription: String? {
+ switch self {
+ case .unavailable:
+ "This recording cannot be exported on this Mac."
+ case .failed:
+ "The recording export did not complete."
+ }
+ }
}
}
@@ -155,7 +215,9 @@ private struct RecordingEditorView: View {
var body: some View {
VStack(spacing: 0) {
- VideoPlayer(player: model.player)
+ // AppKit AVPlayerView avoids a macOS 26 `_AVKit_SwiftUI` metadata abort
+ // that crashes when hosting SwiftUI `VideoPlayer` in an NSHostingView.
+ RecordingPlayerView(player: model.player)
.background(Color.black)
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -216,6 +278,24 @@ private struct RecordingEditorView: View {
}
}
+/// AppKit-backed player for the Recording trim window (avoids SwiftUI `VideoPlayer` crash).
+private struct RecordingPlayerView: NSViewRepresentable {
+ let player: AVPlayer
+
+ func makeNSView(context: Context) -> AVPlayerView {
+ let view = AVPlayerView()
+ view.controlsStyle = .inline
+ view.player = player
+ return view
+ }
+
+ func updateNSView(_ nsView: AVPlayerView, context: Context) {
+ if nsView.player !== player {
+ nsView.player = player
+ }
+ }
+}
+
private enum RecordingGIFExporter {
static func write(asset: AVAsset, timeRange: CMTimeRange, to url: URL) throws {
let duration = CMTimeGetSeconds(timeRange.duration)
diff --git a/Sources/Parcel/Recording/ScreenRecorder.swift b/Sources/Parcel/Recording/ScreenRecorder.swift
index b567413..8782f98 100644
--- a/Sources/Parcel/Recording/ScreenRecorder.swift
+++ b/Sources/Parcel/Recording/ScreenRecorder.swift
@@ -1,11 +1,16 @@
import AppKit
import AVFoundation
import CoreMedia
+import CoreVideo
import ScreenCaptureKit
-/// One-display MP4 recorder. macOS 15+ uses ScreenCaptureKit's native recorder; macOS 13–14
-/// writes the same ScreenCaptureKit stream through AVFoundation. The output always includes
-/// system audio when the system grants Screen Recording permission.
+/// One-display MP4 recorder. Writes the ScreenCaptureKit stream through AVFoundation so
+/// pause/resume can skip samples on every supported macOS.
+///
+/// Video frames are appended via `AVAssetWriterInputPixelBufferAdaptor` (required for stable
+/// H.264 finalize from SCStream BGRA buffers). Audio is optional: the AAC input is added only
+/// when a real audio sample arrives before `startWriting`, so an empty audio track cannot
+/// leave an MP4 without a `moov` atom.
@MainActor
final class ScreenRecorder: NSObject, ObservableObject {
@@ -14,6 +19,8 @@ final class ScreenRecorder: NSObject, ObservableObject {
case alreadyRecording
case notRecording
case permissionDenied
+ case noVideoFrames
+ case finalizeFailed(String)
var errorDescription: String? {
switch self {
@@ -21,11 +28,30 @@ final class ScreenRecorder: NSObject, ObservableObject {
case .alreadyRecording: return "A recording is already in progress."
case .notRecording: return "There is no recording in progress."
case .permissionDenied: return "Screen Recording permission is required to record a display."
+ case .noVideoFrames: return "The recording ended before any video frames were captured."
+ case .finalizeFailed(let detail):
+ return "The recording could not be finalized. \(detail)"
+ }
+ }
+
+ static func == (lhs: RecorderError, rhs: RecorderError) -> Bool {
+ switch (lhs, rhs) {
+ case (.noDisplay, .noDisplay),
+ (.alreadyRecording, .alreadyRecording),
+ (.notRecording, .notRecording),
+ (.permissionDenied, .permissionDenied),
+ (.noVideoFrames, .noVideoFrames):
+ return true
+ case (.finalizeFailed(let a), .finalizeFailed(let b)):
+ return a == b
+ default:
+ return false
}
}
}
@Published private(set) var isRecording = false
+ @Published private(set) var isPaused = false
@Published private(set) var elapsed: TimeInterval = 0
var onFailure: ((Error) -> Void)?
@@ -33,15 +59,20 @@ final class ScreenRecorder: NSObject, ObservableObject {
private var stream: SCStream?
private var legacyWriter: LegacyRecordingWriter?
private var destinationURL: URL?
+ private var workingURL: URL?
private var startedAt: Date?
+ private var pausedAccumulated: TimeInterval = 0
+ private var pauseStartedAt: Date?
private var elapsedTimer: Timer?
/// Stored as NSObject to keep macOS 15-only APIs out of the macOS 13 property surface.
private var nativeObjects: [NSObject] = []
- func start(to url: URL) async throws {
+ func start(to url: URL, selection: SelectionResult? = nil) async throws {
guard !isRecording else { throw RecorderError.alreadyRecording }
- ScreenRecordingPermission.requestIfNeeded()
+ guard ScreenRecordingPermission.isGranted else {
+ throw RecorderError.permissionDenied
+ }
let content: SCShareableContent
do {
@@ -52,80 +83,139 @@ final class ScreenRecorder: NSObject, ObservableObject {
}
throw error
}
- guard let display = preferredDisplay(in: content) else { throw RecorderError.noDisplay }
+
+ let display: SCDisplay
+ if let selection {
+ guard let match = content.displays.first(where: { $0.displayID == selection.screen.id }) else {
+ throw RecorderError.noDisplay
+ }
+ display = match
+ } else if let preferred = preferredDisplay(in: content) {
+ display = preferred
+ } else {
+ throw RecorderError.noDisplay
+ }
let scale = NSScreen.screen(forDisplayID: display.displayID)?.backingScaleFactor ?? 1
+ let sourceRect = selection?.rectInPoints
+ let captureWidth = sourceRect?.width ?? CGFloat(display.width)
+ let captureHeight = sourceRect?.height ?? CGFloat(display.height)
+ let pixelSize = RecordingGeometry.pixelSize(
+ captureSizeInPoints: CGSize(width: captureWidth, height: captureHeight),
+ scale: scale,
+ maxResolution: .current
+ )
+ let pixelWidth = pixelSize.width
+ let pixelHeight = pixelSize.height
+
let configuration = SCStreamConfiguration()
- configuration.width = Int((CGFloat(display.width) * scale).rounded())
- configuration.height = Int((CGFloat(display.height) * scale).rounded())
+ configuration.width = pixelWidth
+ configuration.height = pixelHeight
+ if let sourceRect {
+ configuration.sourceRect = sourceRect
+ }
configuration.minimumFrameInterval = RecordingFPS.current.frameInterval
configuration.queueDepth = 5
- configuration.showsCursor = true
+ configuration.showsCursor = RecordingPreferences.showsCursor
configuration.capturesAudio = true
configuration.excludesCurrentProcessAudio = true
configuration.sampleRate = 48_000
- configuration.channelCount = 2
+ configuration.channelCount = RecordingPreferences.recordMonoAudio ? 1 : 2
+ configuration.pixelFormat = kCVPixelFormatType_32BGRA
if #available(macOS 15.0, *) {
- configuration.showMouseClicks = true
- configuration.captureMicrophone = true
+ configuration.showMouseClicks = RecordingPreferences.showsMouseClicks
+ configuration.captureMicrophone = RecordingPreferences.capturesMicrophone
}
+ DesktopIconHider.beginSessionIfNeeded()
+
let filter = SCContentFilter(display: display, excludingWindows: [])
let newStream = SCStream(filter: filter, configuration: configuration, delegate: nil)
- if #available(macOS 15.0, *) {
- let outputConfiguration = SCRecordingOutputConfiguration()
- outputConfiguration.outputURL = url
- outputConfiguration.outputFileType = .mp4
- outputConfiguration.videoCodecType = .h264
+ let recordingURL = Self.workingRecordingURL(for: url)
+ try? FileManager.default.removeItem(at: recordingURL)
- let delegate = NativeRecordingDelegate { [weak self] error in
- Task { @MainActor in self?.fail(error) }
- }
- let output = SCRecordingOutput(configuration: outputConfiguration, delegate: delegate)
- try newStream.addRecordingOutput(output)
- nativeObjects = [output, delegate]
- } else {
- let writer = try LegacyRecordingWriter(
- url: url,
- videoSize: CGSize(width: configuration.width, height: configuration.height)
- )
- try newStream.addStreamOutput(writer, type: .screen, sampleHandlerQueue: writer.sampleQueue)
- try newStream.addStreamOutput(writer, type: .audio, sampleHandlerQueue: writer.sampleQueue)
- legacyWriter = writer
- }
+ // Always use AVAssetWriter path so pause/resume works on every supported macOS.
+ let writer = try LegacyRecordingWriter(
+ url: recordingURL,
+ videoSize: CGSize(width: pixelWidth, height: pixelHeight),
+ monoAudio: RecordingPreferences.recordMonoAudio
+ )
+ try newStream.addStreamOutput(writer, type: .screen, sampleHandlerQueue: writer.sampleQueue)
+ try newStream.addStreamOutput(writer, type: .audio, sampleHandlerQueue: writer.sampleQueue)
+ legacyWriter = writer
+ nativeObjects = []
stream = newStream
destinationURL = url
+ workingURL = recordingURL
do {
try await newStream.startCapture()
isRecording = true
+ isPaused = false
startedAt = Date()
+ pausedAccumulated = 0
+ pauseStartedAt = nil
elapsed = 0
startElapsedTimer()
} catch {
+ try? FileManager.default.removeItem(at: recordingURL)
clearState()
throw error
}
}
+ func pause() {
+ guard isRecording, !isPaused else { return }
+ isPaused = true
+ pauseStartedAt = Date()
+ legacyWriter?.isPaused = true
+ }
+
+ func resume() {
+ guard isRecording, isPaused else { return }
+ if let pauseStartedAt {
+ pausedAccumulated += Date().timeIntervalSince(pauseStartedAt)
+ }
+ pauseStartedAt = nil
+ isPaused = false
+ legacyWriter?.isPaused = false
+ }
+
func stop() async throws -> URL {
- guard let stream, let destinationURL else { throw RecorderError.notRecording }
+ guard let stream, let destinationURL, let workingURL else { throw RecorderError.notRecording }
elapsedTimer?.invalidate()
elapsedTimer = nil
+ let writer = legacyWriter
do {
try await stream.stopCapture()
- if let legacyWriter {
- await legacyWriter.finish()
+ } catch {
+ // Still attempt finalize if frames were written.
+ _ = error
+ }
+
+ do {
+ if let writer {
+ try await writer.finish()
}
- clearState()
- return destinationURL
} catch {
clearState()
+ try? FileManager.default.removeItem(at: workingURL)
throw error
}
+
+ do {
+ try Self.installFinishedRecording(from: workingURL, to: destinationURL)
+ } catch {
+ clearState()
+ try? FileManager.default.removeItem(at: workingURL)
+ throw RecorderError.finalizeFailed("Could not move the completed recording into place. \(error.localizedDescription)")
+ }
+
+ clearState()
+ return destinationURL
}
private func preferredDisplay(in content: SCShareableContent) -> SCDisplay? {
@@ -142,7 +232,11 @@ final class ScreenRecorder: NSObject, ObservableObject {
elapsedTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, let startedAt = self.startedAt else { return }
- self.elapsed = Date().timeIntervalSince(startedAt)
+ var pauseExtra = self.pausedAccumulated
+ if let pauseStartedAt = self.pauseStartedAt {
+ pauseExtra += Date().timeIntervalSince(pauseStartedAt)
+ }
+ self.elapsed = Date().timeIntervalSince(startedAt) - pauseExtra
}
}
}
@@ -156,12 +250,69 @@ final class ScreenRecorder: NSObject, ObservableObject {
elapsedTimer?.invalidate()
elapsedTimer = nil
isRecording = false
+ isPaused = false
elapsed = 0
startedAt = nil
+ pausedAccumulated = 0
+ pauseStartedAt = nil
stream = nil
legacyWriter = nil
destinationURL = nil
+ workingURL = nil
nativeObjects.removeAll()
+ DesktopIconHider.endSession()
+ }
+
+ static func workingRecordingURL(for destinationURL: URL) -> URL {
+ let fileExtension = destinationURL.pathExtension.isEmpty ? "mp4" : destinationURL.pathExtension
+ return FileManager.default.temporaryDirectory
+ .appendingPathComponent("ParcelRecording-\(UUID().uuidString)")
+ .appendingPathExtension(fileExtension)
+ }
+
+ static func installFinishedRecording(from sourceURL: URL, to destinationURL: URL) throws {
+ let fileManager = FileManager.default
+ let parent = destinationURL.deletingLastPathComponent()
+ try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
+ if fileManager.fileExists(atPath: destinationURL.path) {
+ _ = try fileManager.replaceItemAt(destinationURL, withItemAt: sourceURL)
+ } else {
+ try fileManager.moveItem(at: sourceURL, to: destinationURL)
+ }
+ }
+}
+
+struct RecordingPixelSize: Equatable {
+ var width: Int
+ var height: Int
+}
+
+enum RecordingGeometry {
+ static func pixelSize(
+ captureSizeInPoints: CGSize,
+ scale: CGFloat,
+ maxResolution: RecordingMaxResolution
+ ) -> RecordingPixelSize {
+ var pixelWidth = Int((captureSizeInPoints.width * scale).rounded())
+ var pixelHeight = Int((captureSizeInPoints.height * scale).rounded())
+ // H.264 requires even dimensions.
+ pixelWidth = evenAtLeastTwo(pixelWidth)
+ pixelHeight = evenAtLeastTwo(pixelHeight)
+
+ if let maxEdge = maxResolution.maxLongEdge {
+ let longest = max(pixelWidth, pixelHeight)
+ if longest > maxEdge {
+ let factor = CGFloat(maxEdge) / CGFloat(longest)
+ pixelWidth = evenAtLeastTwo(Int((CGFloat(pixelWidth) * factor).rounded()))
+ pixelHeight = evenAtLeastTwo(Int((CGFloat(pixelHeight) * factor).rounded()))
+ }
+ }
+
+ return RecordingPixelSize(width: pixelWidth, height: pixelHeight)
+ }
+
+ private static func evenAtLeastTwo(_ value: Int) -> Int {
+ max(2, value & ~1)
}
}
@@ -178,18 +329,35 @@ private final class NativeRecordingDelegate: NSObject, SCRecordingOutputDelegate
}
}
-/// AVFoundation fallback used on macOS 13–14. The stream is owned by `ScreenRecorder`; this
-/// object only serializes sample delivery and finalization for one MP4 file.
-private final class LegacyRecordingWriter: NSObject, SCStreamOutput, @unchecked Sendable {
+/// AVFoundation writer used for all macOS versions so pause/resume can skip samples.
+final class LegacyRecordingWriter: NSObject, SCStreamOutput, @unchecked Sendable {
let sampleQueue = DispatchQueue(label: "dev.parable.recording.samples")
+ var isPaused = false
private let writer: AVAssetWriter
private let videoInput: AVAssetWriterInput
- private let audioInput: AVAssetWriterInput
+ private let pixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor
+ private let monoAudio: Bool
+ private let videoSize: CGSize
+ private var audioInput: AVAssetWriterInput?
+
private var sessionStarted = false
private var hasFinished = false
+ private var videoSampleCount = 0
+ private var pendingVideo: [CMSampleBuffer] = []
+ private var pendingAudio: [CMSampleBuffer] = []
+ private var sawAudioBeforeStart = false
+ private var writerFailure: Error?
+ #if DEBUG
+ private var receivedVideoSampleCount = 0
+ #endif
+
+ /// After this many buffered video frames with no audio, start a video-only session.
+ private let maxVideoFramesBeforeForceStart = 12
- init(url: URL, videoSize: CGSize) throws {
+ init(url: URL, videoSize: CGSize, monoAudio: Bool = false) throws {
+ self.monoAudio = monoAudio
+ self.videoSize = videoSize
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
videoInput = AVAssetWriterInput(
mediaType: .video,
@@ -197,50 +365,102 @@ private final class LegacyRecordingWriter: NSObject, SCStreamOutput, @unchecked
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: Int(videoSize.width.rounded()),
AVVideoHeightKey: Int(videoSize.height.rounded()),
- AVVideoCompressionPropertiesKey: [AVVideoAverageBitRateKey: 12_000_000],
+ AVVideoCompressionPropertiesKey: [
+ AVVideoAverageBitRateKey: 12_000_000,
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
+ ],
]
)
- audioInput = AVAssetWriterInput(
- mediaType: .audio,
- outputSettings: [
- AVFormatIDKey: kAudioFormatMPEG4AAC,
- AVSampleRateKey: 48_000,
- AVNumberOfChannelsKey: 2,
- AVEncoderBitRateKey: 160_000,
+ videoInput.expectsMediaDataInRealTime = true
+ pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(
+ assetWriterInput: videoInput,
+ sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA),
+ kCVPixelBufferWidthKey as String: Int(videoSize.width.rounded()),
+ kCVPixelBufferHeightKey as String: Int(videoSize.height.rounded()),
]
)
- videoInput.expectsMediaDataInRealTime = true
- audioInput.expectsMediaDataInRealTime = true
- guard writer.canAdd(videoInput), writer.canAdd(audioInput) else {
+ guard writer.canAdd(videoInput) else {
throw NSError(domain: "dev.parable.recording", code: 1, userInfo: [
- NSLocalizedDescriptionKey: "The MP4 writer could not accept its media inputs.",
+ NSLocalizedDescriptionKey: "The MP4 writer could not accept its video input.",
])
}
writer.add(videoInput)
- writer.add(audioInput)
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
- guard sampleBuffer.isValid, CMSampleBufferDataIsReady(sampleBuffer) else { return }
+ guard !isPaused, sampleBuffer.isValid, CMSampleBufferDataIsReady(sampleBuffer) else { return }
sampleQueue.async { [weak self] in self?.append(sampleBuffer, type: type) }
}
- func finish() async {
- await withCheckedContinuation { continuation in
+ #if DEBUG
+ func receiveSampleForTesting(_ sampleBuffer: CMSampleBuffer, type: SCStreamOutputType) {
+ guard !isPaused, sampleBuffer.isValid, CMSampleBufferDataIsReady(sampleBuffer) else { return }
+ sampleQueue.async { [weak self] in self?.append(sampleBuffer, type: type) }
+ }
+
+ var videoSampleCountForTesting: Int {
+ sampleQueue.sync { videoSampleCount }
+ }
+
+ var receivedVideoSampleCountForTesting: Int {
+ sampleQueue.sync { receivedVideoSampleCount }
+ }
+
+ func flushSamplesForTesting() {
+ sampleQueue.sync {}
+ }
+ #endif
+
+ func finish() async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
sampleQueue.async { [weak self] in
- guard let self, !self.hasFinished else {
+ guard let self else {
+ continuation.resume(throwing: ScreenRecorder.RecorderError.notRecording)
+ return
+ }
+ if let writerFailure = self.writerFailure {
+ continuation.resume(throwing: writerFailure)
+ return
+ }
+ guard !self.hasFinished else {
continuation.resume()
return
}
self.hasFinished = true
- self.videoInput.markAsFinished()
- self.audioInput.markAsFinished()
- guard self.sessionStarted else {
+
+ if !self.sessionStarted {
+ if let startError = self.startSessionIfNeeded(force: true) {
+ self.writer.cancelWriting()
+ continuation.resume(throwing: startError)
+ return
+ }
+ }
+
+ guard self.sessionStarted, self.videoSampleCount > 0 else {
self.writer.cancelWriting()
- continuation.resume()
+ continuation.resume(throwing: ScreenRecorder.RecorderError.noVideoFrames)
+ return
+ }
+
+ if self.writer.status == .failed {
+ let detail = self.writer.error?.localizedDescription ?? "Writer failed during capture."
+ continuation.resume(throwing: ScreenRecorder.RecorderError.finalizeFailed(detail))
return
}
- self.writer.finishWriting { continuation.resume() }
+
+ self.videoInput.markAsFinished()
+ self.audioInput?.markAsFinished()
+
+ self.writer.finishWriting {
+ if self.writer.status == .completed {
+ continuation.resume()
+ } else {
+ let detail = self.writer.error?.localizedDescription
+ ?? "Unknown writer error (status \(self.writer.status.rawValue))."
+ continuation.resume(throwing: ScreenRecorder.RecorderError.finalizeFailed(detail))
+ }
+ }
}
}
}
@@ -249,17 +469,118 @@ private final class LegacyRecordingWriter: NSObject, SCStreamOutput, @unchecked
guard !hasFinished else { return }
switch type {
case .screen:
+ #if DEBUG
+ receivedVideoSampleCount += 1
+ #endif
if !sessionStarted {
- writer.startWriting()
- writer.startSession(atSourceTime: sampleBuffer.presentationTimeStamp)
- sessionStarted = true
+ pendingVideo.append(sampleBuffer)
+ let force = pendingVideo.count >= maxVideoFramesBeforeForceStart
+ if let error = startSessionIfNeeded(force: force) {
+ writerFailure = error
+ hasFinished = true
+ writer.cancelWriting()
+ NSLog("Parcel recording writer failed to start: %@", error.localizedDescription)
+ }
+ return
}
- if videoInput.isReadyForMoreMediaData { videoInput.append(sampleBuffer) }
+ appendVideo(sampleBuffer)
+
case .audio:
- guard sessionStarted else { return }
- if audioInput.isReadyForMoreMediaData { audioInput.append(sampleBuffer) }
+ if !sessionStarted {
+ sawAudioBeforeStart = true
+ pendingAudio.append(sampleBuffer)
+ if let error = startSessionIfNeeded(force: false) {
+ writerFailure = error
+ hasFinished = true
+ writer.cancelWriting()
+ NSLog("Parcel recording writer failed to start: %@", error.localizedDescription)
+ }
+ return
+ }
+ appendAudio(sampleBuffer)
+
default:
break
}
}
+
+ /// Starts the writer once we have video, optionally with audio if samples arrived first.
+ private func startSessionIfNeeded(force: Bool) -> Error? {
+ guard !sessionStarted else { return nil }
+ guard let firstVideo = pendingVideo.first else {
+ return force ? ScreenRecorder.RecorderError.noVideoFrames : nil
+ }
+ // Wait for an early audio sample unless forced (finish or frame budget).
+ if !force && !sawAudioBeforeStart {
+ return nil
+ }
+
+ if sawAudioBeforeStart {
+ addAudioInputIfPossible()
+ }
+
+ guard writer.startWriting() else {
+ return writer.error ?? ScreenRecorder.RecorderError.finalizeFailed("startWriting failed.")
+ }
+ writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(firstVideo))
+ sessionStarted = true
+
+ let videoBuffers = pendingVideo
+ let audioBuffers = pendingAudio
+ pendingVideo.removeAll(keepingCapacity: false)
+ pendingAudio.removeAll(keepingCapacity: false)
+
+ for buffer in videoBuffers {
+ appendVideo(buffer)
+ }
+ for buffer in audioBuffers {
+ appendAudio(buffer)
+ }
+ return nil
+ }
+
+ private func addAudioInputIfPossible() {
+ guard audioInput == nil else { return }
+ let input = AVAssetWriterInput(
+ mediaType: .audio,
+ outputSettings: [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVSampleRateKey: 48_000,
+ AVNumberOfChannelsKey: monoAudio ? 1 : 2,
+ AVEncoderBitRateKey: 160_000,
+ ]
+ )
+ input.expectsMediaDataInRealTime = true
+ guard writer.canAdd(input) else {
+ sawAudioBeforeStart = false
+ pendingAudio.removeAll(keepingCapacity: false)
+ return
+ }
+ writer.add(input)
+ audioInput = input
+ }
+
+ private func appendVideo(_ sampleBuffer: CMSampleBuffer) {
+ guard writer.status == .writing,
+ videoInput.isReadyForMoreMediaData,
+ let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
+ else { return }
+
+ let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+ if pixelBufferAdaptor.append(pixelBuffer, withPresentationTime: pts) {
+ videoSampleCount += 1
+ } else if writer.status == .failed {
+ writerFailure = writer.error ?? ScreenRecorder.RecorderError.finalizeFailed("Video append failed.")
+ NSLog("Parcel recording video append failed: %@", writerFailure?.localizedDescription ?? "unknown")
+ }
+ }
+
+ private func appendAudio(_ sampleBuffer: CMSampleBuffer) {
+ guard let audioInput, writer.status == .writing else { return }
+ guard audioInput.isReadyForMoreMediaData else { return }
+ if !audioInput.append(sampleBuffer), writer.status == .failed {
+ writerFailure = writer.error ?? ScreenRecorder.RecorderError.finalizeFailed("Audio append failed.")
+ NSLog("Parcel recording audio append failed: %@", writerFailure?.localizedDescription ?? "unknown")
+ }
+ }
}
diff --git a/Sources/Parcel/Recording/WebcamPiPController.swift b/Sources/Parcel/Recording/WebcamPiPController.swift
new file mode 100644
index 0000000..951e556
--- /dev/null
+++ b/Sources/Parcel/Recording/WebcamPiPController.swift
@@ -0,0 +1,136 @@
+import AppKit
+@preconcurrency import AVFoundation
+import SwiftUI
+
+enum WebcamPiPGeometry {
+ static let diameter: CGFloat = 168
+ static let margin: CGFloat = 24
+
+ static func bottomRightOrigin(
+ screen: CGRect,
+ windowSize: CGSize,
+ margin: CGFloat = Self.margin
+ ) -> CGPoint {
+ CGPoint(
+ x: screen.maxX - windowSize.width - margin,
+ y: screen.minY + margin
+ )
+ }
+}
+
+/// Circular, draggable webcam preview that floats above other windows during recording.
+@MainActor
+final class WebcamPiPController: NSObject {
+
+ private var window: NSPanel?
+ private var session: AVCaptureSession?
+ private var previewLayer: AVCaptureVideoPreviewLayer?
+
+ func start() {
+ guard RecordingPreferences.showsWebcam else { return }
+ stop()
+
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ present()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ Task { @MainActor in
+ if granted { self?.present() }
+ }
+ }
+ default:
+ NSLog("Parcel: camera access denied — webcam PiP skipped")
+ }
+ }
+
+ func stop() {
+ session?.stopRunning()
+ session = nil
+ previewLayer = nil
+ window?.orderOut(nil)
+ window = nil
+ }
+
+ // MARK: Private
+
+ private func present() {
+ guard let device = AVCaptureDevice.default(for: .video) else {
+ NSLog("Parcel: no camera available for webcam PiP")
+ return
+ }
+
+ let session = AVCaptureSession()
+ session.sessionPreset = .medium
+ do {
+ let input = try AVCaptureDeviceInput(device: device)
+ guard session.canAddInput(input) else { return }
+ session.addInput(input)
+ } catch {
+ NSLog("Parcel: webcam PiP failed — \(error)")
+ return
+ }
+
+ let diameter = WebcamPiPGeometry.diameter
+ let panel = NSPanel(
+ contentRect: NSRect(x: 0, y: 0, width: diameter, height: diameter),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = true
+ panel.level = .floating
+ panel.isMovableByWindowBackground = true
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.isReleasedWhenClosed = false
+ panel.hidesOnDeactivate = false
+
+ let container = WebcamPiPContainerView(frame: NSRect(origin: .zero, size: CGSize(width: diameter, height: diameter)))
+ let preview = AVCaptureVideoPreviewLayer(session: session)
+ preview.videoGravity = .resizeAspectFill
+ preview.cornerRadius = diameter / 2
+ preview.masksToBounds = true
+ preview.frame = container.bounds
+ container.wantsLayer = true
+ container.layer?.cornerRadius = diameter / 2
+ container.layer?.masksToBounds = true
+ container.layer?.borderWidth = 3
+ container.layer?.borderColor = NSColor.white.withAlphaComponent(0.85).cgColor
+ container.layer?.addSublayer(preview)
+ panel.contentView = container
+
+ self.session = session
+ self.previewLayer = preview
+ self.window = panel
+
+ positionBottomRight()
+ panel.orderFrontRegardless()
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ session.startRunning()
+ }
+ }
+
+ private func positionBottomRight() {
+ guard let window else { return }
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
+ let origin = WebcamPiPGeometry.bottomRightOrigin(screen: screen, windowSize: window.frame.size)
+ window.setFrameOrigin(origin)
+ }
+}
+
+/// Resizes the preview layer when the panel is dragged/resized.
+private final class WebcamPiPContainerView: NSView {
+ override func layout() {
+ super.layout()
+ layer?.sublayers?.forEach { sub in
+ if sub is AVCaptureVideoPreviewLayer {
+ sub.frame = bounds
+ sub.cornerRadius = min(bounds.width, bounds.height) / 2
+ }
+ }
+ layer?.cornerRadius = min(bounds.width, bounds.height) / 2
+ }
+}
diff --git a/Sources/Parcel/Resources/Info.plist b/Sources/Parcel/Resources/Info.plist
index bee2b95..b08b92d 100644
--- a/Sources/Parcel/Resources/Info.plist
+++ b/Sources/Parcel/Resources/Info.plist
@@ -30,6 +30,8 @@
NSMicrophoneUsageDescriptionParcel can include microphone audio in screen recordings when you start a recording.
+ NSCameraUsageDescription
+ Parcel can show your webcam as a picture-in-picture overlay while recording your screen.NSPrincipalClassNSApplicationNSHumanReadableCopyright
@@ -37,6 +39,38 @@
SUFeedURLhttps://parcel.parable.dev/appcast.xmlSUPublicEDKey
- REPLACE_WITH_SPARKLE_EDDSA_PUBLIC_KEY
+ KzMPoJWvyEPSZnLcyE6AcaMU1HpBrLRZnP8xs5XdpHI=
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ dev.parable.Parcel
+ CFBundleURLSchemes
+
+ parcel
+
+
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ dev.parable.parcel-project
+ UTTypeDescription
+ Parcel Project
+ UTTypeConformsTo
+
+ public.data
+ public.directory
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ parcel
+
+
+
+
diff --git a/Sources/Parcel/Resources/Parcel.Debug.entitlements b/Sources/Parcel/Resources/Parcel.Debug.entitlements
new file mode 100644
index 0000000..080e1e0
--- /dev/null
+++ b/Sources/Parcel/Resources/Parcel.Debug.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.files.user-selected.read-write
+
+
+
diff --git a/Sources/Parcel/Resources/Parcel.entitlements b/Sources/Parcel/Resources/Parcel.entitlements
index 19afff1..83fbc68 100644
--- a/Sources/Parcel/Resources/Parcel.entitlements
+++ b/Sources/Parcel/Resources/Parcel.entitlements
@@ -6,5 +6,14 @@
com.apple.security.files.user-selected.read-write
+
+ com.apple.security.network.client
+
+
+ com.apple.security.device.audio-input
+
+
+ com.apple.security.device.camera
+
diff --git a/Sources/Parcel/Support/AppIdentity.swift b/Sources/Parcel/Support/AppIdentity.swift
index b036ac0..79a1ef9 100644
--- a/Sources/Parcel/Support/AppIdentity.swift
+++ b/Sources/Parcel/Support/AppIdentity.swift
@@ -37,6 +37,32 @@ enum AppIdentity {
defaults.set(true, forKey: migrationCompletedKey)
}
+ /// Debug/non-sandbox builds read host UserDefaults; copy keys from the sandbox container once.
+ static func migrateSandboxContainerDefaultsIfNeeded() {
+ let defaults = UserDefaults.standard
+ let containerPlist = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Containers/\(bundleIdentifier)/Data/Library/Preferences/\(bundleIdentifier).plist")
+ guard FileManager.default.fileExists(atPath: containerPlist.path),
+ let container = NSDictionary(contentsOf: containerPlist) as? [String: Any] else { return }
+
+ let keysToMigrate = [
+ "dev.parable.onboardingCompleted",
+ "\(defaultsPrefix).hotkey.keyCode",
+ "\(defaultsPrefix).hotkey.modifiers",
+ "\(defaultsPrefix).upload.supabaseURL",
+ "\(defaultsPrefix).upload.anonKey",
+ "\(defaultsPrefix).upload.bucket",
+ "\(defaultsPrefix).upload.publicBase",
+ "\(defaultsPrefix).recording.fps",
+ "\(defaultsPrefix).brandKits",
+ ]
+ for key in keysToMigrate {
+ if defaults.object(forKey: key) == nil, let value = container[key] {
+ defaults.set(value, forKey: key)
+ }
+ }
+ }
+
private static func migrateHistoryDirectoryIfNeeded() {
let fileManager = FileManager.default
guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
diff --git a/Sources/Parcel/Support/CapturePreferences.swift b/Sources/Parcel/Support/CapturePreferences.swift
new file mode 100644
index 0000000..ef9a7b6
--- /dev/null
+++ b/Sources/Parcel/Support/CapturePreferences.swift
@@ -0,0 +1,245 @@
+import CoreGraphics
+import Foundation
+
+/// User-tunable Capture / Overlay / after-Capture behavior.
+enum CapturePreferences {
+ private static let prefix = AppIdentity.defaultsPrefix
+
+ /// After Capture, show the floating Quick Access panel instead of opening the Editor immediately.
+ static var useQuickAccess: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).capture.useQuickAccess") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.useQuickAccess") }
+ }
+
+ /// Seconds before Quick Access auto-closes (0 = stay until dismissed).
+ static var quickAccessAutoCloseSeconds: Double {
+ get {
+ let stored = UserDefaults.standard.double(forKey: "\(prefix).capture.quickAccessAutoClose")
+ return stored > 0 ? stored : 0
+ }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.quickAccessAutoClose") }
+ }
+
+ /// Prompt for a file name before Save / auto-save after Capture.
+ static var askForName: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.askForName") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.askForName") }
+ }
+
+ // MARK: After-Capture actions (can combine)
+
+ static var afterCaptureCopy: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.after.copy") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.after.copy") }
+ }
+
+ static var afterCaptureOpenEditor: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.after.editor") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.after.editor") }
+ }
+
+ static var afterCapturePin: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.after.pin") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.after.pin") }
+ }
+
+ static var afterCaptureUpload: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.after.upload") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.after.upload") }
+ }
+
+ static var afterCaptureSave: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.after.save") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.after.save") }
+ }
+
+ /// Play a shutter sound when a Capture completes.
+ static var playShutterSound: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).capture.playShutterSound") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.playShutterSound") }
+ }
+
+ /// Convert exported pixels to sRGB before writing.
+ static var convertToSRGB: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.convertToSRGB") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.convertToSRGB") }
+ }
+
+ /// Allow `parcel://` URL scheme automation.
+ static var urlSchemeEnabled: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).capture.urlSchemeEnabled") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.urlSchemeEnabled") }
+ }
+
+ /// Filename template tokens: `{date}`, `{time}`, `{month}`, `{index}`, `{app}`, `{window}`.
+ static var fileNameTemplate: String {
+ get {
+ UserDefaults.standard.string(forKey: "\(prefix).capture.fileNameTemplate")
+ ?? "Parcel {date} at {time}"
+ }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.fileNameTemplate") }
+ }
+
+ static var fileNameIndex: Int {
+ get { max(0, UserDefaults.standard.integer(forKey: "\(prefix).capture.fileNameIndex")) }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.fileNameIndex") }
+ }
+
+ /// When true, OCR joins recognized lines with spaces instead of newlines.
+ static var ocrStripLineBreaks: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.ocrStripLineBreaks") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.ocrStripLineBreaks") }
+ }
+
+ /// Downscale Retina Captures to 1× points for smaller files / sharing.
+ static var scaleDownRetina: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.scaleDownRetina") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.scaleDownRetina") }
+ }
+
+ /// Draw full-screen crosshairs under the cursor while selecting.
+ static var showCrosshair: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).capture.showCrosshair") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.showCrosshair") }
+ }
+
+ /// Show a loupe magnifier near the cursor while selecting.
+ static var showMagnifier: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).capture.showMagnifier") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.showMagnifier") }
+ }
+
+ /// Hide Finder desktop icons while capturing / recording (best-effort; may require non-sandbox).
+ static var hideDesktopIcons: Bool {
+ get { UserDefaults.standard.bool(forKey: "\(prefix).capture.hideDesktopIcons") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.hideDesktopIcons") }
+ }
+
+ /// Show the All-in-One mode strip on the Selection Overlay.
+ static var showAllInOneBar: Bool {
+ get { UserDefaults.standard.object(forKey: "\(prefix).capture.showAllInOneBar") as? Bool ?? true }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.showAllInOneBar") }
+ }
+
+ // MARK: Previous area
+
+ static var hasPreviousArea: Bool {
+ lastSelectionDisplayID != 0 && lastSelectionWidth > 1 && lastSelectionHeight > 1
+ }
+
+ static var lastSelectionDisplayID: CGDirectDisplayID {
+ get { CGDirectDisplayID(UserDefaults.standard.integer(forKey: "\(prefix).capture.last.displayID")) }
+ set { UserDefaults.standard.set(Int(newValue), forKey: "\(prefix).capture.last.displayID") }
+ }
+
+ static var lastSelectionX: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.last.x") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.last.x") }
+ }
+
+ static var lastSelectionY: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.last.y") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.last.y") }
+ }
+
+ static var lastSelectionWidth: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.last.width") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.last.width") }
+ }
+
+ static var lastSelectionHeight: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.last.height") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.last.height") }
+ }
+
+ static var lastSelectionRect: CGRect {
+ CGRect(
+ x: lastSelectionX,
+ y: lastSelectionY,
+ width: lastSelectionWidth,
+ height: lastSelectionHeight
+ )
+ }
+
+ static func rememberSelection(_ result: SelectionResult) {
+ lastSelectionDisplayID = result.screen.id
+ lastSelectionX = result.rectInPoints.origin.x
+ lastSelectionY = result.rectInPoints.origin.y
+ lastSelectionWidth = result.rectInPoints.width
+ lastSelectionHeight = result.rectInPoints.height
+ }
+
+ // MARK: Last recording area
+
+ static var hasPreviousRecordingArea: Bool {
+ lastRecordingDisplayID != 0 && lastRecordingWidth > 1 && lastRecordingHeight > 1
+ }
+
+ static var lastRecordingDisplayID: CGDirectDisplayID {
+ get { CGDirectDisplayID(UserDefaults.standard.integer(forKey: "\(prefix).capture.lastRec.displayID")) }
+ set { UserDefaults.standard.set(Int(newValue), forKey: "\(prefix).capture.lastRec.displayID") }
+ }
+
+ static var lastRecordingX: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.lastRec.x") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.lastRec.x") }
+ }
+
+ static var lastRecordingY: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.lastRec.y") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.lastRec.y") }
+ }
+
+ static var lastRecordingWidth: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.lastRec.width") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.lastRec.width") }
+ }
+
+ static var lastRecordingHeight: Double {
+ get { UserDefaults.standard.double(forKey: "\(prefix).capture.lastRec.height") }
+ set { UserDefaults.standard.set(newValue, forKey: "\(prefix).capture.lastRec.height") }
+ }
+
+ static func rememberRecordingSelection(displayID: CGDirectDisplayID, rect: CGRect) {
+ lastRecordingDisplayID = displayID
+ lastRecordingX = rect.origin.x
+ lastRecordingY = rect.origin.y
+ lastRecordingWidth = rect.width
+ lastRecordingHeight = rect.height
+ }
+}
+
+/// Builds Capture file names from the user template.
+enum CaptureFileName {
+ static func make(
+ extension ext: String,
+ appName: String? = nil,
+ windowTitle: String? = nil,
+ date: Date = Date()
+ ) -> String {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd"
+ let timeFormatter = DateFormatter()
+ timeFormatter.dateFormat = "HH.mm.ss"
+ let monthFormatter = DateFormatter()
+ monthFormatter.dateFormat = "yyyy-MM"
+
+ var result = CapturePreferences.fileNameTemplate
+ result = result.replacingOccurrences(of: "{date}", with: dateFormatter.string(from: date))
+ result = result.replacingOccurrences(of: "{time}", with: timeFormatter.string(from: date))
+ result = result.replacingOccurrences(of: "{month}", with: monthFormatter.string(from: date))
+ result = result.replacingOccurrences(of: "{index}", with: String(CapturePreferences.fileNameIndex))
+ result = result.replacingOccurrences(of: "{app}", with: sanitize(appName ?? "App"))
+ result = result.replacingOccurrences(of: "{window}", with: sanitize(windowTitle ?? "Window"))
+ CapturePreferences.fileNameIndex += 1
+
+ let illegal = CharacterSet(charactersIn: "/:\\?%*|\"<>")
+ let cleaned = result.components(separatedBy: illegal).joined(separator: "-")
+ return "\(cleaned).\(ext)"
+ }
+
+ private static func sanitize(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? "Untitled" : trimmed
+ }
+}
diff --git a/Sources/Parcel/Support/CapturePrintPayload.swift b/Sources/Parcel/Support/CapturePrintPayload.swift
new file mode 100644
index 0000000..09f8d73
--- /dev/null
+++ b/Sources/Parcel/Support/CapturePrintPayload.swift
@@ -0,0 +1,25 @@
+import AppKit
+
+enum CapturePrintPayload {
+ static func printableView(for image: NSImage) -> NSImageView {
+ let view = NSImageView(image: image)
+ view.frame = NSRect(origin: .zero, size: image.size)
+ return view
+ }
+
+ static func printInfo(from base: NSPrintInfo = .shared) -> NSPrintInfo {
+ let info = (base.copy() as? NSPrintInfo) ?? NSPrintInfo()
+ info.horizontalPagination = .fit
+ info.verticalPagination = .fit
+ return info
+ }
+
+ static func printOperation(for image: NSImage, showsPrintPanel: Bool = true) -> NSPrintOperation {
+ let operation = NSPrintOperation(
+ view: printableView(for: image),
+ printInfo: printInfo()
+ )
+ operation.showsPrintPanel = showsPrintPanel
+ return operation
+ }
+}
diff --git a/Sources/Parcel/Support/CaptureSharePayload.swift b/Sources/Parcel/Support/CaptureSharePayload.swift
new file mode 100644
index 0000000..1025a92
--- /dev/null
+++ b/Sources/Parcel/Support/CaptureSharePayload.swift
@@ -0,0 +1,28 @@
+import AppKit
+
+struct CaptureShareAnchor {
+ let rect: NSRect
+ let view: NSView
+ let preferredEdge: NSRectEdge
+}
+
+enum CaptureSharePayload {
+ static let preferredEdge: NSRectEdge = .minY
+
+ static func items(for image: NSImage) -> [Any] {
+ [image]
+ }
+
+ static func picker(for image: NSImage) -> NSSharingServicePicker {
+ NSSharingServicePicker(items: items(for: image))
+ }
+
+ static func anchor(in window: NSWindow?) -> CaptureShareAnchor? {
+ guard let content = window?.contentView else { return nil }
+ return CaptureShareAnchor(
+ rect: content.bounds,
+ view: content,
+ preferredEdge: preferredEdge
+ )
+ }
+}
diff --git a/Sources/Parcel/Support/DesktopIconHider.swift b/Sources/Parcel/Support/DesktopIconHider.swift
new file mode 100644
index 0000000..c499867
--- /dev/null
+++ b/Sources/Parcel/Support/DesktopIconHider.swift
@@ -0,0 +1,83 @@
+import AppKit
+import Foundation
+
+/// Best-effort hide/show of Finder desktop icons for a cleaner Capture or recording.
+/// Uses `defaults` + `killall Finder`. Works in non-sandboxed Debug builds; Release sandbox
+/// may block the child processes — failures are ignored so Capture still proceeds.
+@MainActor
+enum DesktopIconHider {
+ private static var sessionHidden = false
+ private static var previousCreateDesktop: Bool?
+
+ /// Hide desktop icons if the preference is on and they are currently visible.
+ static func beginSessionIfNeeded() {
+ guard CapturePreferences.hideDesktopIcons else { return }
+ guard !sessionHidden else { return }
+ previousCreateDesktop = readCreateDesktop()
+ guard previousCreateDesktop != false else { return }
+ if setCreateDesktop(false) {
+ sessionHidden = true
+ // Give Finder a beat to redraw without icons before freeze.
+ Thread.sleep(forTimeInterval: 0.35)
+ }
+ }
+
+ /// Restore icons if this session hid them.
+ static func endSession() {
+ guard sessionHidden else { return }
+ sessionHidden = false
+ let restore = previousCreateDesktop ?? true
+ previousCreateDesktop = nil
+ _ = setCreateDesktop(restore)
+ }
+
+ private static func readCreateDesktop() -> Bool {
+ let task = Process()
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
+ task.arguments = ["read", "com.apple.finder", "CreateDesktop"]
+ let pipe = Pipe()
+ task.standardOutput = pipe
+ task.standardError = Pipe()
+ do {
+ try task.run()
+ task.waitUntilExit()
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if text == "0" || text.lowercased() == "false" { return false }
+ return true
+ } catch {
+ return true
+ }
+ }
+
+ @discardableResult
+ private static func setCreateDesktop(_ visible: Bool) -> Bool {
+ let write = Process()
+ write.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
+ write.arguments = [
+ "write", "com.apple.finder", "CreateDesktop", "-bool", visible ? "true" : "false",
+ ]
+ write.standardOutput = Pipe()
+ write.standardError = Pipe()
+ do {
+ try write.run()
+ write.waitUntilExit()
+ guard write.terminationStatus == 0 else { return false }
+ } catch {
+ return false
+ }
+
+ let kill = Process()
+ kill.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
+ kill.arguments = ["Finder"]
+ kill.standardOutput = Pipe()
+ kill.standardError = Pipe()
+ do {
+ try kill.run()
+ kill.waitUntilExit()
+ return true
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/Sources/Parcel/Support/ShutterSoundFeedback.swift b/Sources/Parcel/Support/ShutterSoundFeedback.swift
new file mode 100644
index 0000000..7564a4b
--- /dev/null
+++ b/Sources/Parcel/Support/ShutterSoundFeedback.swift
@@ -0,0 +1,15 @@
+import AudioToolbox
+
+enum ShutterSoundFeedback {
+ static let captureCompleteSoundID: SystemSoundID = 1108
+
+ @discardableResult
+ static func playIfEnabled(
+ _ enabled: Bool,
+ player: (SystemSoundID) -> Void = AudioServicesPlaySystemSound
+ ) -> Bool {
+ guard enabled else { return false }
+ player(captureCompleteSoundID)
+ return true
+ }
+}
diff --git a/Sources/Parcel/Upload/UploadService.swift b/Sources/Parcel/Upload/UploadService.swift
index dc3f0f6..262e48c 100644
--- a/Sources/Parcel/Upload/UploadService.swift
+++ b/Sources/Parcel/Upload/UploadService.swift
@@ -22,7 +22,13 @@ enum UploadError: LocalizedError {
/// Uploads rendered Capture bytes to Supabase Storage via the REST API.
enum UploadService {
- static func uploadPNG(data: Data, fileName: String) async throws -> URL {
+ typealias DataLoader = (URLRequest) async throws -> (Data, URLResponse)
+
+ static func uploadPNG(
+ data: Data,
+ fileName: String,
+ dataLoader: DataLoader = defaultDataLoader
+ ) async throws -> URL {
guard UploadPreferences.isConfigured else { throw UploadError.notConfigured }
let base = UploadPreferences.supabaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
@@ -39,7 +45,7 @@ enum UploadService {
request.setValue("true", forHTTPHeaderField: "x-upsert")
request.httpBody = data
- let (responseData, response) = try await URLSession.shared.data(for: request)
+ let (responseData, response) = try await dataLoader(request)
guard let http = response as? HTTPURLResponse else { throw UploadError.invalidResponse }
guard (200...299).contains(http.statusCode) else {
let body = String(data: responseData, encoding: .utf8) ?? ""
@@ -59,4 +65,8 @@ enum UploadService {
}
return publicURL
}
+
+ private static func defaultDataLoader(_ request: URLRequest) async throws -> (Data, URLResponse) {
+ try await URLSession.shared.data(for: request)
+ }
}
diff --git a/Sources/Parcel/Vision/OCRTextFormatter.swift b/Sources/Parcel/Vision/OCRTextFormatter.swift
new file mode 100644
index 0000000..eebf2ce
--- /dev/null
+++ b/Sources/Parcel/Vision/OCRTextFormatter.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+enum OCRTextFormatter {
+ static func outputText(from recognizedText: String, stripLineBreaks: Bool) -> String {
+ let trimmedText = recognizedText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard stripLineBreaks else { return trimmedText }
+
+ return trimmedText
+ .components(separatedBy: .newlines)
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ }
+}
diff --git a/Sources/Parcel/Vision/TranslationService.swift b/Sources/Parcel/Vision/TranslationService.swift
index 5114431..e10dc0c 100644
--- a/Sources/Parcel/Vision/TranslationService.swift
+++ b/Sources/Parcel/Vision/TranslationService.swift
@@ -12,7 +12,7 @@ enum TranslationService {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
guard #available(macOS 26.0, *) else { return nil }
- #if canImport(Translation)
+ #if canImport(Translation) && compiler(>=6.2)
guard let source = detectLanguage(text) else { return nil }
do {
let session = TranslationSession(installedSource: source, target: target)
@@ -30,7 +30,7 @@ enum TranslationService {
static var isAvailable: Bool {
if #available(macOS 26.0, *) {
- #if canImport(Translation)
+ #if canImport(Translation) && compiler(>=6.2)
return true
#endif
}
diff --git a/Sources/Parcel/Vision/VisionAnalyzer.swift b/Sources/Parcel/Vision/VisionAnalyzer.swift
index cb75a87..c6c0708 100644
--- a/Sources/Parcel/Vision/VisionAnalyzer.swift
+++ b/Sources/Parcel/Vision/VisionAnalyzer.swift
@@ -70,14 +70,14 @@ enum VisionAnalyzer {
VisionFaceObservation(rect: captureRect(fromVision: $0.boundingBox, pointSize: pointSize))
}
let qrCodes = (barcodeRequest.results ?? []).compactMap { observation -> VisionQRCode? in
- guard observation.symbology == .QR, let payload = observation.payloadStringValue else { return nil }
+ guard observation.symbology == .qr, let payload = observation.payloadStringValue else { return nil }
return VisionQRCode(payload: payload)
}
return VisionAnalysis(text: text, faces: faces, qrCodes: qrCodes)
}.value
}
- private static func captureRect(fromVision rect: CGRect, pointSize: CGSize) -> CGRect {
+ static func captureRect(fromVision rect: CGRect, pointSize: CGSize) -> CGRect {
CGRect(
x: rect.minX * pointSize.width,
y: (1 - rect.maxY) * pointSize.height,
diff --git a/Tests/ParcelTests/ParcelModelTests.swift b/Tests/ParcelTests/ParcelModelTests.swift
new file mode 100644
index 0000000..e53c815
--- /dev/null
+++ b/Tests/ParcelTests/ParcelModelTests.swift
@@ -0,0 +1,1925 @@
+import AppKit
+import AVFoundation
+import AudioToolbox
+import Carbon.HIToolbox
+import CoreImage
+import CoreVideo
+import ImageIO
+import ScreenCaptureKit
+import UniformTypeIdentifiers
+import XCTest
+@testable import Parcel
+
+@MainActor
+final class ParcelModelTests: XCTestCase {
+ func testOutputFormatsMatchClaimedReleaseFormats() {
+ XCTAssertEqual(OutputFormat.allCases.map(\.rawValue), ["png", "jpeg", "heic", "tiff", "webp"])
+ XCTAssertEqual(OutputFormat.jpeg.fileExtension, "jpg")
+ XCTAssertEqual(OutputFormat.webp.fileExtension, "webp")
+ XCTAssertEqual(OutputFormat.webp.contentType.preferredFilenameExtension, "webp")
+ XCTAssertFalse(OutputFormat.webp.usesImageIO)
+ XCTAssertNil(OutputFormat.webp.bitmapType)
+ }
+
+ func testToolInventoryMatchesClaimedToolbar() {
+ XCTAssertEqual(
+ Tool.allCases.map(\.label),
+ [
+ "Select", "Arrow", "Rectangle", "Ellipse", "Text", "Pencil", "Censor",
+ "Number", "Stamp", "Highlighter", "Measure", "Spotlight", "Loupe", "Eyedropper",
+ ]
+ )
+ XCTAssertEqual(Tool.allCases.count, 14)
+ XCTAssertEqual(Tool.allCases.filter(\.isUtility).map(\.label), ["Loupe", "Eyedropper"])
+ XCTAssertEqual(ArrowStyle.allCases.map(\.label), ["Arrow", "Open Head", "Double", "Curved", "Elbow"])
+ XCTAssertEqual(CensorMode.allCases.map(\.label), ["Blur", "Pixelate", "Solid", "Erase"])
+ }
+
+ func testURLSchemeActionsCoverParityRoutes() throws {
+ XCTAssertEqual(
+ ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "parcel://capture/region"))),
+ .region
+ )
+ XCTAssertEqual(
+ ParcelURLRouter.action(
+ for: try XCTUnwrap(URL(string: "parcel://capture/region?x=10&y=20&w=30&h=40&display=99"))
+ ),
+ .area(CGRect(x: 10, y: 20, width: 30, height: 40), 99)
+ )
+ XCTAssertEqual(
+ ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "parcel://capture/previous"))),
+ .previous
+ )
+ XCTAssertEqual(
+ ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "parcel://capture/scroll"))),
+ .scroll
+ )
+ XCTAssertEqual(
+ ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "parcel://open/clipboard"))),
+ .openClipboard
+ )
+ XCTAssertEqual(
+ ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "parcel://restore"))),
+ .restoreRecentlyClosed
+ )
+ XCTAssertEqual(
+ ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "parcel://overlays/hide"))),
+ .hideOverlays
+ )
+ XCTAssertNil(ParcelURLRouter.action(for: try XCTUnwrap(URL(string: "https://parcel.parable.dev"))))
+ }
+
+ func testAfterCapturePlanCoversPreferenceMatrixAndForcedIntents() {
+ let quickAccessDefaults = AfterCapturePreferences(
+ useQuickAccess: true,
+ copy: false,
+ upload: false,
+ save: false,
+ pin: false,
+ openEditor: false
+ )
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: quickAccessDefaults, intent: .standard),
+ AfterCapturePlan(showQuickAccess: true)
+ )
+
+ let editorFallback = AfterCapturePreferences(
+ useQuickAccess: false,
+ copy: false,
+ upload: false,
+ save: false,
+ pin: false,
+ openEditor: false
+ )
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: editorFallback, intent: .standard),
+ AfterCapturePlan(openEditor: true)
+ )
+
+ let allActions = AfterCapturePreferences(
+ useQuickAccess: true,
+ copy: true,
+ upload: true,
+ save: true,
+ pin: true,
+ openEditor: true
+ )
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: allActions, intent: .standard),
+ AfterCapturePlan(copy: true, upload: true, save: true, pin: true, openEditor: true)
+ )
+
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: quickAccessDefaults, intent: .forceCopy),
+ AfterCapturePlan(copy: true)
+ )
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: quickAccessDefaults, intent: .forceSave),
+ AfterCapturePlan(save: true)
+ )
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: quickAccessDefaults, intent: .forcePin),
+ AfterCapturePlan(pin: true)
+ )
+ XCTAssertEqual(
+ AfterCapturePlan.make(preferences: quickAccessDefaults, intent: .forceEditor),
+ AfterCapturePlan(openEditor: true)
+ )
+ }
+
+ func testCapturePreferencesPersistDocumentedDefaultsAndToggles() {
+ let prefix = AppIdentity.defaultsPrefix
+ let keys = [
+ "\(prefix).capture.useQuickAccess",
+ "\(prefix).capture.quickAccessAutoClose",
+ "\(prefix).capture.askForName",
+ "\(prefix).capture.after.copy",
+ "\(prefix).capture.after.editor",
+ "\(prefix).capture.after.pin",
+ "\(prefix).capture.after.upload",
+ "\(prefix).capture.after.save",
+ "\(prefix).capture.playShutterSound",
+ "\(prefix).capture.convertToSRGB",
+ "\(prefix).capture.urlSchemeEnabled",
+ "\(prefix).capture.fileNameTemplate",
+ "\(prefix).capture.fileNameIndex",
+ "\(prefix).capture.ocrStripLineBreaks",
+ "\(prefix).capture.scaleDownRetina",
+ "\(prefix).capture.showCrosshair",
+ "\(prefix).capture.showMagnifier",
+ "\(prefix).capture.hideDesktopIcons",
+ "\(prefix).capture.showAllInOneBar",
+ ]
+ let defaults = UserDefaults.standard
+ let previousValues: [(String, Any?)] = keys.map { ($0, defaults.object(forKey: $0)) }
+ defer {
+ for (key, value) in previousValues {
+ if let value {
+ defaults.set(value, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ }
+
+ keys.forEach { defaults.removeObject(forKey: $0) }
+
+ XCTAssertTrue(CapturePreferences.useQuickAccess)
+ XCTAssertEqual(CapturePreferences.quickAccessAutoCloseSeconds, 0)
+ XCTAssertFalse(CapturePreferences.askForName)
+ XCTAssertFalse(CapturePreferences.afterCaptureCopy)
+ XCTAssertFalse(CapturePreferences.afterCaptureOpenEditor)
+ XCTAssertFalse(CapturePreferences.afterCapturePin)
+ XCTAssertFalse(CapturePreferences.afterCaptureUpload)
+ XCTAssertFalse(CapturePreferences.afterCaptureSave)
+ XCTAssertTrue(CapturePreferences.playShutterSound)
+ XCTAssertFalse(CapturePreferences.convertToSRGB)
+ XCTAssertTrue(CapturePreferences.urlSchemeEnabled)
+ XCTAssertEqual(CapturePreferences.fileNameTemplate, "Parcel {date} at {time}")
+ XCTAssertEqual(CapturePreferences.fileNameIndex, 0)
+ XCTAssertFalse(CapturePreferences.ocrStripLineBreaks)
+ XCTAssertFalse(CapturePreferences.scaleDownRetina)
+ XCTAssertTrue(CapturePreferences.showCrosshair)
+ XCTAssertTrue(CapturePreferences.showMagnifier)
+ XCTAssertFalse(CapturePreferences.hideDesktopIcons)
+ XCTAssertTrue(CapturePreferences.showAllInOneBar)
+
+ CapturePreferences.useQuickAccess = false
+ CapturePreferences.quickAccessAutoCloseSeconds = -5
+ CapturePreferences.askForName = true
+ CapturePreferences.afterCaptureCopy = true
+ CapturePreferences.afterCaptureOpenEditor = true
+ CapturePreferences.afterCapturePin = true
+ CapturePreferences.afterCaptureUpload = true
+ CapturePreferences.afterCaptureSave = true
+ CapturePreferences.playShutterSound = false
+ CapturePreferences.convertToSRGB = true
+ CapturePreferences.urlSchemeEnabled = false
+ CapturePreferences.fileNameTemplate = "Proof {index}"
+ CapturePreferences.fileNameIndex = -3
+ CapturePreferences.ocrStripLineBreaks = true
+ CapturePreferences.scaleDownRetina = true
+ CapturePreferences.showCrosshair = false
+ CapturePreferences.showMagnifier = false
+ CapturePreferences.hideDesktopIcons = true
+ CapturePreferences.showAllInOneBar = false
+
+ XCTAssertFalse(CapturePreferences.useQuickAccess)
+ XCTAssertEqual(CapturePreferences.quickAccessAutoCloseSeconds, 0)
+ XCTAssertTrue(CapturePreferences.askForName)
+ XCTAssertTrue(CapturePreferences.afterCaptureCopy)
+ XCTAssertTrue(CapturePreferences.afterCaptureOpenEditor)
+ XCTAssertTrue(CapturePreferences.afterCapturePin)
+ XCTAssertTrue(CapturePreferences.afterCaptureUpload)
+ XCTAssertTrue(CapturePreferences.afterCaptureSave)
+ XCTAssertFalse(CapturePreferences.playShutterSound)
+ XCTAssertTrue(CapturePreferences.convertToSRGB)
+ XCTAssertFalse(CapturePreferences.urlSchemeEnabled)
+ XCTAssertEqual(CapturePreferences.fileNameTemplate, "Proof {index}")
+ XCTAssertEqual(CapturePreferences.fileNameIndex, 0)
+ XCTAssertTrue(CapturePreferences.ocrStripLineBreaks)
+ XCTAssertTrue(CapturePreferences.scaleDownRetina)
+ XCTAssertFalse(CapturePreferences.showCrosshair)
+ XCTAssertFalse(CapturePreferences.showMagnifier)
+ XCTAssertTrue(CapturePreferences.hideDesktopIcons)
+ XCTAssertFalse(CapturePreferences.showAllInOneBar)
+ }
+
+ func testShutterSoundFeedbackHonorsPreferenceWithoutPlayingWhenDisabled() {
+ var playedIDs: [SystemSoundID] = []
+
+ XCTAssertFalse(ShutterSoundFeedback.playIfEnabled(false) { playedIDs.append($0) })
+ XCTAssertTrue(playedIDs.isEmpty)
+
+ XCTAssertTrue(ShutterSoundFeedback.playIfEnabled(true) { playedIDs.append($0) })
+ XCTAssertEqual(playedIDs, [ShutterSoundFeedback.captureCompleteSoundID])
+ XCTAssertEqual(ShutterSoundFeedback.captureCompleteSoundID, 1108)
+ }
+
+ func testQuickAccessShortcutMappingAndSwipeDiscardThreshold() {
+ let command: NSEvent.ModifierFlags = [.command]
+
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: "c", modifierFlags: command, keyCode: 8),
+ .copy
+ )
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: "s", modifierFlags: command, keyCode: 1),
+ .save
+ )
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: "w", modifierFlags: command, keyCode: 13),
+ .close
+ )
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: "u", modifierFlags: command, keyCode: 32),
+ .upload
+ )
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: "e", modifierFlags: command, keyCode: 14),
+ .annotate
+ )
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: "p", modifierFlags: command, keyCode: 35),
+ .printCapture
+ )
+ XCTAssertEqual(
+ QuickAccessShortcut.action(charactersIgnoringModifiers: nil, modifierFlags: [], keyCode: 53),
+ .close
+ )
+ XCTAssertNil(QuickAccessShortcut.action(charactersIgnoringModifiers: "c", modifierFlags: [], keyCode: 8))
+ XCTAssertNil(QuickAccessShortcut.action(charactersIgnoringModifiers: "x", modifierFlags: command, keyCode: 7))
+
+ XCTAssertFalse(QuickAccessSwipe.shouldDiscard(translationHeight: 80))
+ XCTAssertTrue(QuickAccessSwipe.shouldDiscard(translationHeight: 80.1))
+ }
+
+ func testRecentlyClosedCapturesRestoreNewestFirstAndPruneOldEntries() throws {
+ var stack = RecentlyClosedCaptures(limit: 3)
+ XCTAssertNil(stack.restore())
+
+ for width in [10, 20, 30, 40] {
+ stack.push(Capture(image: try makeTestImage(width: width, height: 5), scale: 1))
+ }
+
+ XCTAssertEqual(stack.count, 3)
+ XCTAssertEqual(stack.restore()?.image.width, 40)
+ XCTAssertEqual(stack.restore()?.image.width, 30)
+ XCTAssertEqual(stack.restore()?.image.width, 20)
+ XCTAssertNil(stack.restore())
+ }
+
+ func testClipboardCaptureReaderImportsImageAndRejectsEmptyPasteboard() throws {
+ let name = NSPasteboard.Name("dev.parable.ParcelTests.clipboard.\(UUID().uuidString)")
+ let pasteboard = NSPasteboard(name: name)
+ pasteboard.clearContents()
+ XCTAssertNil(ClipboardCaptureReader.capture(from: pasteboard, scale: 2))
+
+ let image = NSImage(cgImage: try makeTestImage(width: 12, height: 8), size: CGSize(width: 6, height: 4))
+ XCTAssertTrue(pasteboard.writeObjects([image]))
+
+ let capture = try XCTUnwrap(ClipboardCaptureReader.capture(from: pasteboard, scale: 2))
+ XCTAssertEqual(capture.scale, 2)
+ XCTAssertEqual(capture.image.width, 12)
+ XCTAssertEqual(capture.image.height, 8)
+ XCTAssertEqual(capture.pointSize.width, 6)
+ XCTAssertEqual(capture.pointSize.height, 4)
+ XCTAssertEqual(ClipboardCaptureReader.capture(from: pasteboard, scale: 0)?.scale, 1)
+
+ pasteboard.clearContents()
+ }
+
+ func testCapturePrintPayloadPreservesImageSizeAndFitPagination() throws {
+ let image = NSImage(cgImage: try makeTestImage(width: 18, height: 12), size: CGSize(width: 9, height: 6))
+ let view = CapturePrintPayload.printableView(for: image)
+ XCTAssertEqual(view.image, image)
+ XCTAssertEqual(view.frame.origin, .zero)
+ XCTAssertEqual(view.frame.size.width, 9)
+ XCTAssertEqual(view.frame.size.height, 6)
+
+ let base = NSPrintInfo()
+ base.horizontalPagination = .clip
+ base.verticalPagination = .clip
+ let info = CapturePrintPayload.printInfo(from: base)
+ XCTAssertEqual(info.horizontalPagination, .fit)
+ XCTAssertEqual(info.verticalPagination, .fit)
+ XCTAssertEqual(base.horizontalPagination, .clip)
+ XCTAssertEqual(base.verticalPagination, .clip)
+ }
+
+ func testCaptureSharePayloadUsesRenderedImageAndContentAnchor() throws {
+ let image = NSImage(cgImage: try makeTestImage(width: 22, height: 14), size: CGSize(width: 11, height: 7))
+ let items = CaptureSharePayload.items(for: image)
+ XCTAssertEqual(items.count, 1)
+ XCTAssertTrue((items[0] as? NSImage) === image)
+
+ XCTAssertNil(CaptureSharePayload.anchor(in: nil))
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 123, height: 45),
+ styleMask: [],
+ backing: .buffered,
+ defer: false
+ )
+ let content = NSView(frame: NSRect(x: 0, y: 0, width: 123, height: 45))
+ window.contentView = content
+
+ let anchor = try XCTUnwrap(CaptureSharePayload.anchor(in: window))
+ XCTAssertTrue(anchor.view === content)
+ XCTAssertEqual(anchor.rect, content.bounds)
+ XCTAssertEqual(anchor.preferredEdge, .minY)
+ }
+
+ func testSnapWindowPickerChoosesSmallestContainingWindow() {
+ let desktop = SnapWindow(
+ id: 1,
+ title: "Desktop",
+ appName: "Finder",
+ frameInScreen: CGRect(x: 0, y: 0, width: 600, height: 400)
+ )
+ let appWindow = SnapWindow(
+ id: 2,
+ title: "Document",
+ appName: "Parcel",
+ frameInScreen: CGRect(x: 100, y: 80, width: 300, height: 220)
+ )
+ let popover = SnapWindow(
+ id: 3,
+ title: "Popover",
+ appName: "Parcel",
+ frameInScreen: CGRect(x: 160, y: 120, width: 120, height: 90)
+ )
+
+ XCTAssertEqual(
+ SnapWindowPicker.frontmostWindow(at: CGPoint(x: 170, y: 130), windows: [desktop, appWindow, popover]),
+ popover
+ )
+ XCTAssertEqual(
+ SnapWindowPicker.frontmostWindow(at: CGPoint(x: 120, y: 100), windows: [desktop, appWindow, popover]),
+ appWindow
+ )
+ XCTAssertEqual(
+ SnapWindowPicker.frontmostWindow(at: CGPoint(x: 20, y: 20), windows: [desktop, appWindow, popover]),
+ desktop
+ )
+ XCTAssertNil(
+ SnapWindowPicker.frontmostWindow(at: CGPoint(x: 700, y: 20), windows: [desktop, appWindow, popover])
+ )
+ }
+
+ func testPinnedCaptureInteractionClampsOpacityAndMapsCloseGestures() {
+ XCTAssertEqual(PinnedCaptureInteraction.clampedOpacity(1.4), 1)
+ XCTAssertEqual(PinnedCaptureInteraction.clampedOpacity(0.1), 0.2)
+ XCTAssertEqual(PinnedCaptureInteraction.adjustedOpacity(current: 1, delta: 0.05), 1)
+ XCTAssertEqual(PinnedCaptureInteraction.adjustedOpacity(current: 0.21, delta: -0.05), 0.2)
+ XCTAssertEqual(PinnedCaptureInteraction.adjustedOpacity(current: 0.5, delta: 0.05), 0.55)
+
+ XCTAssertEqual(PinnedCaptureInteraction.opacityDelta(forScrollingDeltaY: 3), 0.05)
+ XCTAssertEqual(PinnedCaptureInteraction.opacityDelta(forScrollingDeltaY: -3), -0.05)
+ XCTAssertEqual(PinnedCaptureInteraction.opacityDelta(forScrollingDeltaY: 0), -0.05)
+
+ XCTAssertTrue(PinnedCaptureInteraction.shouldClose(eventType: .otherMouseDown, buttonNumber: 0))
+ XCTAssertTrue(PinnedCaptureInteraction.shouldClose(eventType: .leftMouseDown, buttonNumber: 2))
+ XCTAssertFalse(PinnedCaptureInteraction.shouldClose(eventType: .leftMouseDown, buttonNumber: 0))
+ }
+
+ func testPinnedCaptureStateTracksLockVisibilityAndOpacity() {
+ var state = PinnedCaptureState(opacity: 1.4)
+ XCTAssertEqual(state.opacity, 1)
+ XCTAssertFalse(state.locked)
+ XCTAssertFalse(state.isHidden)
+ XCTAssertFalse(state.ignoresMouseEvents)
+
+ state.setHidden(true)
+ XCTAssertTrue(state.isHidden)
+ state.setHidden(false)
+ XCTAssertFalse(state.isHidden)
+
+ XCTAssertTrue(state.toggleLock())
+ XCTAssertTrue(state.locked)
+ XCTAssertTrue(state.ignoresMouseEvents)
+ XCTAssertFalse(state.toggleLock())
+ XCTAssertFalse(state.ignoresMouseEvents)
+
+ XCTAssertEqual(state.setOpacity(0.05), 0.2)
+ XCTAssertEqual(state.opacity, 0.2)
+ XCTAssertEqual(state.adjustOpacity(by: 0.17), 0.37, accuracy: 0.0001)
+ XCTAssertEqual(state.adjustOpacity(by: 2), 1)
+ }
+
+ func testScrollCaptureStitcherComposesVerticalAndHorizontalOverlaps() throws {
+ let upper = try makePatternedImage(width: 96, height: 96, offsetX: 0, offsetY: 0)
+ let lower = try makePatternedImage(width: 96, height: 96, offsetX: 0, offsetY: 48)
+
+ let vertical = try XCTUnwrap(ScrollCaptureStitcher.append(upper: upper, lower: lower))
+ XCTAssertEqual(vertical.width, 96)
+ XCTAssertEqual(vertical.height, 144)
+
+ let left = try makePatternedImage(width: 96, height: 96, offsetX: 0, offsetY: 0)
+ let right = try makePatternedImage(width: 96, height: 96, offsetX: 48, offsetY: 0)
+
+ let horizontal = try XCTUnwrap(ScrollCaptureStitcher.append(upper: left, lower: right))
+ XCTAssertEqual(horizontal.width, 144)
+ XCTAssertEqual(horizontal.height, 96)
+ }
+
+ func testScrollCaptureStitcherRejectsFramesWithoutReliableOverlap() throws {
+ let black = try makeSolidImage(width: 96, height: 96, red: 0, green: 0, blue: 0)
+ let white = try makeSolidImage(width: 96, height: 96, red: 255, green: 255, blue: 255)
+
+ XCTAssertNil(try ScrollCaptureStitcher.append(upper: black, lower: white))
+ }
+
+ func testAllDisplayStitcherPreservesDesktopArrangementAndGaps() throws {
+ let blue = try makeSolidImage(width: 2, height: 2, red: 0, green: 0, blue: 255)
+ let red = try makeSolidImage(width: 2, height: 2, red: 255, green: 0, blue: 0)
+ let green = try makeSolidImage(width: 2, height: 2, red: 0, green: 255, blue: 0)
+
+ let capture = try XCTUnwrap(
+ AllDisplayStitcher.stitch(
+ [
+ AllDisplayStitcher.Item(frame: CGRect(x: -2, y: 0, width: 2, height: 2), image: blue, scale: 1),
+ AllDisplayStitcher.Item(frame: CGRect(x: 0, y: 0, width: 2, height: 2), image: red, scale: 1),
+ AllDisplayStitcher.Item(frame: CGRect(x: 0, y: 2, width: 2, height: 2), image: green, scale: 1),
+ ],
+ scaleDownRetina: false
+ )
+ )
+
+ XCTAssertEqual(capture.scale, 1)
+ XCTAssertEqual(capture.image.width, 4)
+ XCTAssertEqual(capture.image.height, 4)
+ XCTAssertEqual(try pixelRGBA(in: capture.image, x: 0, y: 0).r, 0)
+ XCTAssertEqual(try pixelRGBA(in: capture.image, x: 0, y: 0).g, 0)
+ XCTAssertEqual(try pixelRGBA(in: capture.image, x: 0, y: 0).b, 0)
+ XCTAssertEqual(try pixelRGBA(in: capture.image, x: 2, y: 0).g, 255)
+ XCTAssertEqual(try pixelRGBA(in: capture.image, x: 0, y: 2).b, 255)
+ XCTAssertEqual(try pixelRGBA(in: capture.image, x: 2, y: 2).r, 255)
+ XCTAssertNil(AllDisplayStitcher.stitch([], scaleDownRetina: false))
+ }
+
+ func testExtraHotKeyBindingsCoverClaimedCaptureAreaShortcutsAndOverrides() {
+ let prefix = "\(AppIdentity.defaultsPrefix).hotkey"
+ let keys = [
+ "copy.keyCode", "copy.modifiers", "copy.enabled",
+ "annotate.keyCode", "annotate.modifiers", "annotate.enabled",
+ "pin.keyCode", "pin.modifiers", "pin.enabled",
+ "save.keyCode", "save.modifiers", "save.enabled",
+ "previous.keyCode", "previous.modifiers", "previous.enabled",
+ "clipboard.keyCode", "clipboard.modifiers", "clipboard.enabled",
+ "restore.keyCode", "restore.modifiers", "restore.enabled",
+ "hide.keyCode", "hide.modifiers", "hide.enabled",
+ "last.keyCode", "last.modifiers", "last.enabled",
+ "ocr.keyCode", "ocr.modifiers", "ocr.enabled",
+ ].map { "\(prefix).\($0)" }
+ let defaults = UserDefaults.standard
+ let previousValues: [(String, Any?)] = keys.map { ($0, defaults.object(forKey: $0)) }
+ defer {
+ for (key, value) in previousValues {
+ if let value {
+ defaults.set(value, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ }
+
+ keys.forEach { defaults.removeObject(forKey: $0) }
+
+ let defaultsByAction = Dictionary(uniqueKeysWithValues: HotKeyPreferences.extraBindings.map { ($0.action, $0) })
+ XCTAssertEqual(defaultsByAction[.captureCopy]?.keyCode, UInt32(kVK_ANSI_C))
+ XCTAssertEqual(defaultsByAction[.captureCopy]?.modifiers, UInt32(cmdKey | shiftKey | optionKey))
+ XCTAssertEqual(defaultsByAction[.captureCopy]?.isEnabled, true)
+ XCTAssertEqual(defaultsByAction[.captureAnnotate]?.keyCode, UInt32(kVK_ANSI_A))
+ XCTAssertEqual(defaultsByAction[.captureAnnotate]?.isEnabled, true)
+ XCTAssertEqual(defaultsByAction[.capturePin]?.keyCode, UInt32(kVK_ANSI_P))
+ XCTAssertEqual(defaultsByAction[.capturePin]?.isEnabled, true)
+ XCTAssertEqual(defaultsByAction[.captureSave]?.keyCode, UInt32(kVK_ANSI_S))
+ XCTAssertEqual(defaultsByAction[.captureSave]?.isEnabled, false)
+ XCTAssertEqual(defaultsByAction[.capturePrevious]?.keyCode, UInt32(kVK_ANSI_5))
+ XCTAssertEqual(defaultsByAction[.capturePrevious]?.modifiers, UInt32(cmdKey | shiftKey))
+ XCTAssertEqual(defaultsByAction[.capturePrevious]?.isEnabled, true)
+ XCTAssertEqual(defaultsByAction[.openClipboard]?.keyCode, UInt32(kVK_ANSI_V))
+ XCTAssertEqual(defaultsByAction[.openClipboard]?.isEnabled, false)
+ XCTAssertEqual(defaultsByAction[.restoreClosed]?.keyCode, UInt32(kVK_ANSI_Z))
+ XCTAssertEqual(defaultsByAction[.restoreClosed]?.isEnabled, true)
+ XCTAssertEqual(defaultsByAction[.hideOverlays]?.keyCode, UInt32(kVK_ANSI_H))
+ XCTAssertEqual(defaultsByAction[.hideOverlays]?.isEnabled, true)
+ XCTAssertEqual(defaultsByAction[.annotateLast]?.keyCode, UInt32(kVK_ANSI_E))
+ XCTAssertEqual(defaultsByAction[.annotateLast]?.isEnabled, false)
+ XCTAssertEqual(defaultsByAction[.ocr]?.keyCode, UInt32(kVK_ANSI_T))
+ XCTAssertEqual(defaultsByAction[.ocr]?.isEnabled, false)
+
+ defaults.set(kVK_ANSI_B, forKey: "\(prefix).copy.keyCode")
+ defaults.set(Int(cmdKey | controlKey), forKey: "\(prefix).copy.modifiers")
+ defaults.set(false, forKey: "\(prefix).copy.enabled")
+ defaults.set(true, forKey: "\(prefix).save.enabled")
+
+ let customByAction = Dictionary(uniqueKeysWithValues: HotKeyPreferences.extraBindings.map { ($0.action, $0) })
+ XCTAssertEqual(customByAction[.captureCopy]?.keyCode, UInt32(kVK_ANSI_B))
+ XCTAssertEqual(customByAction[.captureCopy]?.modifiers, UInt32(cmdKey | controlKey))
+ XCTAssertEqual(customByAction[.captureCopy]?.isEnabled, false)
+ XCTAssertEqual(customByAction[.captureSave]?.isEnabled, true)
+ }
+
+ func testHotKeyAndRecordingPreferenceModelsMatchClaimedControls() {
+ XCTAssertEqual(
+ HotKeyDisplay.string(keyCode: UInt32(kVK_ANSI_2), modifiers: UInt32(cmdKey | shiftKey)),
+ "⇧⌘2"
+ )
+ XCTAssertEqual(
+ HotKeyDisplay.string(keyCode: UInt32(kVK_ANSI_5), modifiers: UInt32(cmdKey | shiftKey)),
+ "⇧⌘5"
+ )
+ XCTAssertTrue(HotKeyPreferences.isReservedSystemShortcut(keyCode: UInt32(kVK_ANSI_W), modifiers: UInt32(cmdKey)))
+ XCTAssertTrue(HotKeyPreferences.isReservedSystemShortcut(keyCode: UInt32(kVK_ANSI_Q), modifiers: UInt32(cmdKey)))
+ XCTAssertFalse(
+ HotKeyPreferences.isReservedSystemShortcut(
+ keyCode: UInt32(kVK_ANSI_W),
+ modifiers: UInt32(cmdKey | shiftKey)
+ )
+ )
+
+ XCTAssertEqual(RecordingFPS.allCases.map(\.label), ["30 fps", "60 fps", "120 fps"])
+ XCTAssertEqual(RecordingFPS.fps60.frameInterval.value, 1)
+ XCTAssertEqual(RecordingFPS.fps60.frameInterval.timescale, 60)
+ XCTAssertEqual(RecordingMaxResolution.allCases.map(\.label), ["Native", "1080p", "720p", "480p"])
+ XCTAssertNil(RecordingMaxResolution.native.maxLongEdge)
+ XCTAssertEqual(RecordingMaxResolution.p1080.maxLongEdge, 1920)
+ XCTAssertEqual(RecordingMaxResolution.p720.maxLongEdge, 1280)
+ XCTAssertEqual(RecordingMaxResolution.p480.maxLongEdge, 854)
+ XCTAssertEqual(
+ RecordingHUDPosition.allCases.map(\.label),
+ ["Bottom center", "Bottom left", "Bottom right", "Top center"]
+ )
+ }
+
+ func testKeystrokeHUDLabelsAndWebcamPiPPlacement() {
+ XCTAssertEqual(
+ KeystrokeHUDLabel.label(
+ eventType: .flagsChanged,
+ keyCode: 0,
+ charactersIgnoringModifiers: nil,
+ modifierFlags: [.shift, .command],
+ commandOnly: true
+ ),
+ "⇧⌘"
+ )
+ XCTAssertNil(
+ KeystrokeHUDLabel.label(
+ eventType: .flagsChanged,
+ keyCode: 0,
+ charactersIgnoringModifiers: nil,
+ modifierFlags: [],
+ commandOnly: true
+ )
+ )
+ XCTAssertEqual(
+ KeystrokeHUDLabel.label(
+ eventType: .keyDown,
+ keyCode: UInt16(kVK_ANSI_A),
+ charactersIgnoringModifiers: "a",
+ modifierFlags: [.shift, .command],
+ commandOnly: true
+ ),
+ "⇧⌘A"
+ )
+ XCTAssertEqual(
+ KeystrokeHUDLabel.label(
+ eventType: .keyDown,
+ keyCode: UInt16(kVK_Return),
+ charactersIgnoringModifiers: "\r",
+ modifierFlags: [],
+ commandOnly: false
+ ),
+ "↩"
+ )
+ XCTAssertEqual(
+ KeystrokeHUDLabel.label(
+ eventType: .keyDown,
+ keyCode: UInt16(kVK_Space),
+ charactersIgnoringModifiers: " ",
+ modifierFlags: [.option],
+ commandOnly: true
+ ),
+ "⌥Space"
+ )
+ XCTAssertNil(
+ KeystrokeHUDLabel.label(
+ eventType: .keyDown,
+ keyCode: UInt16(kVK_ANSI_X),
+ charactersIgnoringModifiers: "x",
+ modifierFlags: [],
+ commandOnly: true
+ )
+ )
+ XCTAssertNil(
+ KeystrokeHUDLabel.label(
+ eventType: .keyDown,
+ keyCode: UInt16(kVK_Shift),
+ charactersIgnoringModifiers: nil,
+ modifierFlags: [.shift],
+ commandOnly: false
+ )
+ )
+
+ XCTAssertEqual(WebcamPiPGeometry.diameter, 168)
+ XCTAssertEqual(
+ WebcamPiPGeometry.bottomRightOrigin(
+ screen: CGRect(x: 100, y: 50, width: 1000, height: 700),
+ windowSize: CGSize(width: 168, height: 168)
+ ),
+ CGPoint(x: 908, y: 74)
+ )
+ }
+
+ func testCountdownDisplayAndFocusAssistPolicy() {
+ XCTAssertEqual(CountdownDisplay.tickInterval, 0.1)
+ XCTAssertEqual(CountdownDisplay.nextRemaining(after: 3), 2.9, accuracy: 0.0001)
+ XCTAssertEqual(CountdownDisplay.nextRemaining(after: 0.05), 0, accuracy: 0.0001)
+
+ XCTAssertEqual(CountdownDisplay.captureLabel(remaining: 3), "Capturing in 3s…")
+ XCTAssertEqual(CountdownDisplay.captureLabel(remaining: 2.01), "Capturing in 3s…")
+ XCTAssertEqual(CountdownDisplay.captureLabel(remaining: 2.0), "Capturing in 2s…")
+ XCTAssertEqual(CountdownDisplay.recordingLabel(remaining: 0.01), "Recording in 1s…")
+ XCTAssertEqual(CountdownDisplay.recordingLabel(remaining: -1), "Recording in 0s…")
+
+ XCTAssertEqual(
+ FocusAssist.script(for: true),
+ "tell application \"System Events\" to keystroke \"d\" using {command down, option down}"
+ )
+ XCTAssertNil(FocusAssist.script(for: false))
+ }
+
+ func testRecordingGeometryAppliesEvenDimensionsAndMaxResolutionCaps() {
+ XCTAssertEqual(
+ RecordingGeometry.pixelSize(
+ captureSizeInPoints: CGSize(width: 960, height: 540),
+ scale: 2,
+ maxResolution: .native
+ ),
+ RecordingPixelSize(width: 1920, height: 1080)
+ )
+ XCTAssertEqual(
+ RecordingGeometry.pixelSize(
+ captureSizeInPoints: CGSize(width: 3.4, height: 1.1),
+ scale: 1,
+ maxResolution: .native
+ ),
+ RecordingPixelSize(width: 2, height: 2)
+ )
+ XCTAssertEqual(
+ RecordingGeometry.pixelSize(
+ captureSizeInPoints: CGSize(width: 3000, height: 2000),
+ scale: 1,
+ maxResolution: .p1080
+ ),
+ RecordingPixelSize(width: 1920, height: 1280)
+ )
+ XCTAssertEqual(
+ RecordingGeometry.pixelSize(
+ captureSizeInPoints: CGSize(width: 1000, height: 3000),
+ scale: 1,
+ maxResolution: .p720
+ ),
+ RecordingPixelSize(width: 426, height: 1280)
+ )
+ XCTAssertEqual(
+ RecordingGeometry.pixelSize(
+ captureSizeInPoints: CGSize(width: 400, height: 300),
+ scale: 1,
+ maxResolution: .p480
+ ),
+ RecordingPixelSize(width: 400, height: 300)
+ )
+ }
+
+ func testRecordingInstallUsesTemporaryWorkingFile() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("ParcelRecordingInstall-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let destination = directory.appendingPathComponent("final.mp4")
+ let working = ScreenRecorder.workingRecordingURL(for: destination)
+
+ XCTAssertNotEqual(working, destination)
+ XCTAssertEqual(working.pathExtension, "mp4")
+
+ try Data("original".utf8).write(to: destination)
+ try Data("finished".utf8).write(to: working)
+
+ XCTAssertEqual(try Data(contentsOf: destination), Data("original".utf8))
+
+ try ScreenRecorder.installFinishedRecording(from: working, to: destination)
+
+ XCTAssertEqual(try Data(contentsOf: destination), Data("finished".utf8))
+ XCTAssertFalse(FileManager.default.fileExists(atPath: working.path))
+ }
+
+ func testProductionRecordingWriterFinalizesMP4AndSkipsPausedSamples() async throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("ParcelRecordingWriter-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let url = directory.appendingPathComponent("writer.mp4")
+ let writer = try LegacyRecordingWriter(url: url, videoSize: CGSize(width: 64, height: 48), monoAudio: true)
+
+ for frame in 0..<14 {
+ writer.receiveSampleForTesting(
+ try makeVideoSampleBuffer(width: 64, height: 48, frame: frame),
+ type: .screen
+ )
+ }
+ writer.flushSamplesForTesting()
+ let countBeforePause = writer.videoSampleCountForTesting
+ let receivedBeforePause = writer.receivedVideoSampleCountForTesting
+ XCTAssertGreaterThan(countBeforePause, 0)
+
+ writer.isPaused = true
+ for frame in 14..<18 {
+ writer.receiveSampleForTesting(
+ try makeVideoSampleBuffer(width: 64, height: 48, frame: frame),
+ type: .screen
+ )
+ }
+ writer.flushSamplesForTesting()
+ XCTAssertEqual(writer.videoSampleCountForTesting, countBeforePause)
+ XCTAssertEqual(writer.receivedVideoSampleCountForTesting, receivedBeforePause)
+
+ writer.isPaused = false
+ for frame in 18..<22 {
+ writer.receiveSampleForTesting(
+ try makeVideoSampleBuffer(width: 64, height: 48, frame: frame),
+ type: .screen
+ )
+ }
+ writer.flushSamplesForTesting()
+ XCTAssertGreaterThan(writer.receivedVideoSampleCountForTesting, receivedBeforePause)
+
+ try await writer.finish()
+
+ let data = try Data(contentsOf: url)
+ XCTAssertNotNil(data.range(of: Data("moov".utf8)))
+
+ let asset = AVURLAsset(url: url)
+ let duration = try await asset.load(.duration)
+ let videoTracks = try await asset.loadTracks(withMediaType: .video)
+
+ XCTAssertGreaterThan(duration.seconds, 0)
+ XCTAssertEqual(videoTracks.count, 1)
+ }
+
+ func testRecordingTrimModelExportsMP4AndGIF() async throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("ParcelRecordingExport-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let sourceURL = directory.appendingPathComponent("source.mp4")
+ try await makeTestRecordingMP4(at: sourceURL, frameCount: 36)
+
+ let model = RecordingEditorModel(url: sourceURL, autoloadDuration: false)
+ await model.loadDuration()
+ XCTAssertGreaterThan(model.duration, 0.1)
+
+ model.startTime = model.duration * 0.25
+ model.endTime = model.duration * 0.75
+ XCTAssertGreaterThanOrEqual(model.trimmedDuration, 0.1)
+
+ let mp4URL = directory.appendingPathComponent("trimmed.mp4")
+ let gifURL = directory.appendingPathComponent("trimmed.gif")
+ try await model.writeMP4(to: mp4URL)
+ try await model.writeGIF(to: gifURL)
+
+ let mp4Asset = AVURLAsset(url: mp4URL)
+ let mp4Duration = try await mp4Asset.load(.duration)
+ let mp4VideoTracks = try await mp4Asset.loadTracks(withMediaType: .video)
+ XCTAssertGreaterThan(mp4Duration.seconds, 0.1)
+ XCTAssertEqual(mp4VideoTracks.count, 1)
+ XCTAssertNotNil(try Data(contentsOf: mp4URL).range(of: Data("moov".utf8)))
+
+ let gifData = try Data(contentsOf: gifURL)
+ XCTAssertTrue(gifData.starts(with: Data("GIF".utf8)))
+ guard let gifSource = CGImageSourceCreateWithData(gifData as CFData, nil) else {
+ XCTFail("Expected exported GIF to be readable by ImageIO")
+ return
+ }
+ XCTAssertEqual(CGImageSourceGetType(gifSource) as String?, UTType.gif.identifier)
+ XCTAssertGreaterThan(CGImageSourceGetCount(gifSource), 0)
+ }
+
+ func testUploadPreferencesAndNotConfiguredErrorStayLocal() async {
+ let previousURL = UploadPreferences.supabaseURL
+ let previousAnonKey = UploadPreferences.anonKey
+ let previousBucket = UploadPreferences.bucketName
+ let previousPublicBase = UploadPreferences.publicBaseURL
+ defer {
+ UploadPreferences.supabaseURL = previousURL
+ UploadPreferences.anonKey = previousAnonKey
+ UploadPreferences.bucketName = previousBucket
+ UploadPreferences.publicBaseURL = previousPublicBase
+ }
+
+ UploadPreferences.supabaseURL = " "
+ UploadPreferences.anonKey = ""
+ UploadPreferences.bucketName = "captures"
+ UploadPreferences.publicBaseURL = " https://cdn.example.test/captures "
+
+ XCTAssertFalse(UploadPreferences.isConfigured)
+ XCTAssertEqual(UploadPreferences.publicBaseURL, "https://cdn.example.test/captures")
+
+ do {
+ _ = try await UploadService.uploadPNG(data: Data([0x89, 0x50, 0x4E, 0x47]), fileName: "local.png")
+ XCTFail("Upload should fail before any network work when Supabase is not configured.")
+ } catch UploadError.notConfigured {
+ XCTAssertTrue(true)
+ } catch {
+ XCTFail("Expected UploadError.notConfigured, got \(error)")
+ }
+
+ UploadPreferences.supabaseURL = " https://project.supabase.co/ "
+ UploadPreferences.anonKey = " anon-key "
+ UploadPreferences.bucketName = " captures "
+
+ XCTAssertTrue(UploadPreferences.isConfigured)
+ XCTAssertEqual(UploadPreferences.supabaseURL, "https://project.supabase.co/")
+ XCTAssertEqual(UploadPreferences.anonKey, "anon-key")
+ XCTAssertEqual(UploadPreferences.bucketName, "captures")
+ }
+
+ func testUploadServiceBuildsSupabaseRequestAndReturnsPublicURL() async throws {
+ let previousURL = UploadPreferences.supabaseURL
+ let previousAnonKey = UploadPreferences.anonKey
+ let previousBucket = UploadPreferences.bucketName
+ let previousPublicBase = UploadPreferences.publicBaseURL
+ defer {
+ UploadPreferences.supabaseURL = previousURL
+ UploadPreferences.anonKey = previousAnonKey
+ UploadPreferences.bucketName = previousBucket
+ UploadPreferences.publicBaseURL = previousPublicBase
+ }
+
+ UploadPreferences.supabaseURL = "https://project.supabase.co/"
+ UploadPreferences.anonKey = "anon-key"
+ UploadPreferences.bucketName = "parcel bucket"
+ UploadPreferences.publicBaseURL = "https://cdn.example.test/captures/"
+
+ final class RequestBox { var request: URLRequest? }
+ let box = RequestBox()
+ let payload = Data([0x89, 0x50, 0x4E, 0x47])
+ let result = try await UploadService.uploadPNG(
+ data: payload,
+ fileName: "Capture 1.png",
+ dataLoader: { request in
+ box.request = request
+ let response = HTTPURLResponse(
+ url: try XCTUnwrap(request.url),
+ statusCode: 201,
+ httpVersion: nil,
+ headerFields: nil
+ )!
+ return (Data("{}".utf8), response)
+ }
+ )
+
+ let request = try XCTUnwrap(box.request)
+ XCTAssertEqual(
+ request.url?.absoluteString,
+ "https://project.supabase.co/storage/v1/object/parcel%20bucket/Capture%201.png"
+ )
+ XCTAssertEqual(request.httpMethod, "POST")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer anon-key")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "image/png")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "x-upsert"), "true")
+ XCTAssertEqual(request.httpBody, payload)
+ XCTAssertEqual(result.absoluteString, "https://cdn.example.test/captures/Capture%201.png")
+ }
+
+ func testUploadServiceSurfacesHTTPErrorBody() async throws {
+ let previousURL = UploadPreferences.supabaseURL
+ let previousAnonKey = UploadPreferences.anonKey
+ let previousBucket = UploadPreferences.bucketName
+ let previousPublicBase = UploadPreferences.publicBaseURL
+ defer {
+ UploadPreferences.supabaseURL = previousURL
+ UploadPreferences.anonKey = previousAnonKey
+ UploadPreferences.bucketName = previousBucket
+ UploadPreferences.publicBaseURL = previousPublicBase
+ }
+
+ UploadPreferences.supabaseURL = "https://project.supabase.co"
+ UploadPreferences.anonKey = "bad-key"
+ UploadPreferences.bucketName = "captures"
+ UploadPreferences.publicBaseURL = ""
+
+ do {
+ _ = try await UploadService.uploadPNG(
+ data: Data([1, 2, 3]),
+ fileName: "private.png",
+ dataLoader: { request in
+ let response = HTTPURLResponse(
+ url: try XCTUnwrap(request.url),
+ statusCode: 401,
+ httpVersion: nil,
+ headerFields: nil
+ )!
+ return (Data("invalid token".utf8), response)
+ }
+ )
+ XCTFail("Upload should surface Supabase HTTP errors.")
+ } catch let UploadError.httpStatus(code, body) {
+ XCTAssertEqual(code, 401)
+ XCTAssertEqual(body, "invalid token")
+ } catch {
+ XCTFail("Expected UploadError.httpStatus, got \(error)")
+ }
+ }
+
+ func testVisionAnalysisDetectsSensitiveTextLocally() {
+ let observations = [
+ VisionTextObservation(string: "hello team", rect: CGRect(x: 0, y: 0, width: 10, height: 10)),
+ VisionTextObservation(string: "email jane@example.com", rect: CGRect(x: 0, y: 12, width: 10, height: 10)),
+ VisionTextObservation(string: "+1 (555) 123-4567", rect: CGRect(x: 0, y: 24, width: 10, height: 10)),
+ VisionTextObservation(string: "4242 4242 4242 4242", rect: CGRect(x: 0, y: 36, width: 10, height: 10)),
+ ]
+ let analysis = VisionAnalysis(text: observations)
+
+ XCTAssertEqual(analysis.recognizedText, observations.map(\.string).joined(separator: "\n"))
+ XCTAssertEqual(analysis.piiText.map(\.string), Array(observations.dropFirst()).map(\.string))
+ }
+
+ func testHighlighterSnapperSnapsNearbyPointsToTextBoxCenters() {
+ let firstTextBox = CGRect(x: 20, y: 10, width: 40, height: 12)
+ let secondTextBox = CGRect(x: 120, y: 50, width: 20, height: 30)
+ let points = [
+ CGPoint(x: 43, y: 18),
+ CGPoint(x: 128, y: 70),
+ CGPoint(x: 190, y: 160),
+ CGPoint(x: 64, y: 16),
+ ]
+
+ let snapped = HighlighterSnapper.snappedPoints(points, to: [firstTextBox, secondTextBox])
+
+ XCTAssertEqual(snapped[0], CGPoint(x: firstTextBox.midX, y: firstTextBox.midY))
+ XCTAssertEqual(snapped[1], CGPoint(x: secondTextBox.midX, y: secondTextBox.midY))
+ XCTAssertEqual(snapped[2], points[2])
+ XCTAssertEqual(snapped[3], points[3], "The 24-point threshold is strict, so equal distance should not snap.")
+ XCTAssertEqual(HighlighterSnapper.snappedPoints(points, to: []), points)
+ }
+
+ func testColorSwatchStorePersistsUniqueRecentColorsAndCapsAtTwelve() {
+ let key = "\(AppIdentity.defaultsPrefix).editor.colorSwatches"
+ let previous = UserDefaults.standard.data(forKey: key)
+ defer {
+ if let previous {
+ UserDefaults.standard.set(previous, forKey: key)
+ } else {
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ }
+ UserDefaults.standard.removeObject(forKey: key)
+
+ let red = RGBAColor(red: 1, green: 0, blue: 0)
+ ColorSwatchStore.add(red)
+ ColorSwatchStore.add(red)
+ XCTAssertEqual(ColorSwatchStore.load(), [red])
+
+ let colors = (0..<13).map { index in
+ RGBAColor(
+ red: Double(index + 1) / 20,
+ green: Double(index + 2) / 21,
+ blue: Double(index + 3) / 22
+ )
+ }
+ colors.forEach(ColorSwatchStore.add)
+
+ XCTAssertEqual(ColorSwatchStore.load(), Array(colors.reversed().prefix(12)))
+ }
+
+ func testOCRTextFormatterHonorsLineBreakPreference() {
+ let recognizedText = " Parcel\n Capture \n\nEditor\r\n Canvas "
+
+ XCTAssertEqual(
+ OCRTextFormatter.outputText(from: recognizedText, stripLineBreaks: false),
+ "Parcel\n Capture \n\nEditor\r\n Canvas"
+ )
+ XCTAssertEqual(
+ OCRTextFormatter.outputText(from: recognizedText, stripLineBreaks: true),
+ "Parcel Capture Editor Canvas"
+ )
+ XCTAssertEqual(
+ OCRTextFormatter.outputText(from: " \n\t ", stripLineBreaks: true),
+ ""
+ )
+ }
+
+ func testVisionAnalyzerDetectsQRCodeAndMapsCaptureRectsLocally() async throws {
+ let payload = "parcel://capture/region?x=10&y=20&w=30&h=40"
+ let image = try makeQRCodeImage(payload: payload, size: 256)
+ let analysis = await VisionAnalyzer.analyze(image: image, pointSize: CGSize(width: 256, height: 256))
+
+ XCTAssertTrue(
+ analysis.qrCodes.contains { $0.payload == payload },
+ "Expected local Vision analyzer to decode generated QR payload."
+ )
+
+ let mapped = VisionAnalyzer.captureRect(
+ fromVision: CGRect(x: 0.25, y: 0.5, width: 0.5, height: 0.25),
+ pointSize: CGSize(width: 200, height: 100)
+ )
+ XCTAssertEqual(mapped, CGRect(x: 50, y: 25, width: 100, height: 25))
+ }
+
+ func testWebPEncoderProducesRIFFWebPBytes() throws {
+ let image = try makeTestImage(width: 32, height: 24)
+ let data = try XCTUnwrap(ParcelWebPEncoder.encode(image))
+
+ XCTAssertGreaterThan(data.count, 12)
+ XCTAssertEqual(Array(data[0..<4]), Array("RIFF".utf8))
+ XCTAssertEqual(Array(data[8..<12]), Array("WEBP".utf8))
+ }
+
+ func testSelectedOutputFormatsEncodeClaimedContainerTypes() throws {
+ let image = try makeTestImage(width: 32, height: 24)
+ let outputSize = CGSize(width: 32, height: 24)
+
+ for format in OutputFormat.allCases {
+ let data = try XCTUnwrap(
+ EditorModel.encodeImage(image, as: format, outputSize: outputSize),
+ "Expected \(format.label) encoder to produce data"
+ )
+ XCTAssertGreaterThan(data.count, 12, "Expected non-empty \(format.label) output")
+
+ switch format {
+ case .png:
+ XCTAssertEqual(Array(data.prefix(4)), [0x89, 0x50, 0x4E, 0x47])
+ XCTAssertEqual(imageSourceType(for: data), UTType.png.identifier)
+ case .jpeg:
+ XCTAssertEqual(Array(data.prefix(2)), [0xFF, 0xD8])
+ XCTAssertEqual(imageSourceType(for: data), UTType.jpeg.identifier)
+ case .heic:
+ let sourceType = try XCTUnwrap(imageSourceType(for: data))
+ XCTAssertTrue(
+ [UTType.heic.identifier, "public.heif"].contains(sourceType),
+ "Expected HEIC/HEIF data, got \(sourceType)"
+ )
+ case .tiff:
+ let littleEndianTIFF = Array(data.prefix(4)) == [0x49, 0x49, 0x2A, 0x00]
+ let bigEndianTIFF = Array(data.prefix(4)) == [0x4D, 0x4D, 0x00, 0x2A]
+ XCTAssertTrue(littleEndianTIFF || bigEndianTIFF)
+ XCTAssertEqual(imageSourceType(for: data), UTType.tiff.identifier)
+ case .webp:
+ XCTAssertEqual(Array(data.prefix(4)), Array("RIFF".utf8))
+ XCTAssertEqual(Array(data.dropFirst(8).prefix(4)), Array("WEBP".utf8))
+ }
+ }
+ }
+
+ func testImageColorSpaceConversionProducesSRGBImageForExportPreference() throws {
+ let p3 = CGColorSpace(name: CGColorSpace.displayP3) ?? CGColorSpaceCreateDeviceRGB()
+ let image = try makeImage(width: 16, height: 12, colorSpace: p3) { x, y in
+ (
+ UInt8((x * 255) / 15),
+ UInt8((y * 255) / 11),
+ 120,
+ 255
+ )
+ }
+
+ let converted = try XCTUnwrap(ImageColorSpace.convertToSRGB(image))
+
+ XCTAssertEqual(converted.width, image.width)
+ XCTAssertEqual(converted.height, image.height)
+ XCTAssertEqual(converted.colorSpace?.name as String?, CGColorSpace.sRGB as String)
+ }
+
+ func testParcelProjectRoundTripRestoresEditableState() throws {
+ let fileManager = FileManager.default
+ let url = fileManager.temporaryDirectory
+ .appendingPathComponent("ParcelProject-\(UUID().uuidString)", isDirectory: true)
+ .appendingPathExtension("parcel")
+ defer { try? fileManager.removeItem(at: url) }
+
+ let capture = Capture(image: try makeTestImage(width: 80, height: 60), scale: 2)
+ let model = EditorModel(capture: capture)
+ model.outputFormat = .webp
+ model.adjustments = Adjustments(contrast: 1.08, saturation: 1.15, sharpness: 0.25)
+ model.beautifyEnabled = true
+ model.beautify.padding = 24
+ model.beautify.cornerRadius = 8
+ model.add(
+ Annotation(
+ kind: .rectangle(rect: CGRect(x: 8, y: 10, width: 24, height: 18)),
+ style: AnnotationStyle(color: .blue, lineWidth: 3, fontSize: 18)
+ )
+ )
+
+ ParcelProjectIO.save(model: model, to: url)
+
+ XCTAssertTrue(fileManager.fileExists(atPath: url.appendingPathComponent("capture.png").path))
+ XCTAssertTrue(fileManager.fileExists(atPath: url.appendingPathComponent("document.json").path))
+
+ let restored = try XCTUnwrap(ParcelProjectIO.open(from: url))
+ XCTAssertEqual(restored.capture.image.width, capture.image.width)
+ XCTAssertEqual(restored.capture.image.height, capture.image.height)
+ XCTAssertEqual(restored.capture.scale, capture.scale)
+ XCTAssertEqual(restored.document.annotations, model.annotations)
+ XCTAssertEqual(restored.document.adjustments, model.adjustments)
+ XCTAssertEqual(restored.document.beautifyEnabled, model.beautifyEnabled)
+ XCTAssertEqual(restored.document.beautify.padding, model.beautify.padding)
+ XCTAssertEqual(restored.document.beautify.cornerRadius, model.beautify.cornerRadius)
+ XCTAssertEqual(restored.document.outputFormat, .webp)
+ }
+
+ func testHistoryStoreCreateRestoreSaveAndDelete() throws {
+ let fileManager = FileManager.default
+ let rootURL = fileManager.temporaryDirectory
+ .appendingPathComponent("ParcelHistory-\(UUID().uuidString)", isDirectory: true)
+ defer { try? fileManager.removeItem(at: rootURL) }
+
+ let store = HistoryStore(fileManager: fileManager, rootURL: rootURL)
+ let capture = Capture(image: try makeTestImage(width: 48, height: 32), scale: 2)
+ let id = try XCTUnwrap(store.createDocument(for: capture))
+
+ XCTAssertEqual(store.entries.map(\.id), [id])
+ XCTAssertNotNil(store.preview(for: try XCTUnwrap(store.entry(for: id))))
+
+ let restored = try XCTUnwrap(store.restore(id))
+ XCTAssertEqual(restored.capture.image.width, capture.image.width)
+ XCTAssertEqual(restored.capture.image.height, capture.image.height)
+ XCTAssertEqual(restored.capture.scale, capture.scale)
+ XCTAssertEqual(restored.document.annotations, [])
+ XCTAssertEqual(restored.document.outputFormat, .png)
+
+ var document = restored.document
+ document.annotations = [
+ Annotation(
+ kind: .text(rect: CGRect(x: 4, y: 5, width: 30, height: 12), string: "History"),
+ style: AnnotationStyle(color: .blue, lineWidth: 2, fontSize: 18)
+ ),
+ ]
+ document.adjustments = Adjustments(brightness: 0.1, contrast: 1.1)
+ document.beautifyEnabled = true
+ document.beautify.padding = 18
+ document.outputFormat = .webp
+ store.save(document)
+
+ let saved = try XCTUnwrap(store.restore(id))
+ XCTAssertEqual(saved.document.annotations, document.annotations)
+ XCTAssertEqual(saved.document.adjustments, document.adjustments)
+ XCTAssertTrue(saved.document.beautifyEnabled)
+ XCTAssertEqual(saved.document.beautify.padding, 18)
+ XCTAssertEqual(saved.document.outputFormat, .webp)
+
+ store.remove(id)
+ XCTAssertTrue(store.entries.isEmpty)
+ XCTAssertFalse(fileManager.fileExists(atPath: rootURL.appendingPathComponent(id.uuidString).path))
+ }
+
+ func testHistoryFilterAndRetentionPruneUsePersistedIndex() throws {
+ let previousRetention = HistoryRetention.current
+ defer { HistoryRetention.current = previousRetention }
+
+ let fileManager = FileManager.default
+ let rootURL = fileManager.temporaryDirectory
+ .appendingPathComponent("ParcelHistoryRetention-\(UUID().uuidString)", isDirectory: true)
+ defer { try? fileManager.removeItem(at: rootURL) }
+ try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true)
+
+ let now = Date()
+ let freshID = UUID()
+ let expiredID = UUID()
+ let fresh = HistoryEntry(
+ id: freshID,
+ createdAt: now.addingTimeInterval(-60 * 60),
+ updatedAt: now.addingTimeInterval(-60 * 60),
+ captureFileName: "fresh-capture.png",
+ pixelWidth: 640,
+ pixelHeight: 480
+ )
+ let expired = HistoryEntry(
+ id: expiredID,
+ createdAt: now.addingTimeInterval(-8 * 24 * 60 * 60),
+ updatedAt: now.addingTimeInterval(-8 * 24 * 60 * 60),
+ captureFileName: "expired-capture.png",
+ pixelWidth: 320,
+ pixelHeight: 240
+ )
+
+ try fileManager.createDirectory(
+ at: rootURL.appendingPathComponent(freshID.uuidString, isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ try fileManager.createDirectory(
+ at: rootURL.appendingPathComponent(expiredID.uuidString, isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ try encoder.encode([expired, fresh]).write(to: rootURL.appendingPathComponent("index.json"), options: .atomic)
+
+ XCTAssertTrue(fresh.matchesFilter("fresh"))
+ XCTAssertTrue(fresh.matchesFilter("640x480"))
+ XCTAssertTrue(fresh.matchesFilter("640 × 480"))
+ XCTAssertTrue(fresh.matchesFilter(" "))
+ XCTAssertFalse(fresh.matchesFilter("expired"))
+
+ HistoryRetention.current = .week
+ let store = HistoryStore(fileManager: fileManager, rootURL: rootURL)
+
+ XCTAssertEqual(store.entries.map(\.id), [freshID])
+ XCTAssertTrue(fileManager.fileExists(atPath: rootURL.appendingPathComponent(freshID.uuidString).path))
+ XCTAssertFalse(fileManager.fileExists(atPath: rootURL.appendingPathComponent(expiredID.uuidString).path))
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let persistedEntries = try decoder.decode(
+ [HistoryEntry].self,
+ from: Data(contentsOf: rootURL.appendingPathComponent("index.json"))
+ )
+ XCTAssertEqual(persistedEntries.map(\.id), [freshID])
+ }
+
+ func testBrandKitStoreSavesReloadsAndRemovesBeautifySettingsLocally() {
+ let suiteName = "ParcelBrandKitTests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ var settings = BeautifySettings()
+ settings.background = .solid(RGBAColor(hex: 0x112233))
+ settings.padding = 72
+ settings.cornerRadius = 18
+ settings.shadow.opacity = 0.42
+ settings.chrome.enabled = true
+ settings.chrome.title = "Launch"
+ settings.chrome.style = .dark
+
+ let store = BrandKitStore(userDefaults: defaults, defaultsKey: "brandKits")
+ store.save(name: " Launch Kit ", settings: settings)
+ store.save(name: " ", settings: BeautifySettings())
+
+ XCTAssertEqual(store.kits.count, 1)
+ XCTAssertEqual(store.kits[0].name, "Launch Kit")
+ XCTAssertEqual(store.kits[0].settings, settings)
+
+ let reloaded = BrandKitStore(userDefaults: defaults, defaultsKey: "brandKits")
+ XCTAssertEqual(reloaded.kits, store.kits)
+
+ reloaded.remove(store.kits[0].id)
+ XCTAssertTrue(reloaded.kits.isEmpty)
+ XCTAssertTrue(BrandKitStore(userDefaults: defaults, defaultsKey: "brandKits").kits.isEmpty)
+ }
+
+ func testAnnotationUndoRedoAndLayerOrder() throws {
+ let model = EditorModel(capture: Capture(image: try makeTestImage(width: 80, height: 60), scale: 1))
+ let back = Annotation(
+ kind: .rectangle(rect: CGRect(x: 5, y: 5, width: 20, height: 16)),
+ style: AnnotationStyle(color: .red, lineWidth: 2, fontSize: 18)
+ )
+ let middle = Annotation(
+ kind: .ellipse(rect: CGRect(x: 14, y: 14, width: 18, height: 18)),
+ style: AnnotationStyle(color: .blue, lineWidth: 2, fontSize: 18)
+ )
+ let front = Annotation(
+ kind: .arrow(start: CGPoint(x: 2, y: 40), end: CGPoint(x: 48, y: 8)),
+ style: AnnotationStyle(color: .yellow, lineWidth: 3, fontSize: 18)
+ )
+
+ model.add(back)
+ model.add(middle)
+ model.add(front)
+
+ XCTAssertEqual(model.annotations.map(\.id), [back.id, middle.id, front.id])
+ XCTAssertTrue(model.canUndo)
+ XCTAssertFalse(model.canRedo)
+
+ model.selectedID = back.id
+ model.bringToFront()
+ XCTAssertEqual(model.annotations.map(\.id), [middle.id, front.id, back.id])
+
+ model.undo()
+ XCTAssertEqual(model.annotations.map(\.id), [back.id, middle.id, front.id])
+ XCTAssertTrue(model.canRedo)
+
+ model.redo()
+ XCTAssertEqual(model.annotations.map(\.id), [middle.id, front.id, back.id])
+ }
+
+ func testDocumentSettingsStayOutOfAnnotationUndoStack() throws {
+ let model = EditorModel(capture: Capture(image: try makeTestImage(width: 80, height: 60), scale: 1))
+ model.add(
+ Annotation(
+ kind: .rectangle(rect: CGRect(x: 8, y: 8, width: 24, height: 18)),
+ style: AnnotationStyle(color: .red, lineWidth: 2, fontSize: 18)
+ )
+ )
+
+ model.adjustments = Adjustments(brightness: 0.5, contrast: 1.2, saturation: 0.9)
+ model.beautifyEnabled = true
+ model.beautify.padding = 32
+ model.outputFormat = .webp
+
+ model.undo()
+
+ XCTAssertTrue(model.annotations.isEmpty)
+ XCTAssertEqual(model.adjustments, Adjustments(brightness: 0.5, contrast: 1.2, saturation: 0.9))
+ XCTAssertTrue(model.beautifyEnabled)
+ XCTAssertEqual(model.beautify.padding, 32)
+ XCTAssertEqual(model.outputFormat, .webp)
+ }
+
+ func testCropTransformRemapsAnnotationsInCapturePointSpace() throws {
+ let model = EditorModel(capture: Capture(image: try makeTestImage(width: 200, height: 100), scale: 2))
+ let rect = Annotation(
+ kind: .rectangle(rect: CGRect(x: 10, y: 12, width: 20, height: 8)),
+ style: AnnotationStyle(color: .red, lineWidth: 2, fontSize: 18)
+ )
+ let arrow = Annotation(
+ kind: .arrow(start: CGPoint(x: 15, y: 20), end: CGPoint(x: 55, y: 40)),
+ style: AnnotationStyle(color: .blue, lineWidth: 3, fontSize: 18)
+ )
+
+ model.add(rect)
+ model.add(arrow)
+ model.cropCapture(toPoints: CGRect(x: 5, y: 10, width: 80, height: 30))
+
+ XCTAssertEqual(model.capture.pointSize.width, 80, accuracy: 0.001)
+ XCTAssertEqual(model.capture.pointSize.height, 30, accuracy: 0.001)
+ XCTAssertEqual(model.annotations.count, 2)
+ XCTAssertEqual(model.annotations[0].kind, .rectangle(rect: CGRect(x: 5, y: 2, width: 20, height: 8)))
+ XCTAssertEqual(model.annotations[1].kind, .arrow(start: CGPoint(x: 10, y: 10), end: CGPoint(x: 50, y: 30)))
+ }
+
+ func testTransformRemappingForRotateFlipAndScale() {
+ let style = AnnotationStyle(color: .red, lineWidth: 2, fontSize: 18)
+ let annotations = [
+ Annotation(
+ id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
+ kind: .rectangle(rect: CGRect(x: 10, y: 20, width: 30, height: 10)),
+ style: style
+ ),
+ Annotation(
+ id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!,
+ kind: .arrow(start: CGPoint(x: 5, y: 8), end: CGPoint(x: 60, y: 40)),
+ style: style
+ ),
+ Annotation(
+ id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!,
+ kind: .number(center: CGPoint(x: 50, y: 25), radius: 6, value: 1),
+ style: style
+ ),
+ ]
+
+ let rotated = CaptureTransform.rotateAnnotations90CW(annotations, canvasSize: CGSize(width: 100, height: 50))
+ XCTAssertEqual(rotated[0].kind, .rectangle(rect: CGRect(x: 20, y: 10, width: 10, height: 30)))
+ XCTAssertEqual(rotated[0].rotation, .pi / 2, accuracy: 0.001)
+ XCTAssertEqual(rotated[1].kind, .arrow(start: CGPoint(x: 42, y: 5), end: CGPoint(x: 10, y: 60)))
+ XCTAssertEqual(rotated[2].kind, .number(center: CGPoint(x: 25, y: 50), radius: 6, value: 1))
+
+ let flippedH = CaptureTransform.flipAnnotationsH(annotations, canvasWidth: 100)
+ XCTAssertEqual(flippedH[0].kind, .rectangle(rect: CGRect(x: 60, y: 20, width: 30, height: 10)))
+ XCTAssertEqual(flippedH[1].kind, .arrow(start: CGPoint(x: 95, y: 8), end: CGPoint(x: 40, y: 40)))
+
+ let flippedV = CaptureTransform.flipAnnotationsV(annotations, canvasHeight: 50)
+ XCTAssertEqual(flippedV[0].kind, .rectangle(rect: CGRect(x: 10, y: 20, width: 30, height: 10)))
+ XCTAssertEqual(flippedV[1].kind, .arrow(start: CGPoint(x: 5, y: 42), end: CGPoint(x: 60, y: 10)))
+
+ let scaled = CaptureTransform.scaleAnnotations(
+ annotations,
+ from: CGSize(width: 100, height: 50),
+ to: CGSize(width: 200, height: 100)
+ )
+ XCTAssertEqual(scaled[0].kind, .rectangle(rect: CGRect(x: 20, y: 40, width: 60, height: 20)))
+ XCTAssertEqual(scaled[1].kind, .arrow(start: CGPoint(x: 10, y: 16), end: CGPoint(x: 120, y: 80)))
+ XCTAssertEqual(scaled[2].kind, .number(center: CGPoint(x: 100, y: 50), radius: 12, value: 1))
+ }
+
+ func testExpandAndCombineTransformsPreserveCapturePointPlacement() throws {
+ let red = try makeSolidImage(width: 2, height: 2, red: 255, green: 0, blue: 0)
+ let blue = NSColor(calibratedRed: 0, green: 0, blue: 1, alpha: 1)
+ let capture = Capture(image: red, scale: 1)
+
+ let expanded = try XCTUnwrap(
+ CaptureTransform.expand(capture, top: 1, left: 2, bottom: 1, right: 1, fill: blue)
+ )
+ XCTAssertEqual(expanded.pointSize, CGSize(width: 5, height: 4))
+ XCTAssertEqual(try pixelRGBA(in: expanded.image, x: 0, y: 0).b, 255)
+ XCTAssertEqual(try pixelRGBA(in: expanded.image, x: 2, y: 1).r, 255)
+
+ let model = EditorModel(capture: capture)
+ let annotation = Annotation(
+ kind: .rectangle(rect: CGRect(x: 0.25, y: 0.5, width: 1, height: 1)),
+ style: AnnotationStyle(color: .red, lineWidth: 2, fontSize: 18)
+ )
+ model.add(annotation)
+ model.expandCanvas(top: 1, left: 2, bottom: 1, right: 1, fill: blue)
+ XCTAssertEqual(
+ model.annotations.first?.kind,
+ .rectangle(rect: CGRect(x: 2.25, y: 1.5, width: 1, height: 1))
+ )
+
+ let base = Capture(image: try makeSolidImage(width: 5, height: 5, red: 0, green: 0, blue: 255), scale: 1)
+ let other = Capture(image: red, scale: 1)
+ let combined = try XCTUnwrap(CaptureTransform.combine(base: base, other: other, into: CGRect(x: 1, y: 2, width: 2, height: 2)))
+ XCTAssertEqual(try pixelRGBA(in: combined.image, x: 0, y: 0).b, 255)
+ XCTAssertEqual(try pixelRGBA(in: combined.image, x: 1, y: 2).r, 255)
+ }
+
+ func testCensorEraseSamplesOutsideRingAndRetinaPointCoordinates() throws {
+ let image = try makeImage(width: 8, height: 8) { x, y in
+ if (2..<6).contains(x), (2..<6).contains(y) {
+ return (255, 0, 0, 255)
+ }
+ return (0, 220, 0, 255)
+ }
+
+ let sampled = try XCTUnwrap(
+ CensorEraseSampler.averageSurroundingColor(
+ in: CGRect(x: 1, y: 1, width: 2, height: 2),
+ image: image,
+ pointSize: CGSize(width: 4, height: 4)
+ )
+ )
+ XCTAssertLessThan(sampled.red, 0.05)
+ XCTAssertGreaterThan(sampled.green, 0.80)
+ XCTAssertLessThan(sampled.blue, 0.05)
+
+ let fullImageSample = CensorEraseSampler.averageSurroundingColor(
+ in: CGRect(x: 0, y: 0, width: 4, height: 4),
+ image: image,
+ pointSize: CGSize(width: 4, height: 4)
+ )
+ XCTAssertNil(fullImageSample)
+ }
+
+ func testRemoveBackgroundMakesWindowMatteTransparentAndPreservesForeground() throws {
+ let image = try makeImage(width: 20, height: 20) { x, y in
+ if (6..<14).contains(x), (6..<14).contains(y) {
+ return (220, 20, 40, 255)
+ }
+ return (145, 145, 145, 255)
+ }
+ let capture = Capture(image: image, scale: 1)
+
+ let cleaned = try XCTUnwrap(CaptureTransform.removeBackground(capture, tolerance: 4))
+ let corner = try pixelRGBA(in: cleaned.image, x: 0, y: 0)
+ let edge = try pixelRGBA(in: cleaned.image, x: 19, y: 19)
+ let foreground = try pixelRGBA(in: cleaned.image, x: 10, y: 10)
+
+ XCTAssertEqual(corner.a, 0)
+ XCTAssertEqual(edge.a, 0)
+ XCTAssertEqual(foreground.a, 255)
+ XCTAssertGreaterThan(foreground.r, foreground.g)
+ }
+
+ func testCaptureFileNameTemplateSanitizesTokensAndIncrementsIndex() {
+ let previousTemplate = CapturePreferences.fileNameTemplate
+ let previousIndex = CapturePreferences.fileNameIndex
+ defer {
+ CapturePreferences.fileNameTemplate = previousTemplate
+ CapturePreferences.fileNameIndex = previousIndex
+ }
+
+ CapturePreferences.fileNameTemplate = "{app}-{window}-{date}-{time}-{month}-{index}"
+ CapturePreferences.fileNameIndex = 7
+
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = .current
+ let date = calendar.date(
+ from: DateComponents(
+ timeZone: .current,
+ year: 2026,
+ month: 8,
+ day: 11,
+ hour: 9,
+ minute: 7,
+ second: 6
+ )
+ )!
+
+ let name = CaptureFileName.make(
+ extension: "png",
+ appName: " Preview/App ",
+ windowTitle: "A:B?C",
+ date: date
+ )
+
+ XCTAssertEqual(name, "Preview-App-A-B-C-2026-08-11-09.07.06-2026-08-7.png")
+ XCTAssertEqual(CapturePreferences.fileNameIndex, 8)
+ }
+
+ func testPreviousAreaPreferencesRememberCaptureAndRecordingSelections() throws {
+ let previousSelectionDisplayID = CapturePreferences.lastSelectionDisplayID
+ let previousSelectionX = CapturePreferences.lastSelectionX
+ let previousSelectionY = CapturePreferences.lastSelectionY
+ let previousSelectionWidth = CapturePreferences.lastSelectionWidth
+ let previousSelectionHeight = CapturePreferences.lastSelectionHeight
+ let previousRecordingDisplayID = CapturePreferences.lastRecordingDisplayID
+ let previousRecordingX = CapturePreferences.lastRecordingX
+ let previousRecordingY = CapturePreferences.lastRecordingY
+ let previousRecordingWidth = CapturePreferences.lastRecordingWidth
+ let previousRecordingHeight = CapturePreferences.lastRecordingHeight
+ defer {
+ CapturePreferences.lastSelectionDisplayID = previousSelectionDisplayID
+ CapturePreferences.lastSelectionX = previousSelectionX
+ CapturePreferences.lastSelectionY = previousSelectionY
+ CapturePreferences.lastSelectionWidth = previousSelectionWidth
+ CapturePreferences.lastSelectionHeight = previousSelectionHeight
+ CapturePreferences.lastRecordingDisplayID = previousRecordingDisplayID
+ CapturePreferences.lastRecordingX = previousRecordingX
+ CapturePreferences.lastRecordingY = previousRecordingY
+ CapturePreferences.lastRecordingWidth = previousRecordingWidth
+ CapturePreferences.lastRecordingHeight = previousRecordingHeight
+ }
+
+ CapturePreferences.lastSelectionDisplayID = 0
+ CapturePreferences.lastSelectionWidth = 240
+ CapturePreferences.lastSelectionHeight = 120
+ XCTAssertFalse(CapturePreferences.hasPreviousArea)
+
+ CapturePreferences.lastSelectionDisplayID = 91
+ CapturePreferences.lastSelectionWidth = 1
+ CapturePreferences.lastSelectionHeight = 120
+ XCTAssertFalse(CapturePreferences.hasPreviousArea)
+
+ let selectionRect = CGRect(x: 12.5, y: 24.25, width: 320.5, height: 180.75)
+ let frozen = FrozenScreen(
+ id: 91,
+ screen: try XCTUnwrap(NSScreen.main),
+ image: try makeTestImage(width: 8, height: 8),
+ scale: 1,
+ pointSize: CGSize(width: 8, height: 8),
+ windows: []
+ )
+ CapturePreferences.rememberSelection(SelectionResult(screen: frozen, rectInPoints: selectionRect))
+ XCTAssertTrue(CapturePreferences.hasPreviousArea)
+ XCTAssertEqual(CapturePreferences.lastSelectionDisplayID, 91)
+ XCTAssertEqual(CapturePreferences.lastSelectionRect, selectionRect)
+
+ CapturePreferences.lastRecordingDisplayID = 0
+ CapturePreferences.lastRecordingWidth = 200
+ CapturePreferences.lastRecordingHeight = 100
+ XCTAssertFalse(CapturePreferences.hasPreviousRecordingArea)
+
+ CapturePreferences.lastRecordingDisplayID = 92
+ CapturePreferences.lastRecordingWidth = 200
+ CapturePreferences.lastRecordingHeight = 1
+ XCTAssertFalse(CapturePreferences.hasPreviousRecordingArea)
+
+ let recordingRect = CGRect(x: 44, y: 55, width: 640, height: 360)
+ CapturePreferences.rememberRecordingSelection(displayID: 92, rect: recordingRect)
+ XCTAssertTrue(CapturePreferences.hasPreviousRecordingArea)
+ XCTAssertEqual(CapturePreferences.lastRecordingDisplayID, 92)
+ XCTAssertEqual(CapturePreferences.lastRecordingX, recordingRect.origin.x)
+ XCTAssertEqual(CapturePreferences.lastRecordingY, recordingRect.origin.y)
+ XCTAssertEqual(CapturePreferences.lastRecordingWidth, recordingRect.width)
+ XCTAssertEqual(CapturePreferences.lastRecordingHeight, recordingRect.height)
+ }
+
+ func testRetinaScaleDownPreferenceResizesCaptureToPointDimensions() throws {
+ let previousScaleDown = CapturePreferences.scaleDownRetina
+ defer { CapturePreferences.scaleDownRetina = previousScaleDown }
+
+ let source = Capture(image: try makeTestImage(width: 20, height: 10), scale: 2)
+ let engine = CaptureEngine()
+
+ CapturePreferences.scaleDownRetina = false
+ let original = engine.applyRetinaPreference(source)
+ XCTAssertEqual(original.scale, 2)
+ XCTAssertEqual(original.image.width, 20)
+ XCTAssertEqual(original.image.height, 10)
+ XCTAssertEqual(original.pointSize, CGSize(width: 10, height: 5))
+
+ CapturePreferences.scaleDownRetina = true
+ let scaled = engine.applyRetinaPreference(source)
+ XCTAssertEqual(scaled.scale, 1)
+ XCTAssertEqual(scaled.image.width, 10)
+ XCTAssertEqual(scaled.image.height, 5)
+ XCTAssertEqual(scaled.pointSize, CGSize(width: 10, height: 5))
+
+ let oneBy = engine.applyRetinaPreference(Capture(image: try makeTestImage(width: 7, height: 6), scale: 1))
+ XCTAssertEqual(oneBy.scale, 1)
+ XCTAssertEqual(oneBy.image.width, 7)
+ XCTAssertEqual(oneBy.image.height, 6)
+ }
+
+ func testSelectionAspectPresetGeometryHonorsRatiosAndShiftBypass() {
+ XCTAssertEqual(SelectionAspectPreset.allCases.map(\.label), ["Free", "1:1", "4:3", "16:9"])
+ XCTAssertEqual(SelectionAspectPreset.free.next, .square)
+ XCTAssertEqual(SelectionAspectPreset.square.next, .standard)
+ XCTAssertEqual(SelectionAspectPreset.standard.next, .widescreen)
+ XCTAssertEqual(SelectionAspectPreset.widescreen.next, .free)
+
+ let start = CGPoint(x: 100, y: 100)
+ let draggedUpLeft = CGPoint(x: 60, y: 80)
+ XCTAssertEqual(
+ SelectionGeometry.rect(
+ start: start,
+ snappedEnd: draggedUpLeft,
+ aspectPreset: .standard,
+ bypassPreset: false
+ ),
+ CGRect(x: 60, y: 70, width: 40, height: 30)
+ )
+ XCTAssertEqual(
+ SelectionGeometry.rect(
+ start: start,
+ snappedEnd: draggedUpLeft,
+ aspectPreset: .standard,
+ bypassPreset: true
+ ),
+ CGRect(x: 60, y: 80, width: 40, height: 20)
+ )
+
+ XCTAssertEqual(
+ SelectionGeometry.rect(
+ start: CGPoint(x: 10, y: 10),
+ snappedEnd: CGPoint(x: 35, y: 60),
+ aspectPreset: .square,
+ bypassPreset: false
+ ),
+ CGRect(x: 10, y: 10, width: 50, height: 50)
+ )
+ XCTAssertEqual(
+ SelectionGeometry.rect(
+ start: CGPoint(x: 10, y: 10),
+ snappedEnd: CGPoint(x: 30, y: 25),
+ aspectPreset: .free,
+ bypassPreset: false
+ ),
+ CGRect(x: 10, y: 10, width: 20, height: 15)
+ )
+ }
+
+ private func makeTestImage(width: Int, height: Int) throws -> CGImage {
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+ let bytesPerRow = width * 4
+ var bytes = [UInt8](repeating: 0, count: bytesPerRow * height)
+
+ for y in 0.. CGImage {
+ try makeImage(width: width, height: height) { x, y in
+ let globalX = UInt64(x + offsetX)
+ let globalY = UInt64(y + offsetY)
+ let seed = (globalX &* 73_856_093) ^ (globalY &* 19_349_663)
+ return (
+ UInt8(truncatingIfNeeded: seed),
+ UInt8(truncatingIfNeeded: seed >> 8),
+ UInt8(truncatingIfNeeded: seed >> 16),
+ 255
+ )
+ }
+ }
+
+ private func makeSolidImage(width: Int, height: Int, red: UInt8, green: UInt8, blue: UInt8) throws -> CGImage {
+ try makeImage(width: width, height: height) { _, _ in
+ (red, green, blue, 255)
+ }
+ }
+
+ private func makeImage(
+ width: Int,
+ height: Int,
+ colorSpace: CGColorSpace? = nil,
+ pixel: (Int, Int) -> (UInt8, UInt8, UInt8, UInt8)
+ ) throws -> CGImage {
+ let colorSpace = colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+ let bytesPerRow = width * 4
+ var bytes = [UInt8](repeating: 0, count: bytesPerRow * height)
+
+ for y in 0.. (r: UInt8, g: UInt8, b: UInt8, a: UInt8) {
+ let width = image.width
+ let height = image.height
+ let bytesPerRow = width * 4
+ var bytes = [UInt8](repeating: 0, count: bytesPerRow * height)
+ let rendered = bytes.withUnsafeMutableBytes { buffer -> Bool in
+ guard let context = CGContext(
+ data: buffer.baseAddress,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
+ ) else { return false }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return true
+ }
+ guard rendered, x >= 0, y >= 0, x < width, y < height else {
+ throw XCTSkip("Could not sample test image pixel")
+ }
+ let offset = y * bytesPerRow + x * 4
+ return (bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3])
+ }
+
+ private func makeQRCodeImage(payload: String, size: Int) throws -> CGImage {
+ let generator = CIFilter(name: "CIQRCodeGenerator")
+ generator?.setValue(payload.data(using: .utf8), forKey: "inputMessage")
+ generator?.setValue("M", forKey: "inputCorrectionLevel")
+
+ guard let qrImage = generator?.outputImage else {
+ throw NSError(domain: "ParcelTests", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Could not generate QR image.",
+ ])
+ }
+
+ let falseColor = CIFilter(name: "CIFalseColor")
+ falseColor?.setValue(qrImage, forKey: kCIInputImageKey)
+ falseColor?.setValue(CIColor.black, forKey: "inputColor0")
+ falseColor?.setValue(CIColor.white, forKey: "inputColor1")
+
+ guard let colored = falseColor?.outputImage else {
+ throw NSError(domain: "ParcelTests", code: 2, userInfo: [
+ NSLocalizedDescriptionKey: "Could not color QR image.",
+ ])
+ }
+
+ let scale = CGFloat(size) / colored.extent.width
+ let scaled = colored.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
+ guard let image = CIContext().createCGImage(scaled, from: scaled.extent) else {
+ throw NSError(domain: "ParcelTests", code: 3, userInfo: [
+ NSLocalizedDescriptionKey: "Could not render QR image.",
+ ])
+ }
+ return image
+ }
+
+ private func makeTestRecordingMP4(at url: URL, frameCount: Int) async throws {
+ let writer = try LegacyRecordingWriter(url: url, videoSize: CGSize(width: 64, height: 48), monoAudio: true)
+ for frame in 0.. CMSampleBuffer {
+ var pixelBuffer: CVPixelBuffer?
+ let attributes: [String: Any] = [
+ kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA),
+ kCVPixelBufferWidthKey as String: width,
+ kCVPixelBufferHeightKey as String: height,
+ kCVPixelBufferCGImageCompatibilityKey as String: true,
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
+ ]
+ let pixelStatus = CVPixelBufferCreate(
+ kCFAllocatorDefault,
+ width,
+ height,
+ kCVPixelFormatType_32BGRA,
+ attributes as CFDictionary,
+ &pixelBuffer
+ )
+ guard pixelStatus == kCVReturnSuccess, let pixelBuffer else {
+ throw NSError(domain: "ParcelTests", code: Int(pixelStatus), userInfo: [
+ NSLocalizedDescriptionKey: "Could not create test pixel buffer.",
+ ])
+ }
+
+ CVPixelBufferLockBaseAddress(pixelBuffer, [])
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
+ guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
+ throw NSError(domain: "ParcelTests", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Could not lock test pixel buffer.",
+ ])
+ }
+ let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
+ let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
+ for y in 0.. String? {
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
+ return CGImageSourceGetType(source) as String?
+ }
+}
diff --git a/Tests/ParcelUITests/ParcelUITests.swift b/Tests/ParcelUITests/ParcelUITests.swift
new file mode 100644
index 0000000..d53ddee
--- /dev/null
+++ b/Tests/ParcelUITests/ParcelUITests.swift
@@ -0,0 +1,200 @@
+import AppKit
+import CoreGraphics
+import XCTest
+
+/// Parcel QA harness. Fails loudly when Screen Recording TCC does not match the current binary.
+final class ParcelUITests: XCTestCase {
+
+ private var app: XCUIApplication!
+ private let evidence = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appendingPathComponent("qa-evidence", isDirectory: true)
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ try FileManager.default.createDirectory(at: evidence, withIntermediateDirectories: true)
+ app = XCUIApplication(bundleIdentifier: "dev.parable.Parcel")
+ app.launch()
+ sleep(2)
+ dismissBlockingAlerts()
+ dismissWelcomeIfPresent()
+ }
+
+ override func tearDownWithError() throws {
+ app?.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
+ app?.terminate()
+ }
+
+ private func snap(_ name: String) {
+ let shot = XCUIScreen.main.screenshot()
+ let attachment = XCTAttachment(screenshot: shot)
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ try? shot.pngRepresentation.write(to: evidence.appendingPathComponent("\(name).png"))
+ }
+
+ private func dismissBlockingAlerts() {
+ let ok = app.buttons["OK"]
+ if ok.waitForExistence(timeout: 2) { ok.click(); sleep(1) }
+ }
+
+ private func dismissWelcomeIfPresent() {
+ let welcome = app.windows["Welcome to Parcel"]
+ guard welcome.waitForExistence(timeout: 2) else { return }
+ snap("00-welcome")
+ for _ in 0..<4 {
+ if app.buttons["Get Started"].exists { app.buttons["Get Started"].click(); break }
+ if app.buttons["Continue"].exists { app.buttons["Continue"].click() }
+ else if app.buttons["Skip"].exists { app.buttons["Skip"].click() }
+ Thread.sleep(forTimeInterval: 0.5)
+ }
+ }
+
+ private func openMenuBarMenu() {
+ dismissBlockingAlerts()
+ let item = app.statusItems.element(boundBy: 0)
+ XCTAssertTrue(item.waitForExistence(timeout: 5))
+ if item.isHittable {
+ item.click()
+ } else if !clickSystemStatusItem() {
+ clickMenuBarCoordinateFallback(from: item.frame)
+ }
+ XCTAssertTrue(
+ app.menuItems["Capture Region"].waitForExistence(timeout: 5),
+ "Parcel menu did not open from the menu bar status item."
+ )
+ }
+
+ private func clickSystemStatusItem() -> Bool {
+ let systemUI = XCUIApplication(bundleIdentifier: "com.apple.systemuiserver")
+ let candidates = [
+ systemUI.menuBars.statusItems["MenuBarIcon"],
+ systemUI.statusItems["MenuBarIcon"],
+ systemUI.statusItems.element(boundBy: 0),
+ ]
+
+ for candidate in candidates where candidate.exists {
+ if candidate.isHittable {
+ candidate.click()
+ } else {
+ postMouseClick(at: clampedMenuPoint(from: candidate.frame))
+ }
+ if app.menuItems["Capture Region"].waitForExistence(timeout: 2) { return true }
+ }
+ return false
+ }
+
+ private func clickMenuBarCoordinateFallback(from frame: CGRect) {
+ let screen = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1728, height: 1117)
+ let x = min(max(finite(frame.midX, fallback: screen.maxX - 20), screen.minX + 12), screen.maxX - 12)
+ for y in [screen.maxY - 12, screen.minY + 12] {
+ postMouseClick(at: CGPoint(x: x, y: y))
+ if app.menuItems["Capture Region"].waitForExistence(timeout: 2) { return }
+ }
+ }
+
+ private func clampedMenuPoint(from frame: CGRect) -> CGPoint {
+ let screen = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1728, height: 1117)
+ return CGPoint(
+ x: min(max(finite(frame.midX, fallback: screen.maxX - 20), screen.minX + 12), screen.maxX - 12),
+ y: min(max(finite(frame.midY, fallback: screen.maxY - 12), screen.minY + 12), screen.maxY - 12)
+ )
+ }
+
+ private func finite(_ value: CGFloat, fallback: CGFloat) -> CGFloat {
+ value.isFinite ? value : fallback
+ }
+
+ private func postMouseClick(at point: CGPoint) {
+ let source = CGEventSource(stateID: .hidSystemState)
+ CGEvent(
+ mouseEventSource: source,
+ mouseType: .leftMouseDown,
+ mouseCursorPosition: point,
+ mouseButton: .left
+ )?.post(tap: .cghidEventTap)
+ usleep(50_000)
+ CGEvent(
+ mouseEventSource: source,
+ mouseType: .leftMouseUp,
+ mouseCursorPosition: point,
+ mouseButton: .left
+ )?.post(tap: .cghidEventTap)
+ }
+
+ private func clickMenuItem(_ title: String) {
+ let item = app.menuItems[title]
+ XCTAssertTrue(item.waitForExistence(timeout: 5), "Missing: \(title)")
+ item.click()
+ }
+
+ private func assertPreferencesDidNotOpen(file: StaticString = #file, line: UInt = #line) {
+ let prefs = app.windows["Parcel Preferences"]
+ if prefs.waitForExistence(timeout: 2) {
+ snap("FAIL-preferences-instead-of-capture")
+ XCTFail(
+ "Capture opened Preferences — Screen Recording TCC does not match this build. " +
+ "Remove Parcel in System Settings → Screen Recording, click +, choose the current Parcel.app, quit & reopen.",
+ file: file, line: line
+ )
+ }
+ }
+
+ // MARK: - Core
+
+ func test01AppLaunches() throws {
+ snap("01-app-launched")
+ }
+
+ func test02MenuBarExtraOpensWithExpectedItems() throws {
+ openMenuBarMenu()
+ snap("02-menu-open")
+ for title in ["Capture Region", "Capture All Displays", "Preferences…", "Capture History…"] {
+ XCTAssertTrue(app.menuItems[title].exists)
+ }
+ }
+
+ func test03CaptureRegionShowsOverlay() throws {
+ openMenuBarMenu()
+ clickMenuItem("Capture Region")
+ sleep(2)
+ snap("03-after-capture-region")
+ assertPreferencesDidNotOpen()
+ app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
+ sleep(1)
+ snap("03-after-esc")
+ }
+
+ func test04PreferencesPermissionStatus() throws {
+ openMenuBarMenu()
+ clickMenuItem("Preferences…")
+ sleep(2)
+ snap("04-preferences")
+ let granted = app.staticTexts["Granted"].waitForExistence(timeout: 5)
+ if !granted {
+ snap("04-FAIL-permission-not-granted")
+ XCTFail("Preferences still shows Screen Recording as not granted for this binary.")
+ }
+ }
+
+ func test05CaptureAllDisplaysOpensEditor() throws {
+ openMenuBarMenu()
+ clickMenuItem("Capture All Displays")
+ sleep(4)
+ snap("05-after-all-displays")
+ assertPreferencesDidNotOpen()
+ let editor = app.windows.containing(.staticText, identifier: "Create").firstMatch
+ XCTAssertTrue(editor.waitForExistence(timeout: 10), "Editor toolbar should appear")
+ }
+
+ func test06CaptureHistoryOpens() throws {
+ openMenuBarMenu()
+ clickMenuItem("Capture History…")
+ sleep(1)
+ snap("06-capture-history")
+ XCTAssertTrue(app.windows.containing(.staticText, identifier: "Capture History").firstMatch.waitForExistence(timeout: 5))
+ }
+}
diff --git a/Website/README.md b/Website/README.md
index be82f36..ee56739 100644
--- a/Website/README.md
+++ b/Website/README.md
@@ -13,7 +13,38 @@ Same stack as [Parable](https://github.com/bswxyz/parable): Next.js App Router,
| Fonts | Geist Sans, Geist Mono, Instrument Serif |
| Theme | next-themes (dark default) |
-Visual components (`DitherAurora`, `ShimmerButton`, `VelocityMarquee`) are adapted from the Parable registry.
+## Brand colors
+
+Edit **`lib/theme.ts`** — one file controls accent, secondary, aurora gradient, and backgrounds site-wide:
+
+```ts
+export const theme = {
+ accent: "#5ee4b5", // mint — CTAs, glow
+ secondary: "#8b5cf6", // violet — icons, links
+ tertiary: "#ec4899", // fuchsia — gradient stops
+ ink: "#070708", // hero/footer background
+ aurora: ["#8b5cf6", "#5ee4b5", "#ec4899"],
+};
+```
+
+Variables are injected on `` in `app/layout.tsx` as `--brand-*` CSS custom properties.
+
+
+## Pages
+
+| Route | Purpose |
+|-------|---------|
+| `/` | Product-led marketing landing — workflow, capabilities, privacy, download |
+| `/docs` | User documentation index |
+| `/docs/getting-started` | Install & first Capture |
+| `/docs/capture` | Freeze-then-select, scroll stitch |
+| `/docs/editor` | Tools, Layers, Beautify, export |
+| `/docs/recording` | MP4, trim, GIF |
+| `/docs/shortcuts` | Keyboard reference |
+| `/docs/privacy` | On-device Vision, permissions |
+| `/docs/supabase` | Optional upload setup |
+
+Contributor architecture docs remain in the repo [`docs/`](../docs/) folder.
## Develop
@@ -47,6 +78,10 @@ Static output lands in `out/` — deployed to [parcel.parable.dev](https://parce
| `public/downloads/Parcel.zip` | macOS app download |
| `public/appcast.xml` | Sparkle update feed |
| `public/og.svg` | Open Graph image |
+| `public/media/hero-workflow.mp4` | Silent staged Parcel workflow loop |
+| `public/media/hero-workflow-poster.webp` | Reduced-motion and preload fallback |
+| `public/media/workflow-*.webp` | Staged Overlay, Editor, and output campaign stills |
+| `public/assets/hero-marketing.png` | Legacy campaign visual, intentionally unused |
| `public/favicon.svg` | Favicon |
Release builds copy `build/Parcel.zip` → `public/downloads/Parcel.zip` via `Scripts/release.sh`.
diff --git a/Website/app/docs/capture/page.tsx b/Website/app/docs/capture/page.tsx
new file mode 100644
index 0000000..ad36442
--- /dev/null
+++ b/Website/app/docs/capture/page.tsx
@@ -0,0 +1,70 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Capture & Selection",
+ description: "Freeze-then-select Capture, window snap, and scroll stitching.",
+};
+
+export default function CaptureDocPage() {
+ return (
+ <>
+
+
Capture & Selection
+
+ Parcel freezes every display with ScreenCaptureKit, then lets you choose
+ a Selection from those pixels — region drag, window snap,
+ or scroll stitch.
+
+
+
Freeze-then-select
+
+ On hotkey, Parcel captures full-resolution images of all displays and
+ shows them in the Overlay — a borderless full-screen
+ panel per display. You work on frozen pixels, not a live view.
+
+
+
+ Region — click and drag any rectangle
+
+
+ Window snap — hover a highlighted window and click, or
+ press Tab to cycle targets
+
+
+ Cancel — Esc dismisses the Overlay with no
+ Editor
+
+
+
+
Scroll Capture
+
+ For tall content that does not fit on screen, choose{" "}
+ Scroll Capture from the menu bar while the Overlay is
+ active:
+
+
+
Drag a tall Selection region in the Overlay.
+
Scroll the source content and add frames from the menu bar.
+
+ Parcel stitches frames with on-device Vision registration — live preview
+ as frames stack.
+
+
Finish to open the stitched Capture in the Editor.
+
+
+
Multi-display
+
+ One Overlay panel appears per display. Your Selection is cropped from the
+ display where you release — pixels stay at native resolution and retina
+ scale.
+
+
+
+ ScreenCaptureKit requires Screen Recording TCC. If Capture fails after an
+ update, re-check System Settings and quit/reopen Parcel. Use a stable
+ Developer ID signing identity so permission persists across updates.
+
+ >
+ );
+}
diff --git a/Website/app/docs/editor/page.tsx b/Website/app/docs/editor/page.tsx
new file mode 100644
index 0000000..12caf98
--- /dev/null
+++ b/Website/app/docs/editor/page.tsx
@@ -0,0 +1,71 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import { annotationTools } from "@/lib/site";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Editor & Annotations",
+ description: "Fourteen Tools, Layer stack, Beautify, Adjustments, and export.",
+};
+
+export default function EditorDocPage() {
+ return (
+ <>
+
+
Editor & Annotations
+
+ The Editor is where you mark up a Capture, tune output, and copy or save.
+ One render pipeline drives display and export —{" "}
+ on screen = saved.
+
+
+
Fourteen Tools
+
Select a Tool from the toolbar to create Annotations:
+
+ {annotationTools.map((tool) => (
+
{tool}
+ ))}
+
+
+
Click-to-edit
+
+ Switch to Select, click any Annotation, and use the style
+ bar to change stroke, color, arrow style, censor mode, or fill. Move and
+ resize with handles. The Layer stack supports reorder via the layers
+ panel.
+
+
+
Censor modes
+
+
+ Blur / Pixelate / Solid — standard redaction
+
+
+ Erase — samples surrounding Capture pixels
+
+
+ Auto-redact — regex PII and detected faces via on-device
+ Vision
+
+
+
+
Beautify & Adjustments
+
+ Beautify wraps your Canvas with gradient backgrounds,
+ window chrome, padding, radius, and shadow — plus saved brand kits.
+ Adjustments run a Core Image chain on the base Capture
+ (exposure, contrast, saturation, and more).
+
+
+ Adjustments and Beautify are document-level — outside the undo stack. Use
+ each panel's Reset to revert.
+
+
+
Export formats
+
+ Copy or save as PNG, JPEG, HEIC, or TIFF. Format and quality live in the
+ output panel. Re-open any past Capture from history (⌘⇧H) with
+ annotations and settings intact.
+
+ >
+ );
+}
diff --git a/Website/app/docs/getting-started/page.tsx b/Website/app/docs/getting-started/page.tsx
new file mode 100644
index 0000000..0e779ae
--- /dev/null
+++ b/Website/app/docs/getting-started/page.tsx
@@ -0,0 +1,81 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Quick start",
+ description: "Install Parcel, grant Screen Recording, and take your first Capture.",
+};
+
+export default function GettingStartedPage() {
+ return (
+ <>
+
+
Quick start
+
+ Parcel lives in your menu bar. After install, grant Screen Recording once,
+ quit and reopen — then press ⌘⇧2 to Capture.
+
+
+
1. Download & install
+
+ Download the signed Release build from the{" "}
+ direct download or build from source
+ with Xcode. Drag Parcel.app to Applications.
+
+
+ Once published to a tap:{" "}
+ brew install --cask parcel
+
+
+
2. First launch
+
+
Complete the welcome onboarding flow.
+
+ When prompted, open System Settings → Privacy & Security →
+ Screen Recording and enable Parcel.
+
+
+ Quit and reopen Parcel — TCC permissions only apply
+ after restart.
+
+
+
+
3. Take your first Capture
+
+
+ Press ⌘⇧2 (configurable in Preferences) from any app.
+
+
+ Every display freezes. Drag a region, press Tab to snap to
+ a window, or choose Scroll Capture from the menu bar.
+
+
+ Release to open the Editor with your Selection cropped
+ at full resolution.
+
+
+ Mark up, then ⌘C to copy or ⌘S to save. What you
+ see on screen is exactly what exports.
+
+
+
+
+ Parcel uses Carbon global hotkeys — not CGEventTap — so you
+ never need Accessibility access for Capture.
+
+
+
+ >
+ );
+}
diff --git a/Website/app/docs/layout.tsx b/Website/app/docs/layout.tsx
new file mode 100644
index 0000000..c6d56b4
--- /dev/null
+++ b/Website/app/docs/layout.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from "next";
+import { DocsShell } from "@/components/docs/docs-shell";
+
+export const metadata: Metadata = {
+ title: "Documentation",
+ description:
+ "User guides for Parcel — install, Capture, annotate, record, and upload on macOS.",
+};
+
+export default function DocsLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+ Parcel · User guides
+
+
+ Everything you need to{" "}
+
+ Capture
+ {" "}
+ with confidence.
+
+
+ Install, permissions, workflows, and integrations — written for
+ daily macOS use, not just contributors.
+
+
+
+ {children}
+
+ );
+}
diff --git a/Website/app/docs/page.tsx b/Website/app/docs/page.tsx
new file mode 100644
index 0000000..18c59e1
--- /dev/null
+++ b/Website/app/docs/page.tsx
@@ -0,0 +1,103 @@
+import Link from "next/link";
+import { ArrowRight } from "lucide-react";
+import { docHref, docSections } from "@/lib/docs";
+
+export default function DocsIndexPage() {
+ return (
+ <>
+
+ Parcel is a native macOS Capture studio from the{" "}
+ Parable ecosystem. These guides cover
+ what end users need — install, permissions, Capture workflows, and
+ optional upload.
+
+
+
Start here
+
+
+
+ Recommended
+
+
Quick start
+
+ Download, grant Screen Recording, and take your first Capture in
+ under two minutes.
+
+
+ Read guide
+
+
+
+
+ Reference
+
+
Keyboard shortcuts
+
+ Global hotkey, Overlay controls, and Editor commands in one place.
+
+
+ View shortcuts
+
+
+
+
+
All guides
+
+ {docSections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.pages.map((page) => (
+
+
+
+
{page.title}
+
+ {page.description}
+
+
+
+
+
+ ))}
+
+
+ ))}
+
+
+
Contributor docs
+
+ Architecture, parity checklists, and agent guides live in the GitHub
+ repository:
+
+ >
+ );
+}
diff --git a/Website/app/docs/privacy/page.tsx b/Website/app/docs/privacy/page.tsx
new file mode 100644
index 0000000..923fd5c
--- /dev/null
+++ b/Website/app/docs/privacy/page.tsx
@@ -0,0 +1,64 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Privacy & permissions",
+ description: "On-device Vision, sandbox entitlements, and what leaves your Mac.",
+};
+
+export default function PrivacyDocPage() {
+ return (
+ <>
+
+
Privacy & permissions
+
+ Parcel is private by default. OCR, face detection, translation, and regex
+ redaction use Apple on-device frameworks only — no cloud AI, no telemetry.
+
+
+
What stays local
+
+
All Capture and recording pixels
+
Vision OCR, QR, face finding, and translation (macOS 26+)
+
Regex PII inspection and auto-redact suggestions
+
Capture history documents on disk
+
+
+
Permissions Parcel uses
+
+
+
+
Screen Recording
+
+ Required — ScreenCaptureKit for Capture and recording
+
+
+
+ Required
+
+
+
+
+
Accessibility
+
+ Not used — Carbon hotkeys avoid event taps
+
+
+ Not needed
+
+
+
+
Sandbox
+
+ Release builds are sandboxed with user-selected file read/write for Save
+ panels. Upload uses HTTPS to your configured Supabase project only when
+ you trigger it.
+
+
+
+ Parcel never sends Captures to a network LLM or third-party AI service.
+ Any future "AI" feature must be provably on-device.
+
+ >
+ );
+}
diff --git a/Website/app/docs/recording/page.tsx b/Website/app/docs/recording/page.tsx
new file mode 100644
index 0000000..1c1f12a
--- /dev/null
+++ b/Website/app/docs/recording/page.tsx
@@ -0,0 +1,54 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Screen recording",
+ description: "Region recording, trim editor, GIF export, and audio options.",
+};
+
+export default function RecordingDocPage() {
+ return (
+ <>
+
+
Screen recording
+
+ Record a screen region as MP4 with system audio, optional microphone on
+ macOS 15+, click highlights, trim, and local GIF export.
+
+
+
Start a recording
+
+
+ Choose Record Region from the menu bar (or the
+ equivalent menu item).
+
+
Drag a Selection in the Overlay — same freeze-then-select model.
+
+ Recording runs at 30, 60, or 120 fps with system audio included.
+
+
Stop from the menu bar control or hotkey.
+
+
+
Trim & export
+
+ After stopping, the trim window opens. Set in/out points, then save MP4
+ or export a lightweight GIF for sharing.
+
+
+
macOS 15+ extras
+
+
Microphone capture alongside system audio
+
Click highlights during recording
+
+
+
+ On macOS 13, an AVFoundation fallback path exists but is written-but-untested
+ on the primary dev machine. See the repo's{" "}
+
+ macOS 13 QA notes
+
+ .
+
+ >
+ );
+}
diff --git a/Website/app/docs/shortcuts/page.tsx b/Website/app/docs/shortcuts/page.tsx
new file mode 100644
index 0000000..b2da83e
--- /dev/null
+++ b/Website/app/docs/shortcuts/page.tsx
@@ -0,0 +1,65 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import { shortcuts } from "@/lib/site";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Keyboard shortcuts",
+ description: "Global hotkeys, Overlay controls, and Editor commands for Parcel.",
+};
+
+export default function ShortcutsDocPage() {
+ return (
+ <>
+
+
+ Esc — cancel current draw or exit utility Tool
+
+
+
+
+ Undo (⌘Z) covers Annotation content only —
+ create, delete, move, resize, and restyle. Adjustments and Beautify are
+ document-level settings with panel Resets.
+
+ >
+ );
+}
diff --git a/Website/app/docs/supabase/page.tsx b/Website/app/docs/supabase/page.tsx
new file mode 100644
index 0000000..c48f27e
--- /dev/null
+++ b/Website/app/docs/supabase/page.tsx
@@ -0,0 +1,57 @@
+import { DocsBreadcrumb, DocsCallout } from "@/components/docs/docs-shell";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Supabase upload",
+ description: "Configure optional Storage upload from the Editor.",
+};
+
+export default function SupabaseDocPage() {
+ return (
+ <>
+
+
Supabase upload
+
+ Upload is optional. Configure your own Supabase Storage bucket once —
+ then upload from the Editor and copy a public link.
+
+
+
Setup
+
+
+ Create a Supabase project and a public Storage bucket.
+
+
+ Open Preferences → Upload in Parcel.
+
+
+ Paste your project URL, anon key, bucket name, and optional custom
+ public base URL.
+
+
Save — Parcel stores credentials locally on your Mac.
+
+
+
Upload from the Editor
+
+ After marking up a Capture, choose Upload from the Editor toolbar. Parcel
+ writes to your bucket via the Supabase Storage REST API and copies the
+ public URL to the clipboard.
+
+
+
+ Use a bucket policy appropriate for your content. The anon key is stored
+ in UserDefaults — treat it like any client-side secret with RLS policies
+ that match your threat model.
+
+
+
Troubleshooting
+
+
Verify the bucket is public or your base URL resolves correctly.
+
Check object size limits on your Supabase plan.
+
+ Ensure network access is allowed — sandboxed builds use standard HTTPS.
+
+ Everything you need.{" "}
+ Nothing you don't.
+ >
+ }
+ subtitle="One menu bar app for Capture, annotation, censoring, beautify, recording, scroll-stitching, and optional upload — all on your Mac."
/>
+
{Icon && (
-
+
@@ -113,7 +128,7 @@ export function FeatureBento() {
{item.title}
-
+
{item.body}
diff --git a/Website/components/focused-capabilities.tsx b/Website/components/focused-capabilities.tsx
new file mode 100644
index 0000000..7d408a7
--- /dev/null
+++ b/Website/components/focused-capabilities.tsx
@@ -0,0 +1,47 @@
+import Image from "next/image";
+import Link from "next/link";
+import { ArrowRight } from "lucide-react";
+import { focusedCapabilities } from "@/lib/site";
+
+export function FocusedCapabilities() {
+ return (
+
+
+
+
+
Capabilities
+
+ A complete Capture workflow. No feature fog.
+
+
+
+ Explore the documentation
+
+
+
+
+ {focusedCapabilities.map((capability) => (
+
+
+
+
+
+
+
{capability.eyebrow}
+
{capability.title}
+
{capability.body}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/Website/components/guides-grid.tsx b/Website/components/guides-grid.tsx
index 8505873..acdf15e 100644
--- a/Website/components/guides-grid.tsx
+++ b/Website/components/guides-grid.tsx
@@ -1,9 +1,52 @@
"use client";
-import { BookOpen } from "lucide-react";
+import Link from "next/link";
+import { ArrowUpRight, BookOpen } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { SectionHeader } from "@/components/section-header";
-import { guides } from "@/lib/site";
+
+const guideLinks = [
+ {
+ title: "Redact sensitive text locally",
+ body: "Inspect Capture → Recognize Text → Censor Detected Sensitive Text. Regex runs on-device via Vision.",
+ href: "/docs/editor",
+ },
+ {
+ title: "Scroll a long page",
+ body: "In the Overlay choose Scroll Capture, drag a tall region, then add frames from the menu bar and finish.",
+ href: "/docs/capture",
+ },
+ {
+ title: "Click-to-edit any annotation",
+ body: "Switch to Select, click an Annotation, then use the style bar to change stroke, color, arrow style, or censor mode.",
+ href: "/docs/editor",
+ },
+ {
+ title: "Record with system audio",
+ body: "Choose Record Region from the menu bar, drag a Selection in the Overlay, then save. MP4 includes system audio.",
+ href: "/docs/recording",
+ },
+ {
+ title: "Translate text on-device",
+ body: "Inspect Capture → Recognize Text → Translate On-Device. Uses Apple Translation on macOS 26+ — nothing leaves your Mac.",
+ href: "/docs/privacy",
+ },
+ {
+ title: "Save a brand kit",
+ body: "Enable Beautify, tune padding and gradient, then save a named kit from the Beautify panel.",
+ href: "/docs/editor",
+ },
+ {
+ title: "Upload to Supabase",
+ body: "Paste project URL, anon key, and bucket in Preferences. Upload from the Editor copies the link.",
+ href: "/docs/supabase",
+ },
+ {
+ title: "Export a recording as GIF",
+ body: "After stopping a recording, open the trim window and export GIF for lightweight sharing.",
+ href: "/docs/recording",
+ },
+] as const;
export function GuidesGrid() {
const reduce = useReducedMotion();
@@ -11,31 +54,54 @@ export function GuidesGrid() {
return (
-
-
- {guides.map((guide, i) => (
-
+
+ Recipes for daily{" "}
+ Capture work.
+ >
+ }
+ subtitle="Each card links to the full guide in the docs."
+ />
+
+ All documentation
+
+
+
+ {guideLinks.map((guide, i) => (
+
-
-
{guide.title}
-
- {guide.body}
-
-
+
+
+
+
+
+
+ {guide.title}
+
+
+ {guide.body}
+
+
+
))}
diff --git a/Website/components/home-hero.tsx b/Website/components/home-hero.tsx
index 1cd884a..9807372 100644
--- a/Website/components/home-hero.tsx
+++ b/Website/components/home-hero.tsx
@@ -1,161 +1,128 @@
-"use client";
-
import Link from "next/link";
-import { ArrowRight } from "lucide-react";
-import { motion, useReducedMotion } from "motion/react";
-import { DitherAurora } from "@/components/parable/dither-aurora";
-import { ShimmerButton } from "@/components/parable/shimmer-button";
-import { VelocityMarquee } from "@/components/parable/velocity-marquee";
-import { DeviceFrame, EditorPreview } from "@/components/editor-preview";
-import { Badge } from "@/components/section-header";
-import { DOWNLOAD_URL, logos, stats } from "@/lib/site";
-
-const marqueeItems = logos.map((name) => (
-
- {name}
- /
-
-));
-
-const fadeUp = {
- hidden: { opacity: 0, y: 18 },
- show: (i: number) => ({
- opacity: 1,
- y: 0,
- transition: {
- type: "spring" as const,
- stiffness: 220,
- damping: 26,
- delay: i * 0.08,
- },
- }),
-};
+import Image from "next/image";
+import { Apple, ArrowDown, Code2 } from "lucide-react";
+import { PrimaryButton } from "@/components/primary-button";
+import { DOWNLOAD_URL, GITHUB_URL } from "@/lib/site";
export function HomeHero() {
- const reduce = useReducedMotion();
-
return (
-
-
+
+
+ );
+}
diff --git a/Website/components/privacy-section.tsx b/Website/components/privacy-section.tsx
index b212899..3211470 100644
--- a/Website/components/privacy-section.tsx
+++ b/Website/components/privacy-section.tsx
@@ -1,77 +1,101 @@
-"use client";
+import Link from "next/link";
+import { ArrowRight, Check, LockKeyhole, ShieldCheck } from "lucide-react";
-import { Shield } from "lucide-react";
-import { motion, useReducedMotion } from "motion/react";
-import { SectionHeader } from "@/components/section-header";
+const localFacts = [
+ "Vision recognition and face finding run locally",
+ "Translation uses Apple on-device frameworks",
+ "Supabase upload happens only when you configure and invoke it",
+ "Sandboxed Release builds use minimal entitlements",
+] as const;
export function PrivacySection() {
- const reduce = useReducedMotion();
-
return (
-
-
-
-
-
-
-
-
- Vision and Core ML process Captures locally
-
-
-
- Supabase upload is optional and user-configured
-
-
-
- Sandboxed Release builds with minimal entitlements
-
+
+
+
+
+
+
+
+ Privacy is architecture
+
+
+ Your pixels stay on your Mac.
+
+
+ Parcel does not send a Capture to a cloud model. Recognition,
+ redaction assistance, face detection, QR reading, and supported
+ translation stay on-device unless you explicitly choose an upload.
+
+
+ {localFacts.map((fact) => (
+
+
+
+
+ {fact}
+
+ ))}
-
+
+ Read the privacy documentation
+
+
-
-
-
- System Settings → Privacy & Security
-
-
-
-
-
-
Screen Recording
-
Required for Capture
+
+
+
+
+
+
+
+
-
- Granted
-
+
Privacy & Security
-
-
-
Accessibility
-
Not required
+
+
+
+
+
+
+
+
+
Screen Recording
+
Required by macOS so Parcel can make a Capture.
+
+
+ Required
+
+
+
+
+
+
+
+
+
+
+
Accessibility
+
Not used. The global hotkey is registered through Carbon.
+
+
+ —
+
+
+
+
+
+
+
Cloud AI requests
+
No Capture data sent for AI processing
+
+ 0
+
- —
-
- Carbon hotkeys · no Accessibility permission needed
-
+
+ Keyboard-first{" "}
+ design.
+ >
+ }
+ subtitle="Every action has a shortcut. The global Capture hotkey is configurable in Preferences."
+ />
+
+ Full reference
+
+
-
-
+
+
Shortcut
-
+
Action
{shortcuts.map((row) => (
-
+
-
+
{row.keys}
-
+
{row.action}
diff --git a/Website/components/showcase-section.tsx b/Website/components/showcase-section.tsx
new file mode 100644
index 0000000..acb3e51
--- /dev/null
+++ b/Website/components/showcase-section.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { motion, useReducedMotion } from "motion/react";
+import { DeviceFrame, EditorPreview } from "@/components/editor-preview";
+import { SectionHeader } from "@/components/section-header";
+
+export function ShowcaseSection() {
+ const reduce = useReducedMotion();
+
+ return (
+
+
+
+
+ A Capture studio you'd expect from a{" "}
+ professional tool.
+ >
+ }
+ subtitle="Fourteen annotation Tools, on-device Vision, Beautify, and one render pipeline — what you see is exactly what copies or saves."
+ />
+
+
+
+
+
+