diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5bfdf2..332ba2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,17 +20,42 @@ jobs: - name: Resolve packages run: xcodebuild -resolvePackageDependencies -project Parcel.xcodeproj -scheme Parcel + - name: Verify scripts + run: | + 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/ship-status.sh \ + Scripts/final-local-qa.sh + + - name: Verify workflows + run: bash Scripts/verify-workflows.sh + + - name: Verify no network AI + run: bash Scripts/verify-no-network-ai.sh + - name: Build Debug run: | xcodebuild -project Parcel.xcodeproj -scheme Parcel -configuration Debug \ -derivedDataPath .derivedData build \ CODE_SIGN_IDENTITY="-" CODE_SIGNING_ALLOWED=YES + - name: Run unit tests + run: | + xcodebuild test -project Parcel.xcodeproj -scheme ParcelUnit -configuration Debug \ + -derivedDataPath .derivedData-unit \ + CODE_SIGN_IDENTITY="-" CODE_SIGNING_ALLOWED=YES + build-website: runs-on: ubuntu-latest - defaults: - run: - working-directory: Website steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -38,4 +63,8 @@ jobs: node-version: "22" cache: npm cache-dependency-path: Website/package-lock.json - - run: npm ci && npm run build + - name: Build website + working-directory: Website + run: npm ci && npm run lint && npm run build + - name: Verify website export artifact + run: bash Scripts/verify-website-export-artifact.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa79e4a..57a7040 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - "v*" +permissions: + contents: write + jobs: release: runs-on: macos-15 @@ -14,29 +17,89 @@ jobs: - name: Install XcodeGen run: brew install xcodegen + - name: Import Developer ID signing certificate + env: + DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + + if [[ -z "${DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64:-}" ]]; then + echo "Missing DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 secret." >&2 + exit 1 + fi + if [[ -z "${DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD:-}" ]]; then + echo "Missing DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD secret." >&2 + exit 1 + fi + + KEYCHAIN_PASSWORD="${KEYCHAIN_PASSWORD:-$(uuidgen)}" + CERTIFICATE_PATH="$RUNNER_TEMP/developer-id-application.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/parcel-signing.keychain-db" + trap 'rm -f "$CERTIFICATE_PATH"' EXIT + + echo -n "$DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64" | base64 --decode -o "$CERTIFICATE_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" \ + -P "$DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + security default-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + - name: Build, export, notarize env: DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + UPDATE_APPCAST: "1" + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + SPARKLE_KEYCHAIN_ACCOUNT: ${{ secrets.SPARKLE_KEYCHAIN_ACCOUNT }} + PARCEL_SCREEN_RECORDING_VERIFIED: ${{ secrets.PARCEL_SCREEN_RECORDING_VERIFIED }} + PARCEL_SECOND_DISPLAY_VERIFIED: ${{ secrets.PARCEL_SECOND_DISPLAY_VERIFIED }} + PARCEL_SUPABASE_URL: ${{ secrets.PARCEL_SUPABASE_URL }} + PARCEL_SUPABASE_ANON_KEY: ${{ secrets.PARCEL_SUPABASE_ANON_KEY }} + PARCEL_SUPABASE_BUCKET: ${{ secrets.PARCEL_SUPABASE_BUCKET }} + PARCEL_MACOS13_VM_VERIFIED: ${{ secrets.PARCEL_MACOS13_VM_VERIFIED }} run: | - chmod +x Scripts/release.sh + chmod +x Scripts/*.sh ./Scripts/release.sh - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - uses: actions/setup-node@v4 with: - files: build/Parcel.zip - generate_release_notes: true + node-version: "22" + cache: npm + cache-dependency-path: Website/package-lock.json - name: Build website working-directory: Website run: | npm ci + npm run lint npm run build + - name: Verify website export artifact + run: bash Scripts/verify-website-export-artifact.sh + - name: Upload website artifact uses: actions/upload-artifact@v4 with: name: website-dist path: Website/out + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: build/Parcel.zip + generate_release_notes: true + + - name: Delete signing keychain + if: always() + run: security delete-keychain "$RUNNER_TEMP/parcel-signing.keychain-db" || true diff --git a/.gitignore b/.gitignore index cdbb176..1c5bd67 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ xcuserdata/ ## XcodeGen — the project is generated from project.yml. ## Regenerate locally with `xcodegen generate`. Parcel.xcodeproj/ +Notable.xcodeproj/ ## Swift Package Manager .build/ @@ -31,5 +32,20 @@ Package.resolved ## Misc *.log .derivedData-release/ -.vercel-deploy*.json +.vercel-* +.deploy-* +.mcp-* +qa-evidence/ .cursor/ + +# Local deployment and macOS UI-automation helpers (not release inputs) +Scripts/deploy-parts-helper.mjs +Scripts/mcp-deploy.cjs +Scripts/mcp-deploy.mjs +Scripts/click-add-screen-recording.swift +Scripts/dump-settings-ax.swift +Scripts/enable-screen-recording.swift +Scripts/install-dev.sh + +# Legacy, unused local artwork retained only for migration reference +Website/public/assets/notable-permission.svg diff --git a/README.md b/README.md index 5ae1c42..fcb7bf3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Live site + Live site macOS 13+ 14 tools 0 cloud AI @@ -17,9 +17,9 @@

- Website + Website  ·  - Download + Download  ·  Docs  ·  @@ -28,7 +28,7 @@ Contributing

- + Parcel — Capture, mark up, and ship @@ -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
- Part of the Parable ecosystem · Parable components & templates · parcel.parable.dev + Part of the Parable ecosystem · Parable components & templates · parcel-zeta-silk.vercel.app
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/.*<pubDate>\([^<]*\)<.*/\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" <<EOF +# Manual proof + +VERIFIED: $verified + +macOS: 26.0 +Parcel app path: /Applications/Parcel.app +Packet ZIP SHA-256: packet-hash +Final ZIP SHA-256: final-hash +Tester: Release QA +Date: 2026-08-12 + +Evidence notes: +- Synthetic verifier self-test note. +EOF +} + +write_complete_packet() { + local dir="$1" + mkdir -p "$dir"/{release,website,ui,hardware,upload,macos13} + + printf '# Parcel Release Gate Handoff\n' >"$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' +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleExecutable</key> + <string>Parcel</string> + <key>CFBundleIdentifier</key> + <string>dev.parable.Parcel</string> + <key>CFBundleName</key> + <string>Parcel</string> + <key>CFBundleDisplayName</key> + <string>Parcel</string> + <key>CFBundleShortVersionString</key> + <string>9.8.7</string> + <key>CFBundleVersion</key> + <string>654</string> +</dict> +</plist> +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/.*<sparkle:shortVersionString>\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)" +actual_build_version="$(sed -n 's/.*<sparkle:version>\([^<]*\)<.*/\1/p' "$appcast_path" | head -n 1)" +actual_title="$(sed -n 's/.*<title>\([^<]*\)<.*/\1/p' "$appcast_path" | sed -n '2p')" +actual_pub_date="$(sed -n 's/.*<pubDate>\([^<]*\)<.*/\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 '<rss><channel><item><title>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/.*<sparkle:version>\([^<]*\)<.*/\1/p' "$updated_appcast" | head -n 1)" +updated_short_version="$(sed -n 's/.*<sparkle:shortVersionString>\([^<]*\)<.*/\1/p' "$updated_appcast" | head -n 1)" +updated_pub_date="$(sed -n 's/.*<pubDate>\([^<]*\)<.*/\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" == *"<key>com.apple.security.app-sandbox</key><true/>"* ]]; 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" == *"<key>com.apple.security.files.user-selected.read-write</key><true/>"* ]]; then + pass "User-selected read/write entitlement present" + else + fail "User-selected read/write entitlement missing" + fi + if [[ "$entitlements_compact" == *"<key>com.apple.security.network.client</key><true/>"* ]]; 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/.*<title>\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)" + appcast_version="$(sed -n 's/.*<sparkle:shortVersionString>\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)" + appcast_build="$(sed -n 's/.*<sparkle:version>\([^<]*\)<.*/\1/p' "$APPCAST_PATH" | head -n 1)" + appcast_pub_date="$(sed -n 's/.*<pubDate>\([^<]*\)<.*/\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" <<EOF +# $title + +VERIFIED: no + +macOS: +Parcel app path: +Packet ZIP SHA-256: $zip_sha +Final ZIP SHA-256: +Tester: +Date: + +Required checks: +$checks + +Evidence notes: +- +EOF +} + +write_manual_template "ui/screen-recording-final.md" "Screen Recording TCC Proof" "- Exact final/test Parcel.app path is granted Screen Recording. +- Parcel was quit and reopened after permission change. +- Region Capture opens the Overlay instead of Preferences. +- Region Recording opens the recording Selection flow instead of Preferences." + +write_manual_template "ui/computer-use-final.md" "Computer Use UI Proof" "- Menu bar opens and expected Capture/Recording/History/Preferences actions are visible. +- Overlay can be opened and cancelled. +- Editor opens from a Capture and exposes Tool/output controls. +- Preferences opens and hotkey/upload/recording settings are inspectable. +- History opens and displays/restores a local Capture. +- Recording flow reaches trim/export UI. +- Evidence was collected with node_repl + @oai/sky, or an equivalent release-machine manual proof is attached. +- If @oai/sky cannot drive Parcel on the release machine, include a normal-window sanity result and the exact Parcel app-path/SystemUIServer error output." + +write_manual_template "hardware/second-display-final.md" "Second Display QA Proof" "- External display is connected and detected. +- Capture All Displays creates a stitched Capture containing both displays. +- Multi-display Overlay placement and cancellation work. +- Single-display behavior still works after disconnect/reconnect." + +write_manual_template "upload/supabase-live-final.md" "Live Supabase QA Proof" "- Preferences accepts project URL, anon key, and bucket. +- Upload succeeds and copies a public URL. +- Public URL opens the uploaded Capture. +- Disabled/not-configured state blocks upload with a useful message. +- Bad credentials surface a useful error. Redact credentials from this note." + +write_manual_template "macos13/fallback-final.md" "macOS 13 Fallback QA Proof" "- macOS 13 VM or secondary Mac is identified. +- Capture uses the SCStream fallback path. +- Recording uses the legacy AVFoundation writer path. +- Core flows pass: region Capture to Editor, all 14 Tools smoke, History restore. +- Any fallback-specific issue is linked or noted." + +cat >"$HANDOFF_PATH" <<EOF +# Parcel Release Gate Handoff + +Generated: $generated_at + +Repository state: +- Branch: \`$branch\` +- Commit: \`$short_sha\` +- Worktree: \`$worktree_state\` +- Evidence folder: \`$EVIDENCE_DIR\` + +Current automated gate checks: +- Release gate preflight: \`$preflight_status\` — \`release/gate-preflight.log\` +- Current website ZIP verifier: \`$verify_status\` — \`release/verify-release-current.log\` + +Use this packet on the release-capable machine to close the external gates before publishing the +public website ZIP. Do not paste secrets into this file; capture only command output, screenshots, +or short notes that prove the gate was satisfied. + +## Required Gate Evidence + +| Gate | Required proof | Evidence path | +|---|---|---| +| Developer ID identity | \`security find-identity -v -p codesigning\` shows a \`Developer ID Application\` identity for the expected Team ID. | \`release/developer-id-identity.txt\` | +| Release preflight | \`Scripts/verify-release-gates.sh\` reports \`0 failed, 0 gated\`. | \`release/gate-preflight-final.log\` | +| Public release build | \`UPDATE_APPCAST=1 DEVELOPMENT_TEAM=... NOTARYTOOL_PROFILE=... Scripts/release.sh\` completes. | \`release/release-final.log\` | +| Notarization | \`notarytool submit --wait\` output includes accepted status. | \`release/notarization.log\` | +| Stapling | \`xcrun stapler validate build/export/Parcel.app\` succeeds. | \`release/stapler-validate.log\` | +| Gatekeeper | \`spctl -a -vvv -t install\` accepts the extracted final \`Parcel.app\`. | \`release/spctl-final.log\` | +| Final ZIP verifier | \`Scripts/verify-release.sh Website/public/downloads/Parcel.zip\` reports \`0 failed, 0 gated\`. | \`release/verify-release-final.log\` | +| Final appcast | \`Scripts/update-appcast.sh\` or release output shows non-placeholder length/signature for the final ZIP. | \`release/appcast-update-final.log\` | +| Website export payload | \`npm --prefix Website run build\` and \`Scripts/verify-website-export-artifact.sh\` pass after final ZIP/appcast update. | \`website/build-final.log\`, \`website/export-artifact-final.log\` | +| Screen Recording TCC | Exact final/test \`Parcel.app\` path is granted Screen Recording and Capture/Recording UI opens Overlay instead of Preferences. Include \`VERIFIED: yes\`. | \`ui/screen-recording-final.md\` | +| Computer Use UI pass | Menu bar, Overlay, Editor, Preferences, History, and Recording UI are driven with \`node_repl\` + \`@oai/sky\` or equivalent release-machine manual proof. Include \`VERIFIED: yes\`. | \`ui/computer-use-final.md\` | +| Second display | External display is connected and multi-display Capture/all-display stitch is verified. Include \`VERIFIED: yes\`. | \`hardware/second-display-final.md\` | +| Live Supabase | Upload success copies a public URL; bad credentials surface a useful error; disabled state blocks upload. Include \`VERIFIED: yes\`. | \`upload/supabase-live-final.md\` | +| macOS 13 fallback | macOS 13 VM/secondary Mac verifies SCStream Capture fallback, legacy recording writer fallback, and core Capture/Editor/History flows. Include \`VERIFIED: yes\`. | \`macos13/fallback-final.md\` | + +## Release-Machine Command Sequence + +\`\`\`sh +set -euo pipefail + +export DEVELOPMENT_TEAM=QFH99B6X5V +export NOTARYTOOL_PROFILE=parcel-release +export SPARKLE_KEYCHAIN_ACCOUNT=parcel.parable.dev + +# Set these to 1 only after the corresponding manual proof is captured. +export PARCEL_SCREEN_RECORDING_VERIFIED=1 +export PARCEL_SECOND_DISPLAY_VERIFIED=1 +export PARCEL_MACOS13_VM_VERIFIED=1 + +# Set for live upload QA. +export PARCEL_SUPABASE_URL=https://example.supabase.co +export PARCEL_SUPABASE_ANON_KEY=... +export PARCEL_SUPABASE_BUCKET=captures + +Scripts/verify-release-gates.sh | tee "$EVIDENCE_DIR/release/gate-preflight-final.log" + +UPDATE_APPCAST=1 \\ +DEVELOPMENT_TEAM="\$DEVELOPMENT_TEAM" \\ +NOTARYTOOL_PROFILE="\$NOTARYTOOL_PROFILE" \\ +Scripts/release.sh | tee "$EVIDENCE_DIR/release/release-final.log" + +Scripts/verify-release.sh Website/public/downloads/Parcel.zip \\ + | tee "$EVIDENCE_DIR/release/verify-release-final.log" +npm --prefix Website run build | tee "$EVIDENCE_DIR/website/build-final.log" +Scripts/verify-website-export-artifact.sh | tee "$EVIDENCE_DIR/website/export-artifact-final.log" + +Scripts/verify-release-gate-evidence.sh "$EVIDENCE_DIR" \\ + | tee "$EVIDENCE_DIR/release-gate-evidence-final.log" +\`\`\` + +## Manual UI Proof Notes + +- Use the exact final/test \`Parcel.app\` path when granting Screen Recording. +- Quit and reopen Parcel after changing TCC permissions. +- Record the macOS version, app path, and final ZIP hash in each manual note. +- For Supabase, redact the anon key and project details if the evidence will be committed. +- Manual proof templates are created as \`VERIFIED: no\`. Change them to \`VERIFIED: yes\` only + after all required checks and metadata fields are complete. +- Do not mark the release ready until \`Scripts/ship-status.sh\` and the final release verifier agree + that all gates are closed. +EOF + +echo "Wrote $HANDOFF_PATH" +echo " preflight=$preflight_status" +echo " release_verifier=$verify_status" diff --git a/Sources/Parcel/App/AfterCapturePlan.swift b/Sources/Parcel/App/AfterCapturePlan.swift new file mode 100644 index 0000000..457da2f --- /dev/null +++ b/Sources/Parcel/App/AfterCapturePlan.swift @@ -0,0 +1,62 @@ +import Foundation + +/// After-Capture override for "Capture Area & ..." hotkeys that still respect preferences +/// when the dedicated action is layered on top of the default matrix. +enum CaptureIntent: Equatable { + case standard + case forceCopy + case forceEditor + case forcePin + case forceSave +} + +struct AfterCapturePreferences: Equatable { + var useQuickAccess: Bool + var copy: Bool + var upload: Bool + var save: Bool + var pin: Bool + var openEditor: Bool + + static var current: AfterCapturePreferences { + AfterCapturePreferences( + useQuickAccess: CapturePreferences.useQuickAccess, + copy: CapturePreferences.afterCaptureCopy, + upload: CapturePreferences.afterCaptureUpload, + save: CapturePreferences.afterCaptureSave, + pin: CapturePreferences.afterCapturePin, + openEditor: CapturePreferences.afterCaptureOpenEditor + ) + } +} + +struct AfterCapturePlan: Equatable { + var copy = false + var upload = false + var save = false + var pin = false + var openEditor = false + var showQuickAccess = false + + static func make(preferences: AfterCapturePreferences, intent: CaptureIntent) -> 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<AnyCancellable>() + 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..<lower.height).contains(overlap) ? overlap : nil + if horizontal { + let shift = abs(Int(transform.tx.rounded())) + let overlap = lower.width - shift + return (16..<lower.width).contains(overlap) ? overlap : nil + } else { + let shift = abs(Int(transform.ty.rounded())) + let overlap = lower.height - shift + return (16..<lower.height).contains(overlap) ? overlap : nil + } } catch { return nil } @@ -99,7 +112,6 @@ private enum ScrollCaptureStitcher { space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } - // Match the Capture's top-left coordinate convention while drawing with Core Graphics. context.translateBy(x: 0, y: CGFloat(height)) context.scaleBy(x: 1, y: -1) context.draw(upper, in: CGRect(x: 0, y: 0, width: width, height: upper.height)) @@ -109,18 +121,40 @@ private enum ScrollCaptureStitcher { ) return context.makeImage() } + + private static func horizontallyStack(left: CGImage, right: CGImage, overlap: Int) -> 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..<columnSamples { + let offset = (sample + 1) * overlap / (columnSamples + 1) + let leftCol = width - overlap + offset + let rightCol = offset + for row in stride(from: 0, to: height, by: rowStep) { + let leftIndex = (row * width + leftCol) * 4 + let rightIndex = (row * width + rightCol) * 4 + total += abs(Int(bytes[leftIndex]) - Int(other.bytes[rightIndex])) + total += abs(Int(bytes[leftIndex + 1]) - Int(other.bytes[rightIndex + 1])) + total += abs(Int(bytes[leftIndex + 2]) - Int(other.bytes[rightIndex + 2])) + count += 3 + } + } + return count > 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..<height { + for x in 0..<width { + let i = (y * width + x) * 4 + let dr = abs(Int(pixels[i]) - avgR) + let dg = abs(Int(pixels[i + 1]) - avgG) + let db = abs(Int(pixels[i + 2]) - avgB) + if dr <= tol && dg <= tol && db <= tol { + pixels[i + 3] = 0 + } + } + } + + guard let provider = CGDataProvider(data: Data(pixels) as CFData), + let result = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo( + rawValue: CGImageAlphaInfo.premultipliedLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue + ), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + else { return nil } + return Capture(image: result, scale: capture.scale) + } + + // MARK: Annotation remapping + + static func offsetAnnotations(_ annotations: [Annotation], by delta: CGPoint) -> [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..<maxY { + sample(x: minX - 1, y: y) + sample(x: maxX, y: y) } guard count > 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<EventHotKeyID>.size, + nil, + &hotKeyID + ) let manager = Unmanaged<HotKeyManager>.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<KeystrokeHUDView>? + + 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<Void, Error>) 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 @@ <true/> <key>NSMicrophoneUsageDescription</key> <string>Parcel can include microphone audio in screen recordings when you start a recording.</string> + <key>NSCameraUsageDescription</key> + <string>Parcel can show your webcam as a picture-in-picture overlay while recording your screen.</string> <key>NSPrincipalClass</key> <string>NSApplication</string> <key>NSHumanReadableCopyright</key> @@ -37,6 +39,38 @@ <key>SUFeedURL</key> <string>https://parcel.parable.dev/appcast.xml</string> <key>SUPublicEDKey</key> - <string>REPLACE_WITH_SPARKLE_EDDSA_PUBLIC_KEY</string> + <string>KzMPoJWvyEPSZnLcyE6AcaMU1HpBrLRZnP8xs5XdpHI=</string> + <key>CFBundleURLTypes</key> + <array> + <dict> + <key>CFBundleURLName</key> + <string>dev.parable.Parcel</string> + <key>CFBundleURLSchemes</key> + <array> + <string>parcel</string> + </array> + </dict> + </array> + <key>UTExportedTypeDeclarations</key> + <array> + <dict> + <key>UTTypeIdentifier</key> + <string>dev.parable.parcel-project</string> + <key>UTTypeDescription</key> + <string>Parcel Project</string> + <key>UTTypeConformsTo</key> + <array> + <string>public.data</string> + <string>public.directory</string> + </array> + <key>UTTypeTagSpecification</key> + <dict> + <key>public.filename-extension</key> + <array> + <string>parcel</string> + </array> + </dict> + </dict> + </array> </dict> </plist> 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.files.user-selected.read-write</key> + <true/> +</dict> +</plist> 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 @@ <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/> + <!-- Sparkle appcast + optional Supabase upload --> + <key>com.apple.security.network.client</key> + <true/> + <!-- Optional microphone track when recording --> + <key>com.apple.security.device.audio-input</key> + <true/> + <!-- Optional webcam PiP while recording --> + <key>com.apple.security.device.camera</key> + <true/> </dict> </plist> 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..<height { + for x in 0..<width { + let offset = y * bytesPerRow + x * 4 + bytes[offset] = UInt8((x * 255) / max(width - 1, 1)) + bytes[offset + 1] = UInt8((y * 255) / max(height - 1, 1)) + bytes[offset + 2] = 180 + bytes[offset + 3] = 255 + } + } + + guard let provider = CGDataProvider(data: Data(bytes) as CFData), + let image = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + else { + throw XCTSkip("Could not create test CGImage") + } + return image + } + + private func makePatternedImage(width: Int, height: Int, offsetX: Int, offsetY: Int) throws -> 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..<height { + for x in 0..<width { + let value = pixel(x, y) + let offset = y * bytesPerRow + x * 4 + bytes[offset] = value.0 + bytes[offset + 1] = value.1 + bytes[offset + 2] = value.2 + bytes[offset + 3] = value.3 + } + } + + guard let provider = CGDataProvider(data: Data(bytes) as CFData), + let image = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGBitmapInfo( + rawValue: CGImageAlphaInfo.premultipliedLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue + ), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + else { + throw XCTSkip("Could not create test CGImage") + } + return image + } + + private func pixelRGBA(in image: CGImage, x: Int, y: Int) throws -> (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..<frameCount { + writer.receiveSampleForTesting( + try makeVideoSampleBuffer(width: 64, height: 48, frame: frame), + type: .screen + ) + } + writer.flushSamplesForTesting() + try await writer.finish() + } + + private 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: "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..<height { + for x in 0..<width { + let offset = y * bytesPerRow + x * 4 + bytes[offset] = UInt8((frame * 13 + x) % 256) + bytes[offset + 1] = UInt8((frame * 7 + y) % 256) + bytes[offset + 2] = UInt8((x + y) % 256) + bytes[offset + 3] = 255 + } + } + + var formatDescription: CMVideoFormatDescription? + let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &formatDescription + ) + guard formatStatus == noErr, let formatDescription else { + throw NSError(domain: "ParcelTests", code: Int(formatStatus), userInfo: [ + NSLocalizedDescriptionKey: "Could not create video format description.", + ]) + } + + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: CMTime(value: CMTimeValue(frame), timescale: 30), + decodeTimeStamp: .invalid + ) + var sampleBuffer: CMSampleBuffer? + let sampleStatus = CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: formatDescription, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) + guard sampleStatus == noErr, let sampleBuffer else { + throw NSError(domain: "ParcelTests", code: Int(sampleStatus), userInfo: [ + NSLocalizedDescriptionKey: "Could not create video sample buffer.", + ]) + } + return sampleBuffer + } + + private func imageSourceType(for data: Data) -> 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 `<html>` 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 ( + <> + <DocsBreadcrumb section="Core workflows" title="Capture & Selection" /> + <h1>Capture & Selection</h1> + <p className="docs-lead"> + Parcel freezes every display with ScreenCaptureKit, then lets you choose + a <strong>Selection</strong> from those pixels — region drag, window snap, + or scroll stitch. + </p> + + <h2>Freeze-then-select</h2> + <p> + On hotkey, Parcel captures full-resolution images of all displays and + shows them in the <strong>Overlay</strong> — a borderless full-screen + panel per display. You work on frozen pixels, not a live view. + </p> + <ul> + <li> + <strong>Region</strong> — click and drag any rectangle + </li> + <li> + <strong>Window snap</strong> — hover a highlighted window and click, or + press <kbd>Tab</kbd> to cycle targets + </li> + <li> + <strong>Cancel</strong> — <kbd>Esc</kbd> dismisses the Overlay with no + Editor + </li> + </ul> + + <h2>Scroll Capture</h2> + <p> + For tall content that does not fit on screen, choose{" "} + <strong>Scroll Capture</strong> from the menu bar while the Overlay is + active: + </p> + <ol> + <li>Drag a tall Selection region in the Overlay.</li> + <li>Scroll the source content and add frames from the menu bar.</li> + <li> + Parcel stitches frames with on-device Vision registration — live preview + as frames stack. + </li> + <li>Finish to open the stitched Capture in the Editor.</li> + </ol> + + <h2>Multi-display</h2> + <p> + One Overlay panel appears per display. Your Selection is cropped from the + display where you release — pixels stay at native resolution and retina + scale. + </p> + + <DocsCallout variant="warning" title="Screen Recording permission"> + 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. + </DocsCallout> + </> + ); +} 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 ( + <> + <DocsBreadcrumb section="Core workflows" title="Editor & Annotations" /> + <h1>Editor & Annotations</h1> + <p className="docs-lead"> + The Editor is where you mark up a Capture, tune output, and copy or save. + One render pipeline drives display and export —{" "} + <strong>on screen = saved</strong>. + </p> + + <h2>Fourteen Tools</h2> + <p>Select a Tool from the toolbar to create Annotations:</p> + <ul className="columns-1 sm:columns-2"> + {annotationTools.map((tool) => ( + <li key={tool}>{tool}</li> + ))} + </ul> + + <h2>Click-to-edit</h2> + <p> + Switch to <strong>Select</strong>, 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. + </p> + + <h2>Censor modes</h2> + <ul> + <li> + <strong>Blur / Pixelate / Solid</strong> — standard redaction + </li> + <li> + <strong>Erase</strong> — samples surrounding Capture pixels + </li> + <li> + <strong>Auto-redact</strong> — regex PII and detected faces via on-device + Vision + </li> + </ul> + + <h2>Beautify & Adjustments</h2> + <p> + <strong>Beautify</strong> wraps your Canvas with gradient backgrounds, + window chrome, padding, radius, and shadow — plus saved brand kits. + <strong> Adjustments</strong> run a Core Image chain on the base Capture + (exposure, contrast, saturation, and more). + </p> + <DocsCallout variant="note"> + Adjustments and Beautify are document-level — outside the undo stack. Use + each panel's Reset to revert. + </DocsCallout> + + <h2>Export formats</h2> + <p> + Copy or save as PNG, JPEG, HEIC, or TIFF. Format and quality live in the + output panel. Re-open any past Capture from history (<kbd>⌘⇧H</kbd>) with + annotations and settings intact. + </p> + </> + ); +} 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 ( + <> + <DocsBreadcrumb section="Getting started" title="Quick start" /> + <h1>Quick start</h1> + <p className="docs-lead"> + Parcel lives in your menu bar. After install, grant Screen Recording once, + quit and reopen — then press <kbd>⌘⇧2</kbd> to Capture. + </p> + + <h2>1. Download & install</h2> + <p> + Download the signed Release build from the{" "} + <a href="/downloads/Parcel.zip">direct download</a> or build from source + with Xcode. Drag <strong>Parcel.app</strong> to Applications. + </p> + <DocsCallout variant="tip" title="Homebrew"> + Once published to a tap:{" "} + <code>brew install --cask parcel</code> + </DocsCallout> + + <h2>2. First launch</h2> + <ol> + <li>Complete the welcome onboarding flow.</li> + <li> + When prompted, open <strong>System Settings → Privacy & Security → + Screen Recording</strong> and enable Parcel. + </li> + <li> + <strong>Quit and reopen</strong> Parcel — TCC permissions only apply + after restart. + </li> + </ol> + + <h2>3. Take your first Capture</h2> + <ol> + <li> + Press <kbd>⌘⇧2</kbd> (configurable in Preferences) from any app. + </li> + <li> + Every display freezes. Drag a region, press <kbd>Tab</kbd> to snap to + a window, or choose Scroll Capture from the menu bar. + </li> + <li> + Release to open the <strong>Editor</strong> with your Selection cropped + at full resolution. + </li> + <li> + Mark up, then <kbd>⌘C</kbd> to copy or <kbd>⌘S</kbd> to save. What you + see on screen is exactly what exports. + </li> + </ol> + + <DocsCallout variant="note" title="No Accessibility permission"> + Parcel uses Carbon global hotkeys — not <code>CGEventTap</code> — so you + never need Accessibility access for Capture. + </DocsCallout> + + <h2>Next steps</h2> + <ul> + <li> + <a href="/docs/capture">Capture & Selection</a> — scroll stitch, multi-display + </li> + <li> + <a href="/docs/editor">Editor & Annotations</a> — all fourteen Tools + </li> + <li> + <a href="/docs/shortcuts">Keyboard shortcuts</a> — full reference + </li> + </ul> + </> + ); +} 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 ( + <main id="main" className="border-b"> + <div className="relative overflow-hidden border-b bg-[#0a0a0b]"> + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(139,92,246,0.18),transparent_60%)]" + /> + <div className="relative mx-auto max-w-7xl px-4 py-14 md:py-20"> + <p className="font-mono text-xs uppercase tracking-widest text-zinc-400"> + Parcel · User guides + </p> + <h1 className="mt-3 max-w-2xl text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl"> + Everything you need to{" "} + <em className="font-display font-normal not-italic text-zinc-300"> + Capture + </em>{" "} + with confidence. + </h1> + <p className="mt-4 max-w-xl text-zinc-300"> + Install, permissions, workflows, and integrations — written for + daily macOS use, not just contributors. + </p> + </div> + </div> + <DocsShell>{children}</DocsShell> + </main> + ); +} 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 ( + <> + <p className="docs-lead"> + Parcel is a native macOS Capture studio from the{" "} + <a href="https://parable.dev">Parable</a> ecosystem. These guides cover + what end users need — install, permissions, Capture workflows, and + optional upload. + </p> + + <h2>Start here</h2> + <div className="not-prose my-8 grid gap-4 sm:grid-cols-2"> + <Link + href="/docs/getting-started" + className="group rounded-2xl border bg-card/80 p-5 transition-all hover:border-violet-500/25 hover:shadow-[0_8px_32px_rgba(139,92,246,0.08)]" + > + <p className="font-mono text-[11px] uppercase tracking-widest text-[var(--pb-mint)]"> + Recommended + </p> + <h3 className="mt-2 text-lg font-semibold">Quick start</h3> + <p className="mt-2 text-sm text-muted-foreground"> + Download, grant Screen Recording, and take your first Capture in + under two minutes. + </p> + <span className="mt-4 inline-flex items-center gap-1 text-sm text-violet-400"> + Read guide <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" /> + </span> + </Link> + <Link + href="/docs/shortcuts" + className="group rounded-2xl border bg-card/80 p-5 transition-all hover:border-violet-500/25" + > + <p className="font-mono text-[11px] uppercase tracking-widest text-muted-foreground"> + Reference + </p> + <h3 className="mt-2 text-lg font-semibold">Keyboard shortcuts</h3> + <p className="mt-2 text-sm text-muted-foreground"> + Global hotkey, Overlay controls, and Editor commands in one place. + </p> + <span className="mt-4 inline-flex items-center gap-1 text-sm text-violet-400"> + View shortcuts <ArrowRight className="size-4" /> + </span> + </Link> + </div> + + <h2>All guides</h2> + <div className="not-prose space-y-8"> + {docSections.map((section) => ( + <div key={section.title}> + <h3 className="mb-3 font-mono text-xs uppercase tracking-widest text-muted-foreground"> + {section.title} + </h3> + <ul className="divide-y rounded-2xl border bg-card/50"> + {section.pages.map((page) => ( + <li key={page.slug || "intro"}> + <Link + href={docHref(page.slug)} + className="flex items-center justify-between gap-4 px-5 py-4 transition-colors hover:bg-muted/30" + > + <div> + <p className="font-medium">{page.title}</p> + <p className="mt-0.5 text-sm text-muted-foreground"> + {page.description} + </p> + </div> + <ArrowRight className="size-4 shrink-0 text-muted-foreground" /> + </Link> + </li> + ))} + </ul> + </div> + ))} + </div> + + <h2>Contributor docs</h2> + <p> + Architecture, parity checklists, and agent guides live in the GitHub + repository: + </p> + <ul> + <li> + <a href="https://github.com/bswxyz/notable/blob/main/docs/architecture.md"> + Architecture & render pipeline + </a> + </li> + <li> + <a href="https://github.com/bswxyz/notable/blob/main/docs/QA_CHECKLIST.md"> + QA checklist + </a> + </li> + <li> + <a href="https://github.com/bswxyz/notable/blob/main/AGENTS.md"> + Glossary & build guide + </a> + </li> + </ul> + </> + ); +} 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 ( + <> + <DocsBreadcrumb section="Privacy & integrations" title="Privacy & permissions" /> + <h1>Privacy & permissions</h1> + <p className="docs-lead"> + Parcel is private by default. OCR, face detection, translation, and regex + redaction use Apple on-device frameworks only — no cloud AI, no telemetry. + </p> + + <h2>What stays local</h2> + <ul> + <li>All Capture and recording pixels</li> + <li>Vision OCR, QR, face finding, and translation (macOS 26+)</li> + <li>Regex PII inspection and auto-redact suggestions</li> + <li>Capture history documents on disk</li> + </ul> + + <h2>Permissions Parcel uses</h2> + <div className="not-prose my-6 space-y-3"> + <div className="flex items-center justify-between rounded-xl border bg-card p-4"> + <div> + <p className="font-medium">Screen Recording</p> + <p className="text-sm text-muted-foreground"> + Required — ScreenCaptureKit for Capture and recording + </p> + </div> + <span className="rounded-full bg-amber-500/15 px-3 py-1 font-mono text-xs text-amber-400"> + Required + </span> + </div> + <div className="flex items-center justify-between rounded-xl border bg-card p-4"> + <div> + <p className="font-medium">Accessibility</p> + <p className="text-sm text-muted-foreground"> + Not used — Carbon hotkeys avoid event taps + </p> + </div> + <span className="font-mono text-xs text-muted-foreground">Not needed</span> + </div> + </div> + + <h2>Sandbox</h2> + <p> + 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. + </p> + + <DocsCallout variant="note" title="Hard rule"> + Parcel never sends Captures to a network LLM or third-party AI service. + Any future "AI" feature must be provably on-device. + </DocsCallout> + </> + ); +} 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 ( + <> + <DocsBreadcrumb section="Core workflows" title="Screen recording" /> + <h1>Screen recording</h1> + <p className="docs-lead"> + Record a screen region as MP4 with system audio, optional microphone on + macOS 15+, click highlights, trim, and local GIF export. + </p> + + <h2>Start a recording</h2> + <ol> + <li> + Choose <strong>Record Region</strong> from the menu bar (or the + equivalent menu item). + </li> + <li>Drag a Selection in the Overlay — same freeze-then-select model.</li> + <li> + Recording runs at 30, 60, or 120 fps with system audio included. + </li> + <li>Stop from the menu bar control or hotkey.</li> + </ol> + + <h2>Trim & export</h2> + <p> + After stopping, the trim window opens. Set in/out points, then save MP4 + or export a lightweight GIF for sharing. + </p> + + <h2>macOS 15+ extras</h2> + <ul> + <li>Microphone capture alongside system audio</li> + <li>Click highlights during recording</li> + </ul> + + <DocsCallout variant="tip"> + On macOS 13, an AVFoundation fallback path exists but is written-but-untested + on the primary dev machine. See the repo's{" "} + <a href="https://github.com/bswxyz/notable/blob/main/docs/MACOS13_VM_QA.md"> + macOS 13 QA notes + </a> + . + </DocsCallout> + </> + ); +} 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 ( + <> + <DocsBreadcrumb section="Getting started" title="Keyboard shortcuts" /> + <h1>Keyboard shortcuts</h1> + <p className="docs-lead"> + Default bindings below. Change the Capture hotkey in{" "} + <strong>Preferences → Hotkey</strong>. + </p> + + <div className="not-prose my-8 overflow-hidden rounded-2xl border"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b bg-muted/40 text-left"> + <th className="px-4 py-3 font-mono text-xs uppercase tracking-widest text-muted-foreground"> + Keys + </th> + <th className="px-4 py-3 font-mono text-xs uppercase tracking-widest text-muted-foreground"> + Action + </th> + </tr> + </thead> + <tbody> + {shortcuts.map((row) => ( + <tr key={row.keys} className="border-b last:border-0"> + <td className="px-4 py-3"> + <kbd className="docs-kbd">{row.keys}</kbd> + </td> + <td className="px-4 py-3 text-muted-foreground">{row.action}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <h2>While drawing Annotations</h2> + <ul> + <li> + <kbd>Shift</kbd> — constrain proportions (square, circle, straight line) + </li> + <li> + <kbd>Space</kbd> — reposition shape while drawing + </li> + <li> + <kbd>Esc</kbd> — cancel current draw or exit utility Tool + </li> + </ul> + + <DocsCallout variant="note"> + Undo (<kbd>⌘Z</kbd>) covers <strong>Annotation content only</strong> — + create, delete, move, resize, and restyle. Adjustments and Beautify are + document-level settings with panel Resets. + </DocsCallout> + </> + ); +} 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 ( + <> + <DocsBreadcrumb section="Privacy & integrations" title="Supabase upload" /> + <h1>Supabase upload</h1> + <p className="docs-lead"> + Upload is optional. Configure your own Supabase Storage bucket once — + then upload from the Editor and copy a public link. + </p> + + <h2>Setup</h2> + <ol> + <li> + Create a Supabase project and a <strong>public</strong> Storage bucket. + </li> + <li> + Open <strong>Preferences → Upload</strong> in Parcel. + </li> + <li> + Paste your project URL, anon key, bucket name, and optional custom + public base URL. + </li> + <li>Save — Parcel stores credentials locally on your Mac.</li> + </ol> + + <h2>Upload from the Editor</h2> + <p> + 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. + </p> + + <DocsCallout variant="warning" title="Your credentials"> + 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. + </DocsCallout> + + <h2>Troubleshooting</h2> + <ul> + <li>Verify the bucket is public or your base URL resolves correctly.</li> + <li>Check object size limits on your Supabase plan.</li> + <li> + Ensure network access is allowed — sandboxed builds use standard HTTPS. + </li> + </ul> + </> + ); +} diff --git a/Website/app/globals.css b/Website/app/globals.css index 2e43d2a..ed30457 100644 --- a/Website/app/globals.css +++ b/Website/app/globals.css @@ -6,9 +6,9 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --font-display: var(--font-instrument-serif); + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-display: ui-serif, Georgia, Cambria, "Times New Roman", serif; --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-muted: var(--muted); @@ -33,6 +33,8 @@ --pb-fuchsia: #ec4899; --pb-mint: #5ee4b5; --pb-gold: #f5c451; + --pb-ink: #0a0a0b; + --pb-surface: oklch(0.18 0 0); --pb-ease-out: cubic-bezier(0.22, 1, 0.36, 1); --pb-ease-snap: cubic-bezier(0.16, 1, 0.3, 1); @@ -63,13 +65,19 @@ --secondary: oklch(0.24 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.24 0 0); - --muted-foreground: oklch(0.708 0 0); + --muted-foreground: oklch(0.78 0 0); --accent: oklch(0.24 0 0); --accent-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 10%); + --border: oklch(1 0 0 / 14%); --ring: oklch(0.556 0 0); } +/* Readable copy on dark aurora / ink surfaces */ +.dark-surface { + --surface-muted: oklch(0.82 0 0); + --surface-subtle: oklch(0.68 0 0); +} + @layer base { * { @apply border-border outline-ring/50; @@ -80,6 +88,27 @@ body { @apply bg-background text-foreground; } + ::selection { + background: color-mix(in srgb, var(--brand-secondary) 35%, transparent); + color: inherit; + } +} + +/* Focus visible for keyboard users */ +:focus-visible { + outline: 2px solid color-mix(in srgb, var(--brand-accent) 60%, transparent); + outline-offset: 2px; +} + +/* Subtle film grain — Parable Aether-style texture */ +body::before { + content: ""; + pointer-events: none; + position: fixed; + inset: 0; + z-index: 9999; + opacity: 0.028; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } @media (prefers-reduced-motion: reduce) { @@ -94,3 +123,79 @@ transition-duration: 0.01ms !important; } } + +/* Parcel accent gradient — edit --brand-* in lib/theme.ts */ +.pb-gradient-text { + background: linear-gradient( + 135deg, + var(--brand-secondary) 0%, + var(--brand-tertiary) 45%, + var(--brand-accent) 100% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Legibility on busy dither backgrounds */ +.pb-gradient-glow { + filter: drop-shadow(0 2px 16px rgba(0, 0, 0, 0.45)); +} + +/* Documentation prose */ +.docs-prose { + @apply max-w-3xl text-[15px] leading-relaxed text-muted-foreground; +} + +.docs-prose h1 { + @apply scroll-mt-24 text-3xl font-semibold tracking-tight text-foreground md:text-4xl; +} + +.docs-prose h2 { + @apply mt-10 scroll-mt-24 text-xl font-semibold tracking-tight text-foreground; +} + +.docs-prose h3 { + @apply mt-6 text-base font-semibold text-foreground; +} + +.docs-prose p { + @apply mt-4; +} + +.docs-prose .docs-lead { + @apply text-lg leading-relaxed text-foreground/85; +} + +.docs-prose a { + @apply text-violet-400 underline-offset-4 transition-colors hover:text-violet-300 hover:underline; +} + +.docs-prose ul, +.docs-prose ol { + @apply mt-4 space-y-2 pl-5; +} + +.docs-prose ul { + @apply list-disc; +} + +.docs-prose ol { + @apply list-decimal; +} + +.docs-prose li { + @apply pl-1; +} + +.docs-prose strong { + @apply font-medium text-foreground; +} + +.docs-prose code:not(.docs-kbd) { + @apply rounded-md bg-muted px-1.5 py-0.5 font-mono text-[13px] text-foreground/90; +} + +.docs-kbd { + @apply inline-flex rounded-md border border-border bg-muted/60 px-2 py-0.5 font-mono text-xs text-foreground; +} diff --git a/Website/app/layout.tsx b/Website/app/layout.tsx index e9b6793..64ff2e5 100644 --- a/Website/app/layout.tsx +++ b/Website/app/layout.tsx @@ -1,35 +1,45 @@ +import type { CSSProperties } from "react"; import type { Metadata } from "next"; -import { Geist, Geist_Mono, Instrument_Serif } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { SiteNav } from "@/components/site-nav"; import { SiteFooter } from "@/components/site-footer"; +import { MobileDownloadBar } from "@/components/mobile-download-bar"; +import { theme } from "@/lib/theme"; import { SITE_URL } from "@/lib/site"; -const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); -const instrumentSerif = Instrument_Serif({ - variable: "--font-instrument-serif", - subsets: ["latin"], - weight: "400", - style: "italic", -}); +const brandStyle = { + "--brand-accent": theme.accent, + "--brand-secondary": theme.secondary, + "--brand-tertiary": theme.tertiary, + "--brand-gold": theme.gold, + "--brand-ink": theme.ink, + "--brand-surface": theme.surface, + "--brand-elevated": theme.elevated, +} as CSSProperties; export const metadata: Metadata = { title: { - default: "Parcel — native macOS Capture studio", + default: "Parcel — Capture anything. Make it unmistakable.", template: "%s · Parcel", }, description: - "Freeze your screen, annotate with fourteen tools, censor with on-device Vision, beautify, record, and upload — from Parable. No cloud AI, ever.", + "The native macOS Capture studio for fast Selection, precise Annotation, private redaction, recording, and polished sharing—without cloud AI.", metadataBase: new URL(SITE_URL), + applicationName: "Parcel", + keywords: [ + "macOS Capture app", + "screen recording", + "Annotation", + "on-device OCR", + "private redaction", + "Scroll Capture", + ], + category: "productivity", openGraph: { - title: "Parcel — native macOS Capture studio", + title: "Parcel — Capture anything. Make it unmistakable.", description: - "The Capture studio Apple forgot to ship. MIT licensed, on-device only.", + "Native macOS Capture, precise Annotation, local redaction, recording, and polished output. Free and MIT licensed.", url: SITE_URL, siteName: "Parcel", images: [{ url: "/og.svg", width: 1200, height: 630 }], @@ -38,9 +48,9 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "Parcel — native macOS Capture studio", + title: "Parcel — Capture anything. Make it unmistakable.", description: - "Freeze, annotate, censor, beautify, record — no cloud AI, ever.", + "A native macOS Capture studio with no cloud AI.", images: ["/og.svg"], }, icons: { icon: "/favicon.svg" }, @@ -53,9 +63,16 @@ export default function RootLayout({ <html lang="en" suppressHydrationWarning - className={`${geistSans.variable} ${geistMono.variable} ${instrumentSerif.variable} h-full`} + className="h-full" + style={brandStyle} > - <body className="flex min-h-full flex-col bg-background text-foreground"> + <body className="flex min-h-full flex-col bg-background pb-20 text-foreground md:pb-0"> + <a + href="#main" + className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-[var(--brand-accent)] focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-[var(--brand-ink)]" + > + Skip to content + </a> <ThemeProvider attribute="class" defaultTheme="dark" @@ -65,6 +82,7 @@ export default function RootLayout({ <SiteNav /> <div className="flex-1">{children}</div> <SiteFooter /> + <MobileDownloadBar /> </ThemeProvider> </body> </html> diff --git a/Website/app/page.tsx b/Website/app/page.tsx index 5ce5a00..825d0e3 100644 --- a/Website/app/page.tsx +++ b/Website/app/page.tsx @@ -1,29 +1,48 @@ import type { Metadata } from "next"; import { HomeHero } from "@/components/home-hero"; -import { FeatureBento } from "@/components/feature-bento"; -import { WorkflowSteps } from "@/components/workflow-steps"; +import { VerifiedProof } from "@/components/verified-proof"; +import { WorkflowShowcase } from "@/components/workflow-showcase"; +import { FocusedCapabilities } from "@/components/focused-capabilities"; import { PrivacySection } from "@/components/privacy-section"; -import { ShortcutsTable } from "@/components/shortcuts-table"; -import { GuidesGrid } from "@/components/guides-grid"; -import { InstallSection } from "@/components/install-section"; -import { FaqSection, CtaBanner } from "@/components/faq-section"; +import { DownloadClose } from "@/components/download-close"; +import { DOWNLOAD_URL, SITE_URL } from "@/lib/site"; export const metadata: Metadata = { alternates: { canonical: "/" }, }; export default function HomePage() { + const softwareApplicationJsonLd = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "Parcel", + applicationCategory: "MultimediaApplication", + operatingSystem: "macOS 13 or later", + description: + "A native macOS Capture studio for Selection, Annotation, local redaction, Beautify, recording, and export.", + url: SITE_URL, + downloadUrl: new URL(DOWNLOAD_URL, SITE_URL).toString(), + softwareVersion: "1.0", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + isAccessibleForFree: true, + }; + return ( - <main> + <main id="main"> + <script + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationJsonLd) }} + /> <HomeHero /> - <FeatureBento /> - <WorkflowSteps /> + <VerifiedProof /> + <WorkflowShowcase /> + <FocusedCapabilities /> <PrivacySection /> - <ShortcutsTable /> - <GuidesGrid /> - <InstallSection /> - <FaqSection /> - <CtaBanner /> + <DownloadClose /> </main> ); } diff --git a/Website/app/robots.ts b/Website/app/robots.ts new file mode 100644 index 0000000..17227bc --- /dev/null +++ b/Website/app/robots.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/site"; + +export const dynamic = "force-static"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${SITE_URL}/sitemap.xml`, + host: SITE_URL, + }; +} diff --git a/Website/app/sitemap.ts b/Website/app/sitemap.ts new file mode 100644 index 0000000..834df89 --- /dev/null +++ b/Website/app/sitemap.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/site"; + +export const dynamic = "force-static"; + +const routes = [ + "", + "/docs", + "/docs/getting-started", + "/docs/capture", + "/docs/editor", + "/docs/recording", + "/docs/privacy", + "/docs/shortcuts", + "/docs/supabase", +] as const; + +export default function sitemap(): MetadataRoute.Sitemap { + return routes.map((route) => ({ + url: `${SITE_URL}${route}`, + lastModified: new Date("2026-08-11"), + changeFrequency: route === "" ? "weekly" : "monthly", + priority: route === "" ? 1 : route === "/docs" ? 0.8 : 0.7, + })); +} diff --git a/Website/components/annotation-tools-strip.tsx b/Website/components/annotation-tools-strip.tsx new file mode 100644 index 0000000..a9120b7 --- /dev/null +++ b/Website/components/annotation-tools-strip.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { annotationTools } from "@/lib/site"; + +export function AnnotationToolsStrip() { + const reduce = useReducedMotion(); + + return ( + <motion.div + initial={reduce ? false : { opacity: 0, y: 12 }} + whileInView={reduce ? undefined : { opacity: 1, y: 0 }} + viewport={{ once: true, amount: 0.4 }} + transition={{ type: "spring", stiffness: 220, damping: 26 }} + className="mt-10" + > + <p className="mb-4 font-mono text-[11px] uppercase tracking-widest text-muted-foreground"> + Fourteen Tools · One toolbar + </p> + <div className="flex flex-wrap gap-2"> + {annotationTools.map((tool) => ( + <span + key={tool} + className="inline-flex items-center rounded-full border border-[var(--brand-secondary)]/15 bg-[var(--brand-secondary)]/[0.06] px-3 py-1.5 font-mono text-[11px] text-foreground/80 transition-colors hover:border-[var(--brand-accent)]/30 hover:bg-[var(--brand-accent)]/10 md:text-xs" + > + {tool} + </span> + ))} + </div> + </motion.div> + ); +} diff --git a/Website/components/docs/docs-shell.tsx b/Website/components/docs/docs-shell.tsx new file mode 100644 index 0000000..2875972 --- /dev/null +++ b/Website/components/docs/docs-shell.tsx @@ -0,0 +1,164 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { BookOpen, ChevronRight, Menu, X } from "lucide-react"; +import { docHref, docSections } from "@/lib/docs"; +import { cn } from "@/lib/utils"; + +export function DocsShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = React.useState(false); + + const isActive = (slug: string) => { + const href = docHref(slug); + return pathname === href; + }; + + return ( + <div className="mx-auto flex w-full max-w-7xl flex-1 gap-0 px-4 lg:gap-10"> + <aside className="hidden w-56 shrink-0 lg:block"> + <div className="sticky top-20 pb-16 pt-2"> + <p className="mb-4 flex items-center gap-2 font-mono text-[11px] uppercase tracking-widest text-muted-foreground"> + <BookOpen className="size-3.5" /> + Documentation + </p> + <nav className="space-y-6"> + {docSections.map((section) => ( + <div key={section.title}> + <p className="mb-2 px-2 font-mono text-[10px] uppercase tracking-widest text-muted-foreground/80"> + {section.title} + </p> + <ul className="space-y-0.5"> + {section.pages.map((page) => { + const href = docHref(page.slug); + const active = isActive(page.slug); + return ( + <li key={page.slug || "intro"}> + <Link + href={href} + className={cn( + "flex items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-colors", + active + ? "bg-violet-500/10 font-medium text-violet-300 ring-1 ring-violet-500/20" + : "text-muted-foreground hover:bg-muted/50 hover:text-foreground" + )} + > + {active && ( + <span className="size-1.5 shrink-0 rounded-full bg-[var(--pb-mint)]" /> + )} + {page.title} + </Link> + </li> + ); + })} + </ul> + </div> + ))} + </nav> + </div> + </aside> + + <div className="min-w-0 flex-1 pb-20 pt-2"> + <div className="mb-6 flex items-center justify-between lg:hidden"> + <p className="font-mono text-xs uppercase tracking-widest text-muted-foreground"> + Docs + </p> + <button + type="button" + onClick={() => setMobileOpen((o) => !o)} + aria-expanded={mobileOpen} + className="inline-flex size-9 items-center justify-center rounded-lg border text-muted-foreground" + > + {mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />} + </button> + </div> + + {mobileOpen && ( + <nav className="mb-8 space-y-4 rounded-2xl border bg-card p-4 lg:hidden"> + {docSections.map((section) => ( + <div key={section.title}> + <p className="mb-2 font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> + {section.title} + </p> + <ul className="space-y-0.5"> + {section.pages.map((page) => ( + <li key={page.slug || "intro"}> + <Link + href={docHref(page.slug)} + onClick={() => setMobileOpen(false)} + className={cn( + "block rounded-lg px-2 py-2 text-sm", + isActive(page.slug) + ? "bg-violet-500/10 text-violet-300" + : "text-muted-foreground" + )} + > + {page.title} + </Link> + </li> + ))} + </ul> + </div> + ))} + </nav> + )} + + <article className="docs-prose">{children}</article> + </div> + </div> + ); +} + +export function DocsBreadcrumb({ + section, + title, +}: { + section: string; + title: string; +}) { + return ( + <div className="mb-6 flex items-center gap-2 text-sm text-muted-foreground"> + <Link href="/docs" className="transition-colors hover:text-foreground"> + Docs + </Link> + <ChevronRight className="size-3.5 opacity-50" /> + <span className="font-mono text-[11px] uppercase tracking-wide text-[var(--pb-mint)]"> + {section} + </span> + <ChevronRight className="size-3.5 opacity-50" /> + <span className="text-foreground/80">{title}</span> + </div> + ); +} + +export function DocsCallout({ + variant = "note", + title, + children, +}: { + variant?: "note" | "tip" | "warning"; + title?: string; + children: React.ReactNode; +}) { + const styles = { + note: "border-violet-500/25 bg-violet-500/5", + tip: "border-[var(--pb-mint)]/30 bg-[var(--pb-mint)]/5", + warning: "border-amber-500/30 bg-amber-500/5", + }; + + return ( + <aside + className={cn( + "my-6 rounded-xl border px-4 py-3 text-sm leading-relaxed", + styles[variant] + )} + > + {title && <p className="mb-1 font-medium text-foreground">{title}</p>} + <div className="text-muted-foreground [&_strong]:text-foreground"> + {children} + </div> + </aside> + ); +} diff --git a/Website/components/download-close.tsx b/Website/components/download-close.tsx new file mode 100644 index 0000000..74dca48 --- /dev/null +++ b/Website/components/download-close.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; +import { Apple, ArrowRight, Check, Code2, ShieldCheck } from "lucide-react"; +import { ParcelMark } from "@/components/site-nav"; +import { DOWNLOAD_URL, GITHUB_URL, faqs } from "@/lib/site"; + +const requirements = [ + "macOS 13.0 or later", + "Apple Silicon and Intel", + "Screen Recording permission", +] as const; + +export function DownloadClose() { + return ( + <section id="install" aria-labelledby="install-title" className="scroll-mt-20 border-b bg-background"> + <div className="mx-auto max-w-7xl px-4 py-16 md:py-24 lg:px-8 lg:py-28"> + <div className="relative overflow-hidden rounded-[2rem] bg-[#0b0c0f] px-5 py-14 text-white shadow-[0_40px_100px_rgba(0,0,0,.22)] sm:px-10 md:py-20 lg:px-16"> + <div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_18%_20%,rgba(94,228,181,.18),transparent_31%),radial-gradient(circle_at_85%_75%,rgba(139,92,246,.22),transparent_36%)]" /> + <div aria-hidden className="pointer-events-none absolute inset-0 opacity-[.15] [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:52px_52px]" /> + <div className="relative grid items-end gap-12 lg:grid-cols-[1.2fr_.8fr]"> + <div className="max-w-3xl"> + <ParcelMark className="size-14 rounded-2xl" /> + <p className="mt-7 font-mono text-[11px] uppercase tracking-[0.2em] text-[var(--brand-accent)]">Free · Open source · Native</p> + <h2 id="install-title" className="mt-4 text-balance text-4xl font-semibold tracking-[-0.045em] sm:text-5xl lg:text-7xl"> + Download Parcel. Keep your Capture workflow yours. + </h2> + <p className="mt-6 max-w-2xl text-pretty text-lg leading-8 text-zinc-300"> + One universal build for modern Macs, signed and notarized for a straightforward first launch. Sparkle handles later updates. + </p> + <div className="mt-9 flex flex-col gap-3 sm:flex-row"> + <a href={DOWNLOAD_URL} download className="inline-flex min-h-12 items-center justify-center gap-2.5 rounded-full bg-[var(--brand-accent)] px-7 text-sm font-semibold text-[var(--brand-ink)] shadow-[0_0_40px_rgba(94,228,181,.2)] transition-all hover:brightness-110 active:scale-[.98]"> + <Apple className="size-4" /> Download Parcel for macOS + </a> + <a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-12 items-center justify-center gap-2 rounded-full border border-white/15 bg-white/[0.05] px-6 text-sm font-semibold transition-colors hover:border-white/30 hover:bg-white/[0.09]"> + <Code2 className="size-4" /> View source + </a> + </div> + </div> + + <div className="rounded-3xl border border-white/12 bg-white/[0.055] p-6 backdrop-blur-xl sm:p-7"> + <div className="flex items-center gap-3"> + <span className="grid size-10 place-items-center rounded-xl bg-[var(--brand-accent)]/12 text-[var(--brand-accent)]"><ShieldCheck className="size-5" /></span> + <div><p className="text-sm font-semibold">Ready for your Mac</p><p className="mt-0.5 text-xs text-zinc-400">Signed · Notarized · Sandboxed</p></div> + </div> + <ul className="mt-6 space-y-3"> + {requirements.map((requirement) => ( + <li key={requirement} className="flex items-center gap-3 text-sm text-zinc-300"><Check className="size-4 text-[var(--brand-accent)]" />{requirement}</li> + ))} + </ul> + <Link href="/docs/getting-started" className="mt-7 inline-flex items-center gap-2 text-sm font-semibold text-white hover:text-[var(--brand-accent)]"> + Open the quick start guide <ArrowRight className="size-4" /> + </Link> + </div> + </div> + </div> + + <div className="mx-auto mt-16 max-w-4xl md:mt-24"> + <div className="text-center"> + <p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">Before you install</p> + <h2 className="mt-3 text-3xl font-semibold tracking-tight md:text-4xl">Three useful answers.</h2> + </div> + <div className="mt-8 divide-y overflow-hidden rounded-2xl border bg-card"> + {faqs.slice(0, 3).map((item, index) => ( + <details key={item.q} className="group px-5 py-4" open={index === 0}> + <summary className="flex cursor-pointer list-none items-center justify-between gap-4 font-medium marker:content-none"> + {item.q}<span className="font-mono text-lg text-muted-foreground transition-transform group-open:rotate-45">+</span> + </summary> + <p className="max-w-3xl pt-3 text-sm leading-6 text-muted-foreground">{item.a}</p> + </details> + ))} + </div> + <p className="mt-5 text-center text-sm text-muted-foreground"> + Need a deeper answer? <Link href="/docs" className="font-semibold text-foreground underline-offset-4 hover:underline">Browse the full documentation.</Link> + </p> + </div> + </div> + </section> + ); +} diff --git a/Website/components/editor-preview.tsx b/Website/components/editor-preview.tsx index de2fb03..acc7c27 100644 --- a/Website/components/editor-preview.tsx +++ b/Website/components/editor-preview.tsx @@ -89,9 +89,10 @@ const TOOL_CHIPS = [ { label: "Select", icon: MousePointer2 }, { label: "Arrow", icon: MoveUpRight }, { label: "Rect", icon: Square }, + { label: "Ellipse", icon: Square }, { label: "Text", icon: Type }, { label: "Censor", icon: EyeOff }, - { label: "Beautify", icon: Sparkles }, + { label: "Spotlight", icon: Sparkles }, { label: "Loupe", icon: Search }, ] as const; diff --git a/Website/components/faq-section.tsx b/Website/components/faq-section.tsx index c6dd634..67afdc8 100644 --- a/Website/components/faq-section.tsx +++ b/Website/components/faq-section.tsx @@ -1,11 +1,13 @@ "use client"; import * as React from "react"; +import { Apple } from "lucide-react"; +import { DitherAurora } from "@/components/parable/dither-aurora"; +import { PrimaryButton } from "@/components/primary-button"; +import { DOWNLOAD_URL, faqs, theme } from "@/lib/site"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { SectionHeader } from "@/components/section-header"; -import { ShimmerButton } from "@/components/parable/shimmer-button"; -import { DOWNLOAD_URL, faqs } from "@/lib/site"; import { cn } from "@/lib/utils"; export function FaqSection() { @@ -13,10 +15,10 @@ export function FaqSection() { const reduce = useReducedMotion(); return ( - <section id="faq" className="border-b bg-muted/20"> + <section id="faq" className="border-b bg-muted/15"> <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> <SectionHeader kicker="FAQ" title="Common questions." /> - <ul className="mt-10 divide-y rounded-2xl border bg-card"> + <ul className="mt-10 divide-y overflow-hidden rounded-2xl border border-border/80 bg-card/90 backdrop-blur-sm"> {faqs.map((item, i) => { const isOpen = open === i; return ( @@ -25,9 +27,9 @@ export function FaqSection() { type="button" onClick={() => setOpen(isOpen ? null : i)} aria-expanded={isOpen} - className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left transition-colors hover:bg-muted/30" + className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left transition-colors hover:bg-muted/40" > - <span className="font-medium">{item.q}</span> + <span className="font-medium text-foreground">{item.q}</span> <ChevronDown className={cn( "size-4 shrink-0 text-muted-foreground transition-transform duration-300", @@ -44,7 +46,7 @@ export function FaqSection() { transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }} className="overflow-hidden" > - <p className="px-5 pb-4 text-sm leading-relaxed text-muted-foreground"> + <p className="px-5 pb-4 text-sm leading-relaxed text-foreground/75"> {item.a} </p> </motion.div> @@ -61,26 +63,47 @@ export function FaqSection() { export function CtaBanner() { return ( - <section className="relative overflow-hidden"> + <section className="dark-surface relative overflow-hidden border-t border-white/5"> + <DitherAurora + className="absolute inset-0 bg-[var(--brand-ink)] opacity-50" + speed={0.06} + pixelSize={5} + colors={[...theme.aurora]} + background={theme.ink} + aria-hidden + > + <span /> + </DitherAurora> + + {/* Radial scrim — keeps text readable (Linear/Perplexity pattern) */} <div aria-hidden - className="pointer-events-none absolute inset-0 bg-gradient-to-br from-violet-600/10 via-transparent to-fuchsia-600/10" + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_60%_at_50%_50%,rgba(0,0,0,0.55)_0%,rgba(7,7,8,0.92)_100%)]" /> - <div className="relative mx-auto max-w-6xl px-4 py-20 text-center md:py-28"> - <h2 className="text-3xl font-semibold tracking-tight md:text-4xl"> - Ready to Capture? - </h2> - <p className="mx-auto mt-4 max-w-lg text-muted-foreground"> - Free, open source, and built for daily macOS work — private by default. - </p> - <ShimmerButton - as="a" - href={DOWNLOAD_URL} - className="mt-8 inline-flex" - shimmerColor="#c4b5fd" - > - Download Parcel - </ShimmerButton> + + <div className="relative mx-auto max-w-3xl px-4 py-24 text-center md:py-32"> + <div className="mx-auto max-w-xl rounded-3xl border border-white/10 bg-black/25 px-6 py-12 backdrop-blur-md md:px-10 md:py-14"> + <p className="font-mono text-xs font-medium uppercase tracking-[0.2em] text-zinc-300"> + Free · Open source · MIT + </p> + <h2 className="mt-5 text-3xl font-semibold tracking-tight text-white md:text-5xl lg:text-6xl"> + Ready to{" "} + <span className="pb-gradient-text pb-gradient-glow">Capture</span>? + </h2> + <p className="mx-auto mt-5 max-w-md text-base leading-relaxed text-zinc-200 md:text-lg"> + Private by default. Built for daily macOS work — from the Parable + ecosystem. + </p> + <div className="mt-10 flex flex-col items-center gap-3"> + <PrimaryButton href={DOWNLOAD_URL} className="gap-2.5 px-8"> + <Apple className="size-4" /> + Download Parcel for macOS + </PrimaryButton> + <p className="font-mono text-xs text-zinc-400"> + Compatible with Apple Silicon and Intel · macOS 13+ + </p> + </div> + </div> </div> </section> ); diff --git a/Website/components/feature-bento.tsx b/Website/components/feature-bento.tsx index fce1b41..8526e31 100644 --- a/Website/components/feature-bento.tsx +++ b/Website/components/feature-bento.tsx @@ -5,37 +5,40 @@ import { motion, useReducedMotion } from "motion/react"; import { Camera, CloudUpload, + Cpu, Eye, History, + MousePointer2, PenTool, + ScanText, ScrollText, + Shield, Sparkles, Video, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { AnnotationToolsStrip } from "@/components/annotation-tools-strip"; import { SectionHeader } from "@/components/section-header"; import { features } from "@/lib/site"; const ICONS: Record<string, LucideIcon> = { camera: Camera, + mouse: MousePointer2, pen: PenTool, scroll: ScrollText, video: Video, + shield: Shield, sparkles: Sparkles, + scan: ScanText, eye: Eye, history: History, cloud: CloudUpload, + cpu: Cpu, }; -function hexToRgba(hex: string, alpha: number) { - const n = Number.parseInt(hex.replace("#", ""), 16); - return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; -} - export function FeatureBento() { const reduce = useReducedMotion(); - const accent = "#8b5cf6"; const onGlowMove = React.useCallback( (e: React.PointerEvent<HTMLDivElement>) => { @@ -48,12 +51,23 @@ export function FeatureBento() { ); return ( - <section id="features" className="border-b bg-muted/20"> - <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> + <section id="features" className="relative border-b bg-muted/20"> + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_0%,color-mix(in_srgb,var(--brand-secondary)_7%,transparent),transparent_70%)]" + /> + <div className="relative mx-auto max-w-6xl px-4 py-16 md:py-24"> <SectionHeader kicker="Capabilities" - title="One menu bar app. Every hard part of Capture solved." + title={ + <> + Everything you need.{" "} + <span className="pb-gradient-text">Nothing you don't.</span> + </> + } + subtitle="One menu bar app for Capture, annotation, censoring, beautify, recording, scroll-stitching, and optional upload — all on your Mac." /> + <AnnotationToolsStrip /> <ul role="list" className="mt-12 grid list-none grid-cols-1 gap-4 sm:grid-cols-2 sm:[grid-auto-rows:minmax(10rem,auto)] lg:grid-cols-3 lg:[grid-auto-flow:dense]" @@ -83,11 +97,11 @@ export function FeatureBento() { type: "spring", stiffness: 220, damping: 24, - delay: Math.min(i * 0.07, 0.56), + delay: Math.min(i * 0.05, 0.5), } } onPointerMove={reduce ? undefined : onGlowMove} - className="group relative flex h-full flex-col overflow-hidden rounded-2xl border bg-card p-6 transition-colors hover:border-border/80" + className="group relative flex h-full flex-col overflow-hidden rounded-2xl border bg-card/80 p-6 backdrop-blur-sm transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--brand-secondary)]/25 hover:shadow-[0_8px_32px_color-mix(in_srgb,var(--brand-secondary)_8%,transparent)]" style={ { "--mx": "50%", "--my": "50%" } as React.CSSProperties } @@ -96,16 +110,17 @@ export function FeatureBento() { aria-hidden className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100" style={{ - background: `radial-gradient(260px circle at var(--mx) var(--my), ${hexToRgba(accent, 0.12)}, transparent 72%)`, + background: + "radial-gradient(260px circle at var(--mx) var(--my), color-mix(in srgb, var(--brand-secondary) 12%, transparent), transparent 72%)", }} /> {Icon && ( <div className="relative mb-4 inline-flex"> <span aria-hidden - className="absolute inset-0 rounded-xl bg-violet-500/30 blur-md" + className="absolute inset-0 rounded-xl bg-violet-500/30 blur-md opacity-0 transition-opacity group-hover:opacity-100" /> - <span className="relative flex size-10 items-center justify-center rounded-xl bg-muted text-violet-400 ring-1 ring-border"> + <span className="relative flex size-10 items-center justify-center rounded-xl bg-[var(--brand-secondary)]/10 text-[var(--brand-secondary)] ring-1 ring-[var(--brand-secondary)]/20"> <Icon className="size-5" strokeWidth={1.75} /> </span> </div> @@ -113,7 +128,7 @@ export function FeatureBento() { <h3 className="text-lg font-semibold tracking-tight"> {item.title} </h3> - <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> + <p className="mt-2 flex-1 text-sm leading-relaxed text-foreground/75"> {item.body} </p> </motion.div> 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 ( + <section id="features" aria-labelledby="features-title" className="scroll-mt-20 border-b bg-background"> + <div className="mx-auto max-w-7xl px-4 py-16 md:py-24 lg:px-8 lg:py-28"> + <div className="flex flex-col justify-between gap-6 md:flex-row md:items-end"> + <div> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">Capabilities</p> + <h2 id="features-title" className="mt-4 max-w-3xl text-balance text-4xl font-semibold tracking-[-0.035em] md:text-6xl"> + A complete Capture workflow. No feature fog. + </h2> + </div> + <Link href="/docs" className="inline-flex items-center gap-2 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"> + Explore the documentation <ArrowRight className="size-4" /> + </Link> + </div> + + <div className="mt-12 grid gap-5 md:grid-cols-2 lg:grid-cols-3"> + {focusedCapabilities.map((capability) => ( + <article key={capability.id} className="group overflow-hidden rounded-3xl border bg-card shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-xl"> + <div className="relative aspect-[16/9] overflow-hidden border-b bg-muted"> + <Image + src={capability.mediaSrc} + width={1440} + height={900} + alt={capability.mediaAlt} + sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" + className="size-full object-cover transition-transform duration-700 group-hover:scale-[1.035]" + /> + <div className="absolute inset-0 bg-gradient-to-t from-black/28 via-transparent to-transparent" aria-hidden /> + </div> + <div className="p-6 md:p-7"> + <p className="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-700 dark:text-violet-400">{capability.eyebrow}</p> + <h3 className="mt-2 text-xl font-semibold tracking-tight">{capability.title}</h3> + <p className="mt-3 text-sm leading-6 text-muted-foreground">{capability.body}</p> + </div> + </article> + ))} + </div> + </div> + </section> + ); +} 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 ( <section id="guides" className="border-b bg-muted/20"> <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> - <SectionHeader - kicker="Guides" - title="Workflow recipes for daily Capture work." - /> - <div className="mt-10 grid gap-4 sm:grid-cols-2"> - {guides.map((guide, i) => ( - <motion.article + <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between"> + <SectionHeader + kicker="Guides" + title={ + <> + Recipes for daily{" "} + <span className="pb-gradient-text">Capture</span> work. + </> + } + subtitle="Each card links to the full guide in the docs." + /> + <Link + href="/docs" + className="inline-flex shrink-0 items-center gap-1.5 text-sm font-medium text-[var(--brand-secondary)] transition-colors hover:text-[var(--brand-accent)]" + > + All documentation <ArrowUpRight className="size-4" /> + </Link> + </div> + <div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> + {guideLinks.map((guide, i) => ( + <motion.div key={guide.title} initial={reduce ? false : { opacity: 0, y: 18 }} whileInView={reduce ? undefined : { opacity: 1, y: 0 }} - viewport={{ once: true, amount: 0.2 }} + viewport={{ once: true, amount: 0.15 }} transition={{ type: "spring", stiffness: 220, damping: 24, - delay: i * 0.06, + delay: Math.min(i * 0.04, 0.32), }} - className="rounded-2xl border bg-card p-6" > - <BookOpen className="size-5 text-violet-400" /> - <h3 className="mt-4 font-medium">{guide.title}</h3> - <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> - {guide.body} - </p> - </motion.article> + <Link + href={guide.href} + className="group relative flex h-full flex-col rounded-2xl border bg-card/80 p-5 backdrop-blur-sm transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--brand-accent)]/25 hover:shadow-[0_8px_32px_color-mix(in_srgb,var(--brand-accent)_8%,transparent)]" + > + <div className="flex items-start justify-between gap-2"> + <BookOpen className="size-5 shrink-0 text-[var(--brand-accent)]" /> + <ArrowUpRight className="size-4 shrink-0 text-muted-foreground/0 transition-all group-hover:text-[var(--brand-accent)]/80" /> + </div> + <h3 className="mt-3 text-sm font-medium leading-snug"> + {guide.title} + </h3> + <p className="mt-2 flex-1 text-sm leading-relaxed text-foreground/75"> + {guide.body} + </p> + </Link> + </motion.div> ))} </div> </div> 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) => ( - <span key={name} className="text-zinc-400"> - {name} - <span className="mx-3 text-zinc-600">/</span> - </span> -)); - -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 ( - <section className="relative overflow-hidden border-b bg-[#0a0a0b]"> - <DitherAurora - className="absolute inset-0" - speed={0.12} - pixelSize={4} + <section className="dark-surface relative -mt-[4.25rem] overflow-hidden border-b border-white/8 bg-[var(--brand-ink)] pt-[4.25rem] text-white"> + <div aria-hidden - > - <span /> - </DitherAurora> + className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_14%_18%,rgba(94,228,181,0.13),transparent_29%),radial-gradient(circle_at_84%_8%,rgba(139,92,246,0.18),transparent_31%),radial-gradient(circle_at_76%_92%,rgba(236,72,153,0.1),transparent_27%)]" + /> <div aria-hidden - className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-black/30" + className="pointer-events-none absolute inset-0 opacity-[0.22] [background-image:linear-gradient(rgba(255,255,255,.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.035)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:linear-gradient(to_bottom,black,transparent_85%)]" /> - <div className="relative mx-auto max-w-6xl px-4 pb-16 pt-12 md:pb-20 md:pt-16"> - <div className="grid items-center gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-10"> - <motion.div - initial={reduce ? false : "hidden"} - animate="show" - className="max-w-3xl" + <div className="relative mx-auto grid min-h-[min(58rem,94vh)] max-w-7xl items-center gap-12 px-4 pb-16 pt-24 lg:grid-cols-[0.78fr_1.22fr] lg:px-8 lg:pb-24 lg:pt-28"> + <div className="relative z-10 max-w-2xl"> + <Link + href="/docs/getting-started" + className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.055] px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-zinc-300 transition-colors hover:border-[var(--brand-accent)]/45 hover:text-white" > - <motion.p - custom={0} - variants={fadeUp} - className="font-mono text-xs uppercase tracking-widest text-zinc-400" - > - Native macOS · MIT · Open source - </motion.p> - <motion.h1 - custom={1} - variants={fadeUp} - className="mt-4 text-4xl font-semibold leading-[1.05] tracking-tight text-zinc-50 md:text-6xl lg:text-7xl" - > - The{" "} - <span className="bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent"> - Capture - </span>{" "} - studio Apple forgot to{" "} - <em className="font-display font-normal not-italic text-zinc-200"> - ship - </em> - . - </motion.h1> - <motion.p - custom={2} - variants={fadeUp} - className="mt-6 max-w-xl text-lg leading-relaxed text-zinc-300" - > - Freeze your screen, annotate with fourteen tools, censor with local - Vision, beautify for ship-ready output, record, scroll-capture, and - upload when you choose — from the Parable ecosystem. - </motion.p> - <motion.div custom={3} variants={fadeUp} className="mt-6 flex flex-wrap gap-2"> - <Badge variant="violet">macOS 13+</Badge> - <Badge variant="mint">On-device only</Badge> - <Badge>MIT License</Badge> - </motion.div> - <motion.div - custom={4} - variants={fadeUp} - className="mt-8 flex flex-wrap items-center gap-4" + <span className="size-1.5 rounded-full bg-[var(--brand-accent)] shadow-[0_0_12px_var(--brand-accent)]" /> + Native macOS · From Parable + </Link> + + <h1 className="mt-7 text-balance text-5xl font-semibold leading-[0.98] tracking-[-0.045em] text-white sm:text-6xl lg:text-[5.35rem]"> + Capture anything. Make it{" "} + <em className="font-display font-normal not-italic text-[var(--brand-accent)]"> + unmistakable. + </em> + </h1> + <p className="mt-7 max-w-xl text-pretty text-lg leading-8 text-zinc-300 sm:text-xl"> + Parcel is the native macOS Capture studio for fast Selection, + precise Annotation, private redaction, and polished sharing—without + cloud AI. + </p> + + <div className="mt-9 flex flex-col gap-3 sm:flex-row sm:items-center"> + <PrimaryButton + href={DOWNLOAD_URL} + download + className="min-h-12 gap-2.5 px-7 text-[15px]" > - <ShimmerButton - as="a" - href={DOWNLOAD_URL} - aria-label="Download Parcel for macOS" - shimmerColor="#c4b5fd" - > - Download for macOS <ArrowRight className="size-4" /> - </ShimmerButton> - <Link - href="#workflow" - className="inline-flex items-center gap-2 rounded-full border border-white/20 px-5 py-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-white/10" - > - See how it works - </Link> - </motion.div> - <motion.dl - custom={5} - variants={fadeUp} - className="mt-10 grid grid-cols-2 gap-6 border-t border-white/10 pt-8 sm:grid-cols-4" + <Apple className="size-4" /> + Download Parcel for macOS + </PrimaryButton> + <a + href="#workflow" + className="inline-flex min-h-12 items-center justify-center gap-2 rounded-full border border-white/15 bg-white/[0.045] px-6 text-sm font-semibold text-zinc-100 transition-colors hover:border-white/30 hover:bg-white/[0.08]" > - {stats.map((s) => ( - <div key={s.label}> - <dt className="font-mono text-3xl font-semibold tabular-nums tracking-tight text-zinc-50"> - {s.value} - </dt> - <dd className="mt-1 text-sm text-zinc-400">{s.label}</dd> - </div> - ))} - </motion.dl> - </motion.div> + Watch the workflow + <ArrowDown className="size-4" /> + </a> + </div> - <motion.div - initial={reduce ? false : { opacity: 0, y: 24, scale: 0.98 }} - animate={reduce ? undefined : { opacity: 1, y: 0, scale: 1 }} - transition={{ - type: "spring", - stiffness: 180, - damping: 24, - delay: 0.15, - }} - className="relative" + <div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 font-mono text-[11px] text-zinc-400"> + <span>Free</span><span aria-hidden>·</span> + <span>MIT licensed</span><span aria-hidden>·</span> + <span>macOS 13+</span><span aria-hidden>·</span> + <span>Apple Silicon and Intel</span> + </div> + <a + href={GITHUB_URL} + target="_blank" + rel="noopener noreferrer" + className="mt-5 inline-flex items-center gap-2 text-xs text-zinc-400 transition-colors hover:text-white" > - <div - aria-hidden - className="pointer-events-none absolute -inset-4 rounded-3xl bg-gradient-to-br from-violet-500/20 via-transparent to-fuchsia-500/15 blur-2xl" - /> - <DeviceFrame> - <EditorPreview /> - </DeviceFrame> - </motion.div> + <Code2 className="size-3.5" /> + Inspect the source on GitHub + </a> </div> - </div> - <div className="relative border-t border-white/10 bg-black/40 py-4 backdrop-blur-sm"> - <VelocityMarquee - items={marqueeItems} - baseSpeed={40} - className="text-base font-medium md:text-lg" - /> + <div className="relative mx-auto w-full max-w-4xl lg:translate-x-[4%]"> + <div + aria-hidden + className="absolute -inset-[10%] rounded-full bg-[radial-gradient(circle,rgba(139,92,246,.18),rgba(94,228,181,.08)_42%,transparent_72%)] blur-3xl" + /> + <div className="relative rotate-[0.5deg] overflow-hidden rounded-[1.45rem] border border-white/15 bg-black/35 p-2 shadow-[0_40px_120px_rgba(0,0,0,.58)] sm:p-3"> + <div className="mb-2 flex items-center gap-2 px-2 py-1 sm:mb-3"> + <span className="size-2.5 rounded-full bg-[#ff5f57]" /> + <span className="size-2.5 rounded-full bg-[#febc2e]" /> + <span className="size-2.5 rounded-full bg-[#28c840]" /> + <span className="ml-2 font-mono text-[10px] text-white/45"> + Parcel workflow · local on your Mac + </span> + </div> + <div className="relative aspect-[8/5] overflow-hidden rounded-xl bg-[#121319]"> + <Image + src="/media/hero-workflow-poster.webp" + width="1440" + height="900" + alt="Parcel Editor with Annotation and export controls" + sizes="(max-width: 1024px) 100vw, 60vw" + className="absolute inset-0 size-full object-cover" + /> + <video + autoPlay + muted + loop + playsInline + preload="metadata" + poster="/media/hero-workflow-poster.webp" + aria-label="Parcel workflow from frozen Capture through Annotation and polished export" + className="absolute inset-0 size-full object-cover motion-reduce:hidden" + > + <source + src="/media/hero-workflow.mp4" + type="video/mp4" + media="(min-width: 768px) and (prefers-reduced-motion: no-preference)" + /> + </video> + </div> + </div> + <div className="absolute -bottom-5 left-3 rounded-2xl border border-white/12 bg-[#15171c]/92 px-4 py-3 shadow-2xl backdrop-blur-xl sm:-left-6 sm:bottom-8"> + <p className="text-xs font-semibold text-white">One render pipeline</p> + <p className="mt-1 font-mono text-[10px] text-zinc-400">Canvas = copied = saved</p> + </div> + <div className="absolute right-2 top-10 hidden rounded-2xl border border-white/12 bg-[#15171c]/92 px-4 py-3 shadow-2xl backdrop-blur-xl sm:block xl:-right-8"> + <p className="text-xs font-semibold text-white">Private by default</p> + <p className="mt-1 font-mono text-[10px] text-[var(--brand-accent)]">0 cloud AI calls</p> + </div> + </div> </div> </section> ); diff --git a/Website/components/install-section.tsx b/Website/components/install-section.tsx index 2d5bc5f..77bbc92 100644 --- a/Website/components/install-section.tsx +++ b/Website/components/install-section.tsx @@ -1,55 +1,106 @@ "use client"; -import { Download } from "lucide-react"; +import Link from "next/link"; +import { Check, Download, Terminal } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import { CopyButton } from "@/components/copy-button"; +import { PrimaryButton } from "@/components/primary-button"; import { SectionHeader } from "@/components/section-header"; -import { ShimmerButton } from "@/components/parable/shimmer-button"; import { DOWNLOAD_URL, HOMEBREW_CMD } from "@/lib/site"; +const requirements = [ + "macOS 13.0 or later", + "Screen Recording permission", + "Quit & reopen after first grant", + "No Accessibility permission needed", +] as const; + export function InstallSection() { + const reduce = useReducedMotion(); + return ( - <section id="install" className="border-b"> - <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> + <section id="install" className="relative border-b"> + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_50%_40%_at_20%_80%,color-mix(in_srgb,var(--brand-secondary)_6%,transparent),transparent)]" + /> + <div className="relative mx-auto max-w-6xl px-4 py-16 md:py-24"> <SectionHeader kicker="Install" - title="Download Parcel for macOS." - subtitle="Requires macOS 13.0 or later. Grant Screen Recording on first launch, then quit and reopen." + title="Install in seconds." + subtitle="Signed, notarized Release builds. Grant Screen Recording on first launch." /> - <div className="mt-10 grid gap-6 md:grid-cols-2"> - <div className="rounded-2xl border bg-card p-6"> - <div className="flex size-10 items-center justify-center rounded-xl bg-violet-500/10 text-violet-400"> + <div className="mt-10 grid gap-6 lg:grid-cols-3"> + <motion.div + initial={reduce ? false : { opacity: 0, y: 16 }} + whileInView={reduce ? undefined : { opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ type: "spring", stiffness: 220, damping: 26 }} + className="group rounded-2xl border bg-[var(--brand-ink)] p-6 text-zinc-200 lg:col-span-1" + > + <p className="font-mono text-[10px] uppercase tracking-widest text-zinc-500"> + Requirements + </p> + <ul className="mt-4 space-y-3"> + {requirements.map((req) => ( + <li key={req} className="flex items-start gap-2.5 text-sm text-zinc-400"> + <Check className="mt-0.5 size-4 shrink-0 text-[var(--brand-accent)]" /> + {req} + </li> + ))} + </ul> + <Link + href="/docs/getting-started" + className="mt-6 inline-block text-sm text-[var(--brand-accent)] hover:underline" + > + Step-by-step guide → + </Link> + </motion.div> + + <motion.div + initial={reduce ? false : { opacity: 0, y: 16 }} + whileInView={reduce ? undefined : { opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ type: "spring", stiffness: 220, damping: 26, delay: 0.05 }} + className="group rounded-2xl border bg-card/80 p-6 backdrop-blur-sm transition-all hover:border-[var(--brand-secondary)]/25 lg:col-span-1" + > + <div className="flex size-10 items-center justify-center rounded-xl bg-[var(--brand-secondary)]/10 text-[var(--brand-secondary)] ring-1 ring-[var(--brand-secondary)]/20"> <Download className="size-5" /> </div> <h3 className="mt-4 font-medium">Direct download</h3> - <p className="mt-2 text-sm text-muted-foreground"> - Signed Release builds from GitHub Actions. Unzip and drag Parcel to - Applications. + <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> + Open the zip and drag Parcel to Applications. Sparkle handles + updates. </p> - <ShimmerButton - as="a" - href={DOWNLOAD_URL} - className="mt-6 w-full justify-center" - shimmerColor="#a78bfa" - > + <PrimaryButton href={DOWNLOAD_URL} className="mt-6 w-full"> Download Parcel.zip - </ShimmerButton> - </div> + </PrimaryButton> + </motion.div> - <div className="rounded-2xl border bg-card p-6"> - <p className="font-mono text-xs uppercase tracking-widest text-muted-foreground"> + <motion.div + initial={reduce ? false : { opacity: 0, y: 16 }} + whileInView={reduce ? undefined : { opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ type: "spring", stiffness: 220, damping: 26, delay: 0.1 }} + className="group rounded-2xl border bg-card/80 p-6 backdrop-blur-sm transition-all hover:border-[var(--brand-accent)]/25 lg:col-span-1" + > + <div className="flex size-10 items-center justify-center rounded-xl bg-[var(--brand-accent)]/10 text-[var(--brand-accent)] ring-1 ring-[var(--brand-accent)]/20"> + <Terminal className="size-5" /> + </div> + <p className="mt-4 font-mono text-xs uppercase tracking-widest text-muted-foreground"> Homebrew </p> - <h3 className="mt-3 font-medium">Install via cask</h3> - <p className="mt-2 text-sm text-muted-foreground"> - Once the cask is published to a tap, install with Homebrew. + <h3 className="mt-2 font-medium">Install via cask</h3> + <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> + Once published to a tap, one command installs Parcel. </p> - <div className="mt-6 flex items-center gap-2 rounded-xl border bg-muted/40 py-2 pl-4 pr-2"> - <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[13px] text-foreground/90 [scrollbar-width:none]"> + <div className="mt-6 flex items-center gap-2 rounded-xl border border-[var(--brand-accent)]/10 bg-muted/40 py-2 pl-4 pr-2"> + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[13px] [scrollbar-width:none]"> {HOMEBREW_CMD} </code> <CopyButton value={HOMEBREW_CMD} label="Copy Homebrew command" /> </div> - </div> + </motion.div> </div> </div> </section> diff --git a/Website/components/mobile-download-bar.tsx b/Website/components/mobile-download-bar.tsx new file mode 100644 index 0000000..32d4330 --- /dev/null +++ b/Website/components/mobile-download-bar.tsx @@ -0,0 +1,36 @@ +"use client"; + +import * as React from "react"; +import { Apple } from "lucide-react"; +import { DOWNLOAD_URL } from "@/lib/site"; + +/** Sticky download bar on mobile after scrolling past hero — Windsurf/Affinity pattern */ +export function MobileDownloadBar() { + const [visible, setVisible] = React.useState(false); + + React.useEffect(() => { + const onScroll = () => setVisible(window.scrollY > 480); + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + if (!visible) return null; + + return ( + <div + className="fixed inset-x-0 bottom-0 z-50 border-t border-white/10 bg-[var(--brand-ink)]/95 p-3 backdrop-blur-xl md:hidden" + role="region" + aria-label="Download Parcel" + > + <a + href={DOWNLOAD_URL} + download + className="flex w-full items-center justify-center gap-2 rounded-xl bg-[var(--brand-accent)] py-3.5 text-sm font-semibold text-[var(--brand-ink)]" + > + <Apple className="size-4" /> + Download for macOS + </a> + </div> + ); +} diff --git a/Website/components/parable/dither-aurora.tsx b/Website/components/parable/dither-aurora.tsx index 8c4b151..ccf0a06 100644 --- a/Website/components/parable/dither-aurora.tsx +++ b/Website/components/parable/dither-aurora.tsx @@ -17,15 +17,15 @@ function hexToRgb(hex: string): [number, number, number] { } function usePrefersReducedMotion(): boolean { - const [reduced, setReduced] = React.useState(false); - React.useEffect(() => { - const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); - setReduced(mq.matches); - const onChange = (e: MediaQueryListEvent) => setReduced(e.matches); - mq.addEventListener("change", onChange); - return () => mq.removeEventListener("change", onChange); - }, []); - return reduced; + return React.useSyncExternalStore( + (onStoreChange) => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + query.addEventListener("change", onStoreChange); + return () => query.removeEventListener("change", onStoreChange); + }, + () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, + () => false + ); } const VERT = ` @@ -171,8 +171,9 @@ export function DitherAurora({ // Live-updatable knobs that must not tear down the GL context. const live = React.useRef({ speed, paused }); - live.current.speed = speed; - live.current.paused = paused; + React.useEffect(() => { + live.current = { speed, paused }; + }, [speed, paused]); const colorKey = `${colors.join("|")}|${background}`; diff --git a/Website/components/primary-button.tsx b/Website/components/primary-button.tsx new file mode 100644 index 0000000..ab64d28 --- /dev/null +++ b/Website/components/primary-button.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; + +type PrimaryButtonProps = { + href: string; + children: ReactNode; + className?: string; + variant?: "accent" | "white"; + external?: boolean; + download?: boolean; +}; + +/** High-contrast CTA — Discord/Linear pattern (readable on dark aurora) */ +export function PrimaryButton({ + href, + children, + className, + variant = "accent", + external, + download, +}: PrimaryButtonProps) { + const styles = + variant === "accent" + ? "bg-[var(--brand-accent)] text-[var(--brand-ink)] shadow-[0_0_40px_color-mix(in_srgb,var(--brand-accent)_35%,transparent)] hover:brightness-110" + : "bg-white text-[var(--brand-ink)] shadow-[0_8px_32px_rgba(0,0,0,0.35)] hover:bg-zinc-100"; + + const Comp = external || download ? "a" : Link; + const extra = external + ? { target: "_blank", rel: "noopener noreferrer" } + : download + ? { download: true } + : {}; + + return ( + <Comp + href={href} + className={cn( + "inline-flex items-center justify-center gap-2 rounded-full px-7 py-3.5 text-sm font-semibold transition-all active:scale-[0.98]", + styles, + className + )} + {...extra} + > + {children} + </Comp> + ); +} diff --git a/Website/components/principles-section.tsx b/Website/components/principles-section.tsx new file mode 100644 index 0000000..9c5f4bb --- /dev/null +++ b/Website/components/principles-section.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Cpu, Keyboard, Shield, Zap } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { SectionHeader } from "@/components/section-header"; + +const principles = [ + { + icon: Zap, + title: "Built for speed", + body: "ScreenCaptureKit freeze, Carbon hotkeys, and a native SwiftUI shell — no Electron, no web views.", + }, + { + icon: Keyboard, + title: "Keyboard-first", + body: "Configurable global hotkey, Overlay shortcuts, and Editor commands designed for daily muscle memory.", + }, + { + icon: Shield, + title: "Private by default", + body: "OCR, faces, translation, and regex redaction stay on your Mac. Zero cloud AI calls.", + }, + { + icon: Cpu, + title: "One render pipeline", + body: "Adjustments, censors, annotations, and Beautify compose once — display and export match exactly.", + }, +] as const; + +export function PrinciplesSection() { + const reduce = useReducedMotion(); + + return ( + <section className="border-b bg-muted/15"> + <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> + <SectionHeader + kicker="Philosophy" + title="Opinionated software for people who ship Captures daily." + subtitle="Inspired by tools like Linear and Monologue — native, fast, and respectful of your pixels." + /> + <div className="mt-12 grid gap-px overflow-hidden rounded-2xl border bg-border/40 sm:grid-cols-2"> + {principles.map((item, i) => { + const Icon = item.icon; + return ( + <motion.div + key={item.title} + initial={reduce ? false : { opacity: 0 }} + whileInView={reduce ? undefined : { opacity: 1 }} + viewport={{ once: true }} + transition={{ delay: i * 0.05, duration: 0.4 }} + className="group bg-card/80 p-8 transition-colors hover:bg-card" + > + <div className="mb-4 inline-flex size-10 items-center justify-center rounded-xl bg-[var(--brand-secondary)]/10 text-[var(--brand-secondary)] ring-1 ring-[var(--brand-secondary)]/20 transition-shadow group-hover:shadow-[0_0_24px_color-mix(in_srgb,var(--brand-secondary)_25%,transparent)]"> + <Icon className="size-5" strokeWidth={1.75} /> + </div> + <h3 className="text-lg font-semibold tracking-tight"> + {item.title} + </h3> + <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> + {item.body} + </p> + </motion.div> + ); + })} + </div> + </div> + </section> + ); +} 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 ( - <section id="privacy" className="border-b bg-muted/20"> - <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> - <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16"> - <motion.div - initial={reduce ? false : { opacity: 0, x: -20 }} - whileInView={reduce ? undefined : { opacity: 1, x: 0 }} - viewport={{ once: true, amount: 0.3 }} - transition={{ type: "spring", stiffness: 200, damping: 26 }} - > - <SectionHeader - kicker="Privacy" - title="Your pixels stay on your Mac." - subtitle="OCR, face detection, translation, and regex redaction run through Apple on-device frameworks. No cloud AI, no telemetry, no surprise uploads." - /> - <ul className="mt-8 space-y-3 text-sm text-muted-foreground"> - <li className="flex items-start gap-3"> - <Shield className="mt-0.5 size-4 shrink-0 text-emerald-500" /> - Vision and Core ML process Captures locally - </li> - <li className="flex items-start gap-3"> - <Shield className="mt-0.5 size-4 shrink-0 text-emerald-500" /> - Supabase upload is optional and user-configured - </li> - <li className="flex items-start gap-3"> - <Shield className="mt-0.5 size-4 shrink-0 text-emerald-500" /> - Sandboxed Release builds with minimal entitlements - </li> + <section id="privacy" aria-labelledby="privacy-title" className="scroll-mt-20 border-b bg-[#0c1110] text-white"> + <div className="relative overflow-hidden"> + <div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_35%,rgba(94,228,181,.14),transparent_34%),radial-gradient(circle_at_88%_70%,rgba(139,92,246,.13),transparent_32%)]" /> + <div className="relative mx-auto grid max-w-7xl items-center gap-12 px-4 py-20 md:py-28 lg:grid-cols-[0.9fr_1.1fr] lg:gap-20 lg:px-8 lg:py-32"> + <div className="max-w-xl"> + <div className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.055] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--brand-accent)]"> + <LockKeyhole className="size-3.5" /> + Privacy is architecture + </div> + <h2 id="privacy-title" className="mt-6 text-balance text-4xl font-semibold tracking-[-0.04em] sm:text-5xl lg:text-6xl"> + Your pixels stay on your Mac. + </h2> + <p className="mt-6 text-pretty text-lg leading-8 text-zinc-300"> + 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. + </p> + <ul className="mt-8 space-y-3"> + {localFacts.map((fact) => ( + <li key={fact} className="flex items-start gap-3 text-sm text-zinc-200"> + <span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-[var(--brand-accent)]/12 text-[var(--brand-accent)] ring-1 ring-[var(--brand-accent)]/25"> + <Check className="size-3" strokeWidth={2.6} /> + </span> + {fact} + </li> + ))} </ul> - </motion.div> + <Link href="/docs/privacy" className="mt-8 inline-flex items-center gap-2 text-sm font-semibold text-[var(--brand-accent)] hover:text-white"> + Read the privacy documentation <ArrowRight className="size-4" /> + </Link> + </div> - <motion.div - initial={reduce ? false : { opacity: 0, x: 20 }} - whileInView={reduce ? undefined : { opacity: 1, x: 0 }} - viewport={{ once: true, amount: 0.3 }} - transition={{ type: "spring", stiffness: 200, damping: 26 }} - className="overflow-hidden rounded-2xl border bg-card shadow-xl" - > - <div className="border-b bg-muted/50 px-4 py-3"> - <p className="font-mono text-xs text-muted-foreground"> - System Settings → Privacy & Security - </p> - </div> - <div className="space-y-4 p-6"> - <div className="flex items-center justify-between rounded-xl border bg-background p-4"> - <div> - <p className="text-sm font-medium">Screen Recording</p> - <p className="text-xs text-muted-foreground">Required for Capture</p> + <div className="relative"> + <div aria-hidden className="absolute -inset-10 rounded-full bg-[var(--brand-accent)]/8 blur-3xl" /> + <div className="relative overflow-hidden rounded-[1.75rem] border border-white/12 bg-[#171b1a]/95 shadow-[0_40px_100px_rgba(0,0,0,.48)]"> + <div className="flex items-center justify-between border-b border-white/8 px-5 py-4"> + <div className="flex items-center gap-2.5"> + <span className="size-2.5 rounded-full bg-[#ff5f57]" /> + <span className="size-2.5 rounded-full bg-[#febc2e]" /> + <span className="size-2.5 rounded-full bg-[#28c840]" /> </div> - <span className="rounded-full bg-emerald-500/15 px-3 py-1 font-mono text-xs text-emerald-500"> - Granted - </span> + <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-500">Privacy & Security</p> </div> - <div className="flex items-center justify-between rounded-xl border bg-background p-4 opacity-60"> - <div> - <p className="text-sm font-medium">Accessibility</p> - <p className="text-xs text-muted-foreground">Not required</p> + <div className="grid gap-4 p-5 sm:p-7"> + <div className="rounded-2xl border border-white/10 bg-white/[0.045] p-5"> + <div className="flex items-start justify-between gap-4"> + <div className="flex items-start gap-4"> + <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[var(--brand-secondary)]/16 text-[var(--brand-secondary)]"> + <ShieldCheck className="size-5" /> + </span> + <div> + <h3 className="font-semibold">Screen Recording</h3> + <p className="mt-1 text-sm leading-6 text-zinc-400">Required by macOS so Parcel can make a Capture.</p> + </div> + </div> + <span className="rounded-full bg-[var(--brand-accent)]/12 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide text-[var(--brand-accent)]">Required</span> + </div> + </div> + + <div className="rounded-2xl border border-white/10 bg-white/[0.025] p-5"> + <div className="flex items-start justify-between gap-4"> + <div className="flex items-start gap-4"> + <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-zinc-400"> + <LockKeyhole className="size-5" /> + </span> + <div> + <h3 className="font-semibold text-zinc-200">Accessibility</h3> + <p className="mt-1 text-sm leading-6 text-zinc-500">Not used. The global hotkey is registered through Carbon.</p> + </div> + </div> + <span className="font-mono text-xs text-zinc-500">—</span> + </div> + </div> + + <div className="rounded-2xl border border-[var(--brand-accent)]/18 bg-[var(--brand-accent)]/[0.055] px-5 py-4"> + <div className="flex items-center justify-between gap-4"> + <div> + <p className="text-sm font-semibold text-white">Cloud AI requests</p> + <p className="mt-1 text-xs text-zinc-400">No Capture data sent for AI processing</p> + </div> + <span className="text-3xl font-semibold tracking-tight text-[var(--brand-accent)]">0</span> + </div> </div> - <span className="font-mono text-xs text-muted-foreground">—</span> </div> - <p className="text-center font-mono text-[11px] text-muted-foreground"> - Carbon hotkeys · no Accessibility permission needed - </p> </div> - </motion.div> + </div> </div> </div> </section> diff --git a/Website/components/section-header.tsx b/Website/components/section-header.tsx index d9fe7ec..c730360 100644 --- a/Website/components/section-header.tsx +++ b/Website/components/section-header.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { cn } from "@/lib/utils"; export function SectionHeader({ @@ -5,14 +6,22 @@ export function SectionHeader({ title, subtitle, className, + align = "left", }: { kicker?: string; - title: string; + title: React.ReactNode; subtitle?: string; className?: string; + align?: "left" | "center"; }) { return ( - <div className={cn("max-w-2xl", className)}> + <div + className={cn( + "max-w-2xl", + align === "center" && "mx-auto text-center", + className + )} + > {kicker && ( <p className="font-mono text-xs uppercase tracking-widest text-muted-foreground"> {kicker} @@ -22,7 +31,7 @@ export function SectionHeader({ {title} </h2> {subtitle && ( - <p className="mt-3 text-base leading-relaxed text-muted-foreground"> + <p className="mt-3 text-base leading-relaxed text-foreground/80"> {subtitle} </p> )} @@ -42,9 +51,9 @@ export function Badge({ className={cn( "inline-flex items-center rounded-full border px-2.5 py-0.5 font-mono text-[11px] uppercase tracking-wide", variant === "violet" && - "border-violet-500/30 bg-violet-500/10 text-violet-300", + "border-[var(--brand-secondary)]/30 bg-[var(--brand-secondary)]/10 text-violet-300", variant === "mint" && - "border-emerald-500/30 bg-emerald-500/10 text-emerald-300", + "border-[var(--brand-accent)]/30 bg-[var(--brand-accent)]/10 text-[var(--brand-accent)]", variant === "default" && "border-border bg-muted/50 text-muted-foreground" )} > diff --git a/Website/components/shortcuts-table.tsx b/Website/components/shortcuts-table.tsx index 6eae4e7..0ee51bd 100644 --- a/Website/components/shortcuts-table.tsx +++ b/Website/components/shortcuts-table.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { SectionHeader } from "@/components/section-header"; import { shortcuts } from "@/lib/site"; @@ -8,39 +10,60 @@ export function ShortcutsTable() { const reduce = useReducedMotion(); return ( - <section className="border-b"> - <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> - <SectionHeader - kicker="Shortcuts" - title="Keyboard-first from Capture to export." - /> + <section id="shortcuts" className="relative border-b"> + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_80%_50%,color-mix(in_srgb,var(--brand-tertiary)_6%,transparent),transparent)]" + /> + <div className="relative mx-auto max-w-6xl px-4 py-16 md:py-24"> + <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> + <SectionHeader + kicker="Shortcuts" + title={ + <> + Keyboard-first{" "} + <span className="pb-gradient-text">design</span>. + </> + } + subtitle="Every action has a shortcut. The global Capture hotkey is configurable in Preferences." + /> + <Link + href="/docs/shortcuts" + className="inline-flex shrink-0 items-center gap-1.5 text-sm font-medium text-[var(--brand-secondary)] hover:text-[var(--brand-accent)]" + > + Full reference <ArrowRight className="size-4" /> + </Link> + </div> <motion.div initial={reduce ? false : { opacity: 0, y: 16 }} whileInView={reduce ? undefined : { opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ type: "spring", stiffness: 220, damping: 26 }} - className="mt-8 overflow-hidden rounded-2xl border" + className="mt-10 overflow-hidden rounded-2xl border bg-card/50" > <table className="w-full text-sm"> <thead> - <tr className="border-b bg-muted/40 text-left"> - <th className="px-5 py-3 font-mono text-xs uppercase tracking-wider text-muted-foreground"> + <tr className="border-b bg-[var(--brand-secondary)]/[0.04] text-left"> + <th className="px-5 py-3.5 font-mono text-xs uppercase tracking-wider text-muted-foreground"> Shortcut </th> - <th className="px-5 py-3 font-mono text-xs uppercase tracking-wider text-muted-foreground"> + <th className="px-5 py-3.5 font-mono text-xs uppercase tracking-wider text-muted-foreground"> Action </th> </tr> </thead> <tbody> {shortcuts.map((row) => ( - <tr key={row.keys} className="border-b last:border-0"> + <tr + key={row.keys} + className="group border-b border-border/60 transition-colors last:border-0 hover:bg-[var(--brand-secondary)]/[0.03]" + > <td className="px-5 py-3.5"> - <kbd className="rounded-md border bg-muted/60 px-2 py-1 font-mono text-xs"> + <kbd className="inline-flex rounded-md border border-[var(--brand-secondary)]/15 bg-muted/60 px-2.5 py-1 font-mono text-xs transition-colors group-hover:border-[var(--brand-secondary)]/30"> {row.keys} </kbd> </td> - <td className="px-5 py-3.5 text-muted-foreground"> + <td className="px-5 py-3.5 text-muted-foreground transition-colors group-hover:text-foreground/90"> {row.action} </td> </tr> 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 ( + <section className="relative overflow-hidden border-b"> + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_100%,color-mix(in_srgb,var(--brand-secondary)_12%,transparent),transparent_70%)]" + /> + <div className="relative mx-auto max-w-6xl px-4 py-16 md:py-28"> + <SectionHeader + kicker="Editor" + align="center" + title={ + <> + A Capture studio you'd expect from a{" "} + <span className="pb-gradient-text">professional</span> tool. + </> + } + subtitle="Fourteen annotation Tools, on-device Vision, Beautify, and one render pipeline — what you see is exactly what copies or saves." + /> + <motion.div + initial={reduce ? false : { opacity: 0, y: 32 }} + whileInView={reduce ? undefined : { opacity: 1, y: 0 }} + viewport={{ once: true, amount: 0.2 }} + transition={{ type: "spring", stiffness: 160, damping: 26 }} + className="relative mx-auto mt-14 max-w-4xl" + > + <div + aria-hidden + className="pointer-events-none absolute -inset-8 rounded-[2rem] bg-gradient-to-b from-[var(--brand-accent)]/10 via-[var(--brand-secondary)]/5 to-transparent blur-3xl" + /> + <DeviceFrame tab="Parcel — Editor" url="parcel.parable.dev/editor"> + <EditorPreview /> + </DeviceFrame> + <div className="mt-6 flex flex-wrap items-center justify-center gap-3"> + {["On screen = saved", "Capture-point coords", "Sandboxed"].map( + (tag) => ( + <span + key={tag} + className="rounded-full border border-white/15 bg-white/5 px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-zinc-300" + > + {tag} + </span> + ) + )} + </div> + </motion.div> + </div> + </section> + ); +} diff --git a/Website/components/site-footer.tsx b/Website/components/site-footer.tsx index b1fd671..792eb8a 100644 --- a/Website/components/site-footer.tsx +++ b/Website/components/site-footer.tsx @@ -1,46 +1,110 @@ import Link from "next/link"; import { DOWNLOAD_URL, GITHUB_URL } from "@/lib/site"; +import { ParcelMark } from "@/components/site-nav"; + +const footerLinks = { + Product: [ + { label: "Features", href: "/#features" }, + { label: "Workflow", href: "/#workflow" }, + { label: "Privacy", href: "/#privacy" }, + { label: "Install", href: "/#install" }, + { label: "Download", href: DOWNLOAD_URL, download: true }, + ], + Docs: [ + { label: "Documentation", href: "/docs" }, + { label: "Quick start", href: "/docs/getting-started" }, + { label: "Shortcuts", href: "/docs/shortcuts" }, + { label: "Privacy", href: "/docs/privacy" }, + ], + Ecosystem: [ + { label: "GitHub", href: GITHUB_URL, external: true }, + { label: "Parable UI", href: "https://github.com/bswxyz/parable", external: true }, + { label: "Parable.dev", href: "https://parable.dev", external: true }, + ], +} as const; export function SiteFooter() { return ( - <footer className="border-t border-border/60"> - <div className="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-10 text-sm text-muted-foreground md:flex-row md:items-center"> - <p> - <span className="font-semibold text-foreground">Parcel</span> — the - native macOS Capture studio from{" "} - <a - href="https://parable.dev" - className="text-foreground/80 underline-offset-4 hover:underline" - > - Parable - </a> - . MIT licensed. - </p> - <nav className="flex flex-wrap gap-4 md:ml-auto"> - <a href="#features" className="hover:text-foreground"> - Features - </a> - <a href="#install" className="hover:text-foreground"> - Install - </a> - <a href={DOWNLOAD_URL} download className="hover:text-foreground"> - Download - </a> - <a - href={GITHUB_URL} - target="_blank" - rel="noopener noreferrer" - className="hover:text-foreground" - > - GitHub - </a> - <Link - href="https://github.com/bswxyz/parable" - className="hover:text-foreground" - > - Parable UI - </Link> - </nav> + <footer className="relative mt-auto overflow-hidden border-t border-border/40 bg-[var(--brand-ink)] text-zinc-300"> + <div + aria-hidden + className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[var(--brand-accent)]/40 to-transparent" + /> + + <div className="mx-auto max-w-6xl px-4 py-16"> + <div className="grid gap-12 lg:grid-cols-[1.2fr_2fr]"> + <div> + <Link href="/" className="inline-flex items-center gap-2.5"> + <ParcelMark className="size-9 rounded-xl" /> + <span className="text-sm font-semibold text-white">Parcel</span> + </Link> + <p className="mt-4 max-w-xs text-sm leading-relaxed text-zinc-400"> + The native macOS Capture studio from{" "} + <a + href="https://parable.dev" + className="text-zinc-200 underline underline-offset-4" + > + Parable + </a> + . MIT licensed — no cloud AI, ever. + </p> + </div> + + <nav className="grid grid-cols-2 gap-8 sm:grid-cols-3"> + {Object.entries(footerLinks).map(([group, links]) => ( + <div key={group}> + <p className="font-mono text-[10px] uppercase tracking-widest text-zinc-400"> + {group} + </p> + <ul className="mt-3 space-y-2.5"> + {links.map((link) => ( + <li key={link.label}> + {"external" in link && link.external ? ( + <a + href={link.href} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-zinc-400 transition-colors hover:text-white" + > + {link.label} + </a> + ) : "download" in link && link.download ? ( + <a + href={link.href} + download + className="text-sm text-zinc-400 transition-colors hover:text-white" + > + {link.label} + </a> + ) : link.href.startsWith("/") ? ( + <Link + href={link.href} + className="text-sm text-zinc-400 transition-colors hover:text-white" + > + {link.label} + </Link> + ) : ( + <a + href={link.href} + className="text-sm text-zinc-400 transition-colors hover:text-white" + > + {link.label} + </a> + )} + </li> + ))} + </ul> + </div> + ))} + </nav> + </div> + + <div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-white/8 pt-6 text-xs text-zinc-400 sm:flex-row"> + <p>© {new Date().getFullYear()} Parable · MIT License</p> + <p className="font-mono"> + SwiftUI · ScreenCaptureKit · Next.js + </p> + </div> </div> </footer> ); diff --git a/Website/components/site-nav.tsx b/Website/components/site-nav.tsx index 779969d..2d20c8e 100644 --- a/Website/components/site-nav.tsx +++ b/Website/components/site-nav.tsx @@ -2,11 +2,12 @@ import * as React from "react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { Menu, Moon, Sun, X } from "lucide-react"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; import { DOWNLOAD_URL, GITHUB_URL } from "@/lib/site"; -import { ShimmerButton } from "@/components/parable/shimmer-button"; +import { navLinks } from "@/lib/nav"; function GithubMark() { return ( @@ -16,64 +17,112 @@ function GithubMark() { ); } -const LINKS = [ - { href: "#features", label: "Features" }, - { href: "#workflow", label: "Workflow" }, - { href: "#privacy", label: "Privacy" }, - { href: "#guides", label: "Guides" }, - { href: "#faq", label: "FAQ" }, -]; +const LINKS = navLinks; + +function ParcelMark({ className }: { className?: string }) { + return ( + <span + className={cn( + "relative grid size-7 shrink-0 place-items-center overflow-hidden rounded-lg ring-1 ring-white/10", + className + )} + > + <span className="absolute inset-0 bg-gradient-to-br from-[var(--brand-secondary)] via-[var(--brand-tertiary)]/70 to-[var(--brand-accent)]/60" /> + <span className="relative text-[11px] font-black text-white">P</span> + </span> + ); +} export function SiteNav() { + const pathname = usePathname(); const [mobileOpen, setMobileOpen] = React.useState(false); + const [scrolled, setScrolled] = React.useState(false); + const isHome = pathname === "/"; + const onHero = isHome && !scrolled; + + React.useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 12); + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); return ( - <header className="sticky top-0 z-40 w-full border-b border-border/60 bg-background/70 backdrop-blur-xl"> - <div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4"> - <Link href="/" className="flex items-center gap-2"> - <span className="grid size-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500 via-fuchsia-500 to-amber-400 text-xs font-black text-black"> - P - </span> - <span className="text-sm font-semibold tracking-tight">Parcel</span> + <header className="sticky top-0 z-50 px-4 pt-3"> + <div + className={cn( + "mx-auto flex h-12 max-w-6xl items-center gap-3 rounded-2xl border px-3 transition-all duration-300", + scrolled || !isHome + ? "border-border/50 bg-background/80 shadow-[0_8px_32px_rgba(0,0,0,0.12)] backdrop-blur-xl" + : "border-white/10 bg-black/35 shadow-[0_8px_40px_rgba(0,0,0,0.35)] backdrop-blur-xl" + )} + > + <Link href="/" className="flex items-center gap-2.5"> + <ParcelMark /> + <span className={cn("text-sm font-semibold tracking-tight", onHero && "text-white")}>Parcel</span> </Link> - <nav className="ml-2 hidden items-center gap-1 md:flex"> - {LINKS.map((l) => ( - <a - key={l.href} - href={l.href} - className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground" - > - {l.label} - </a> - ))} + <nav className="ml-1 hidden items-center lg:flex"> + {LINKS.map((l) => + "isRoute" in l && l.isRoute ? ( + <Link + key={l.href} + href={l.href} + className={cn( + "rounded-lg px-3 py-1.5 text-sm transition-colors", + onHero ? "text-zinc-400 hover:text-white" : "text-muted-foreground hover:text-foreground" + )} + > + {l.label} + </Link> + ) : ( + <a + key={l.href} + href={l.href} + className={cn( + "rounded-lg px-3 py-1.5 text-sm transition-colors", + onHero ? "text-zinc-400 hover:text-white" : "text-muted-foreground hover:text-foreground" + )} + > + {l.label} + </a> + ) + )} </nav> - <div className="ml-auto flex items-center gap-2"> - <ThemeToggle /> + <div className="ml-auto flex items-center gap-1.5"> + <ThemeToggle onHero={onHero} /> <a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" aria-label="GitHub" - className="hidden size-9 items-center justify-center rounded-lg border text-muted-foreground transition-colors hover:text-foreground sm:inline-flex" + className={cn( + "hidden size-8 items-center justify-center rounded-lg border transition-colors sm:inline-flex", + onHero + ? "border-white/15 text-zinc-400 hover:text-white" + : "border-border/60 text-muted-foreground hover:text-foreground" + )} > <GithubMark /> </a> - <ShimmerButton - as="a" + <a href={DOWNLOAD_URL} - className="hidden px-4 py-2 text-xs sm:inline-flex" - shimmerColor="#a78bfa" + download + className="hidden items-center justify-center rounded-full bg-[var(--brand-accent)] px-4 py-2 text-xs font-semibold text-[var(--brand-ink)] transition-all hover:brightness-110 sm:inline-flex" > Download - </ShimmerButton> + </a> <button onClick={() => setMobileOpen((o) => !o)} aria-label="Toggle navigation menu" aria-expanded={mobileOpen} - aria-controls="mobile-nav" - className="inline-flex size-9 items-center justify-center rounded-lg border text-muted-foreground transition-colors hover:text-foreground md:hidden" + className={cn( + "inline-flex size-8 items-center justify-center rounded-lg border lg:hidden", + onHero + ? "border-white/15 text-zinc-300" + : "border-border/60 text-muted-foreground" + )} > {mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />} </button> @@ -81,25 +130,32 @@ export function SiteNav() { </div> {mobileOpen && ( - <nav - id="mobile-nav" - aria-label="Primary" - className="border-t border-border/60 px-4 py-3 md:hidden" - > - {LINKS.map((l) => ( - <a - key={l.href} - href={l.href} - onClick={() => setMobileOpen(false)} - className="block rounded-md px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground" - > - {l.label} - </a> - ))} + <nav className="mx-auto mt-2 max-w-6xl rounded-2xl border border-border/60 bg-background/95 p-3 backdrop-blur-xl lg:hidden"> + {LINKS.map((l) => + "isRoute" in l && l.isRoute ? ( + <Link + key={l.href} + href={l.href} + onClick={() => setMobileOpen(false)} + className="block rounded-lg px-3 py-2.5 text-sm text-muted-foreground hover:bg-muted/50" + > + {l.label} + </Link> + ) : ( + <a + key={l.href} + href={l.href} + onClick={() => setMobileOpen(false)} + className="block rounded-lg px-3 py-2.5 text-sm text-muted-foreground hover:bg-muted/50" + > + {l.label} + </a> + ) + )} <a href={DOWNLOAD_URL} download - className="mt-2 block rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 px-4 py-2.5 text-center text-sm font-medium text-white" + className="mt-2 block rounded-xl bg-[var(--brand-accent)] px-4 py-2.5 text-center text-sm font-semibold text-[var(--brand-ink)]" > Download for macOS </a> @@ -109,23 +165,35 @@ export function SiteNav() { ); } -function ThemeToggle() { +function ThemeToggle({ onHero = false }: { onHero?: boolean }) { const { resolvedTheme, setTheme } = useTheme(); - const [mounted, setMounted] = React.useState(false); - React.useEffect(() => setMounted(true), []); + const mounted = React.useSyncExternalStore( + () => () => undefined, + () => true, + () => false + ); + + if (!mounted) { + return <span aria-hidden className="inline-block size-8" />; + } + const dark = resolvedTheme === "dark"; return ( <button + type="button" onClick={() => setTheme(dark ? "light" : "dark")} aria-label="Toggle theme" - className="inline-flex size-9 items-center justify-center rounded-lg border text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - > - {mounted && dark ? ( - <Sun className="size-4" /> - ) : ( - <Moon className="size-4" /> + className={cn( + "inline-flex size-8 items-center justify-center rounded-lg border transition-colors", + onHero + ? "border-white/15 text-zinc-300 hover:text-white" + : "border-border/60 text-muted-foreground hover:text-foreground" )} + > + {dark ? <Sun className="size-4" /> : <Moon className="size-4" />} </button> ); } + +export { ParcelMark }; diff --git a/Website/components/stats-band.tsx b/Website/components/stats-band.tsx new file mode 100644 index 0000000..9267a08 --- /dev/null +++ b/Website/components/stats-band.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { stats } from "@/lib/site"; + +export function StatsBand() { + const reduce = useReducedMotion(); + + return ( + <section + aria-label="Key metrics" + className="relative border-b border-border/40 bg-[var(--brand-ink)]" + > + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,color-mix(in_srgb,var(--brand-accent)_6%,transparent)_50%,transparent_100%)]" + /> + <div className="relative mx-auto max-w-6xl px-4 py-10 md:py-12"> + <dl className="grid grid-cols-2 gap-8 sm:grid-cols-4 sm:gap-6"> + {stats.map((s, i) => ( + <motion.div + key={s.label} + initial={reduce ? false : { opacity: 0, y: 12 }} + whileInView={reduce ? undefined : { opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ + type: "spring", + stiffness: 240, + damping: 28, + delay: i * 0.06, + }} + className="relative text-center sm:text-left" + > + {i > 0 && ( + <div + aria-hidden + className="absolute -left-3 top-1/2 hidden h-10 w-px -translate-y-1/2 bg-border/60 sm:block" + /> + )} + <dt className="font-mono text-4xl font-semibold tabular-nums tracking-tight text-zinc-50 md:text-5xl"> + {s.value} + </dt> + <dd className="mt-1.5 text-sm text-zinc-300">{s.label}</dd> + </motion.div> + ))} + </dl> + </div> + </section> + ); +} diff --git a/Website/components/use-cases-section.tsx b/Website/components/use-cases-section.tsx new file mode 100644 index 0000000..e535a54 --- /dev/null +++ b/Website/components/use-cases-section.tsx @@ -0,0 +1,109 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { SectionHeader } from "@/components/section-header"; +import { cn } from "@/lib/utils"; + +const personas = [ + { + tag: "Developers", + title: "Bug reports that actually help", + body: "Freeze the UI, arrow to the problem, censor API keys, and paste into Slack — full resolution, no cloud upload required.", + }, + { + tag: "Designers", + title: "Ship-ready Captures", + body: "Beautify with gradients, window chrome, and brand kits. What you see in the Editor is exactly what exports.", + }, + { + tag: "Support", + title: "Redact before you share", + body: "Auto-detect faces and regex PII with on-device Vision. Censor sensitive lines before the Capture leaves your Mac.", + }, + { + tag: "Writers", + title: "Scroll long pages", + body: "Stitch tall content with Scroll Capture and on-device Vision registration — no browser extension needed.", + }, + { + tag: "Educators", + title: "Record walkthroughs", + body: "Region recording with system audio, trim editor, and local GIF export for lightweight sharing.", + }, + { + tag: "Teams", + title: "Optional upload", + body: "Configure your own Supabase bucket once, upload from the Editor, and copy a public link.", + }, +] as const; + +export function UseCasesSection() { + const [active, setActive] = React.useState(0); + const reduce = useReducedMotion(); + const current = personas[active]; + + return ( + <section className="border-b bg-muted/10"> + <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> + <SectionHeader + kicker="Use cases" + title={ + <> + Parcel is made{" "} + <span className="pb-gradient-text">for you</span>. + </> + } + subtitle="Pick a workflow — same app, different daily Capture jobs." + /> + + <div className="mt-12 grid gap-8 lg:grid-cols-[1fr_1.1fr] lg:gap-12"> + <div className="flex flex-wrap gap-2"> + {personas.map((p, i) => ( + <button + key={p.tag} + type="button" + onClick={() => setActive(i)} + aria-pressed={active === i} + className={cn( + "rounded-full border px-3.5 py-1.5 text-sm transition-all", + active === i + ? "border-[var(--brand-accent)]/50 bg-[var(--brand-accent)]/15 text-[var(--brand-accent)]" + : "border-border/60 text-muted-foreground hover:border-border hover:text-foreground" + )} + > + {p.tag} + </button> + ))} + </div> + + <motion.div + key={current.tag} + initial={reduce ? false : { opacity: 0, x: 12 }} + animate={reduce ? undefined : { opacity: 1, x: 0 }} + transition={{ type: "spring", stiffness: 260, damping: 28 }} + className="rounded-2xl border bg-card/80 p-8 backdrop-blur-sm" + > + <p className="font-mono text-[11px] uppercase tracking-widest text-[var(--brand-accent)]"> + For {current.tag} + </p> + <h3 className="mt-3 text-2xl font-semibold tracking-tight"> + {current.title} + </h3> + <p className="mt-3 text-sm leading-relaxed text-muted-foreground"> + {current.body} + </p> + <Link + href="/docs/getting-started" + className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-[var(--brand-secondary)] transition-colors hover:text-[var(--brand-accent)]" + > + Get started <ArrowRight className="size-4" /> + </Link> + </motion.div> + </div> + </div> + </section> + ); +} diff --git a/Website/components/verified-proof.tsx b/Website/components/verified-proof.tsx new file mode 100644 index 0000000..32a4a39 --- /dev/null +++ b/Website/components/verified-proof.tsx @@ -0,0 +1,19 @@ +import { proofPoints } from "@/lib/site"; + +export function VerifiedProof() { + return ( + <section aria-label="Verified Parcel facts" className="border-b bg-background"> + <dl className="mx-auto grid max-w-6xl grid-cols-2 px-4 py-7 md:grid-cols-4 md:py-9"> + {proofPoints.map((point, index) => ( + <div + key={point.label} + className={`px-4 py-3 text-center md:px-8 ${index % 2 === 1 ? "border-l" : ""} ${index > 1 ? "border-t md:border-t-0" : ""} ${index === 2 ? "md:border-l" : ""}`} + > + <dt className="text-2xl font-semibold tracking-tight md:text-3xl">{point.value}</dt> + <dd className="mt-1 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground md:text-xs">{point.label}</dd> + </div> + ))} + </dl> + </section> + ); +} diff --git a/Website/components/workflow-showcase.tsx b/Website/components/workflow-showcase.tsx new file mode 100644 index 0000000..0f93804 --- /dev/null +++ b/Website/components/workflow-showcase.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; +import { Check } from "lucide-react"; +import { productStories } from "@/lib/site"; +import { cn } from "@/lib/utils"; + +const tones = { + paper: "bg-[#f2f0eb] text-[#151618] dark:bg-[#e9e7e2] dark:text-[#151618]", + ink: "dark-surface bg-[#0b0c0f] text-white", + mist: "bg-[#e8f3ef] text-[#121816] dark:bg-[#dfece8] dark:text-[#121816]", +}; + +export function WorkflowShowcase() { + return ( + <section id="workflow" aria-labelledby="workflow-title" className="scroll-mt-20"> + <div className="border-b bg-background px-4 py-16 text-center md:py-24"> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">Workflow</p> + <h2 id="workflow-title" className="mx-auto mt-4 max-w-3xl text-balance text-4xl font-semibold tracking-[-0.035em] md:text-6xl"> + From frozen pixels to a clear point. + </h2> + <p className="mx-auto mt-5 max-w-2xl text-pretty text-base leading-7 text-muted-foreground md:text-lg"> + Parcel keeps the path short: make a Selection, add the right Annotation, and send the result where it needs to go. + </p> + </div> + + {productStories.map((story) => ( + <article key={story.id} className={cn("border-b", tones[story.tone])}> + <div className="mx-auto grid max-w-7xl items-center gap-10 px-4 py-16 md:py-24 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:py-32"> + <div className={cn("max-w-xl", story.reverse && "lg:order-2 lg:pl-8")}> + <p className={cn( + "font-mono text-[11px] font-semibold uppercase tracking-[0.2em]", + story.tone === "ink" ? "text-zinc-400" : "text-[#505451]" + )}>{story.eyebrow}</p> + <h3 className="mt-4 text-balance text-4xl font-semibold tracking-[-0.04em] md:text-5xl">{story.title}</h3> + <p className={cn( + "mt-5 text-pretty text-base leading-7 md:text-lg md:leading-8", + story.tone === "ink" ? "text-zinc-300" : "text-[#454a47]" + )}>{story.body}</p> + <ul className="mt-8 space-y-3"> + {story.details.map((detail) => ( + <li key={detail} className="flex items-start gap-3 text-sm font-medium"> + <span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full border border-current/20 bg-current/[0.06]"> + <Check className="size-3" strokeWidth={2.5} /> + </span> + {detail} + </li> + ))} + </ul> + </div> + <figure className={cn("relative", story.reverse && "lg:order-1")}> + <div className="absolute -inset-6 rounded-[2.5rem] bg-current opacity-[0.035] blur-2xl" aria-hidden /> + <Image + src={story.mediaSrc} + width={1440} + height={900} + alt={story.mediaAlt} + sizes="(max-width: 1024px) 100vw, 50vw" + className="relative w-full rounded-2xl border border-current/10 shadow-[0_30px_80px_rgba(0,0,0,.2)]" + /> + </figure> + </div> + </article> + ))} + </section> + ); +} diff --git a/Website/components/workflow-steps.tsx b/Website/components/workflow-steps.tsx index 67f2e1d..166ec95 100644 --- a/Website/components/workflow-steps.tsx +++ b/Website/components/workflow-steps.tsx @@ -4,6 +4,7 @@ import { Camera, ClipboardCopy, PenLine } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { SectionHeader } from "@/components/section-header"; import { workflow } from "@/lib/site"; +import { cn } from "@/lib/utils"; const STEP_ICONS = [Camera, PenLine, ClipboardCopy]; @@ -11,13 +12,26 @@ export function WorkflowSteps() { const reduce = useReducedMotion(); return ( - <section id="workflow" className="border-b"> - <div className="mx-auto max-w-6xl px-4 py-16 md:py-24"> + <section id="workflow" className="relative border-b"> + <div + aria-hidden + className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,color-mix(in_srgb,var(--brand-secondary)_8%,transparent),transparent)]" + /> + <div className="relative mx-auto max-w-6xl px-4 py-16 md:py-24"> <SectionHeader kicker="Workflow" - title="Freeze, mark up, ship — in three steps." + title={ + <> + Freeze, mark up,{" "} + <span className="pb-gradient-text">ship</span> — in three steps. + </> + } /> - <div className="mt-10 grid grid-cols-1 gap-5 md:grid-cols-3"> + <div className="relative mt-12 grid grid-cols-1 gap-5 md:grid-cols-3"> + <div + aria-hidden + className="pointer-events-none absolute left-[16.67%] right-[16.67%] top-9 hidden h-px bg-gradient-to-r from-transparent via-[var(--brand-secondary)]/40 to-transparent md:block" + /> {workflow.map((step, i) => { const Icon = STEP_ICONS[i] ?? Camera; return ( @@ -32,13 +46,19 @@ export function WorkflowSteps() { damping: 24, delay: i * 0.08, }} - className="rounded-2xl border bg-card p-6" + className="group relative rounded-2xl border bg-card/80 p-6 backdrop-blur-sm transition-colors hover:border-[var(--brand-secondary)]/30 hover:bg-card" > <div className="flex items-center justify-between"> - <Icon className="size-5 text-muted-foreground" /> - <span className="font-mono text-xs text-muted-foreground"> + <span + className={cn( + "inline-flex size-10 items-center justify-center rounded-xl font-mono text-sm font-semibold", + "bg-[var(--brand-secondary)]/15 text-[var(--brand-secondary)] ring-1 ring-[var(--brand-secondary)]/25", + "transition-shadow group-hover:shadow-[0_0_24px_color-mix(in_srgb,var(--brand-secondary)_20%,transparent)]" + )} + > {step.step} </span> + <Icon className="size-5 text-muted-foreground transition-colors group-hover:text-[var(--brand-accent)]" /> </div> <h3 className="mt-4 text-base font-medium">{step.title}</h3> <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground"> diff --git a/Website/eslint.config.mjs b/Website/eslint.config.mjs new file mode 100644 index 0000000..f3fa6fb --- /dev/null +++ b/Website/eslint.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; + +export default defineConfig([ + ...nextVitals, + globalIgnores([".next/**", "out/**", "node_modules/**", "next-env.d.ts"]), +]); diff --git a/Website/lib/docs.ts b/Website/lib/docs.ts new file mode 100644 index 0000000..88c598b --- /dev/null +++ b/Website/lib/docs.ts @@ -0,0 +1,82 @@ +export type DocPage = { + slug: string; + title: string; + description: string; + section: string; +}; + +export const docSections: { title: string; pages: DocPage[] }[] = [ + { + title: "Getting started", + pages: [ + { + slug: "", + title: "Introduction", + description: "What Parcel is and how it fits into your workflow.", + section: "Getting started", + }, + { + slug: "getting-started", + title: "Quick start", + description: "Install, grant permissions, and take your first Capture.", + section: "Getting started", + }, + { + slug: "shortcuts", + title: "Keyboard shortcuts", + description: "Global hotkeys, Overlay controls, and Editor commands.", + section: "Getting started", + }, + ], + }, + { + title: "Core workflows", + pages: [ + { + slug: "capture", + title: "Capture & Selection", + description: "Freeze-then-select, window snap, and scroll Capture.", + section: "Core workflows", + }, + { + slug: "editor", + title: "Editor & Annotations", + description: "Tools, Layers, Beautify, Adjustments, and export.", + section: "Core workflows", + }, + { + slug: "recording", + title: "Screen recording", + description: "Region recording, trim, GIF export, and audio.", + section: "Core workflows", + }, + ], + }, + { + title: "Privacy & integrations", + pages: [ + { + slug: "privacy", + title: "Privacy & permissions", + description: "On-device Vision, sandbox, and what leaves your Mac.", + section: "Privacy & integrations", + }, + { + slug: "supabase", + title: "Supabase upload", + description: "Configure optional Storage upload from the Editor.", + section: "Privacy & integrations", + }, + ], + }, +]; + +export const allDocPages: DocPage[] = docSections.flatMap((s) => s.pages); + +export function docHref(slug: string) { + return slug ? `/docs/${slug}` : "/docs"; +} + +export function findDocPage(slug: string) { + return allDocPages.find((p) => p.slug === slug); +} diff --git a/Website/lib/nav.ts b/Website/lib/nav.ts new file mode 100644 index 0000000..5ea8519 --- /dev/null +++ b/Website/lib/nav.ts @@ -0,0 +1,10 @@ +/** Shared navigation — hash links use /# so they work from any route */ +export const navLinks = [ + { href: "/#features", label: "Features" }, + { href: "/#workflow", label: "Workflow" }, + { href: "/#privacy", label: "Privacy" }, + { href: "/docs", label: "Docs", isRoute: true }, + { href: "/#install", label: "Install" }, +] as const; + +export type NavLink = (typeof navLinks)[number]; diff --git a/Website/lib/site.ts b/Website/lib/site.ts index e0315f2..39a276f 100644 --- a/Website/lib/site.ts +++ b/Website/lib/site.ts @@ -1,7 +1,128 @@ +import { theme } from "./theme"; + +export type ProductStory = { + id: "freeze" | "explain" | "share"; + eyebrow: string; + title: string; + body: string; + details: readonly string[]; + mediaSrc: string; + mediaAlt: string; + tone: "paper" | "ink" | "mist"; + reverse?: boolean; +}; + +export const productStories: readonly ProductStory[] = [ + { + id: "freeze", + eyebrow: "01 · Capture", + title: "Freeze exactly what you saw.", + body: "Press one global hotkey and every display becomes a full-resolution frozen Capture. Drag a Selection, snap to a window, or continue into Scroll Capture without racing the screen.", + details: [ + "Region, window, display, and Scroll Capture", + "Tab-to-snap window Selection", + "Full-resolution pixels across every display", + ], + mediaSrc: "/media/workflow-overlay.webp", + mediaAlt: + "Parcel Overlay with Window mode selected around a frozen application window", + tone: "paper", + }, + { + id: "explain", + eyebrow: "02 · Editor", + title: "Make the point obvious.", + body: "Open the Editor with the right Tool already close at hand. Add an Arrow, highlight a region, Censor sensitive details, or reorder the Layer stack while the original Capture remains untouched.", + details: [ + "Fourteen focused Annotation Tools", + "Click-to-edit styles, color, size, and position", + "Annotation-only undo and redo", + ], + mediaSrc: "/media/workflow-editor.webp", + mediaAlt: + "Parcel Editor showing Arrow, Rectangle, Censor, and Layer controls", + tone: "ink", + reverse: true, + }, + { + id: "share", + eyebrow: "03 · Output", + title: "Share something polished.", + body: "Apply a saved brand kit, add window chrome and padding, then copy or export exactly what the Canvas shows. Recordings, local files, and optional Supabase links follow the same direct workflow.", + details: [ + "Beautify backgrounds and saved brand kits", + "PNG, JPEG, HEIC, and TIFF output", + "Optional upload only when you choose it", + ], + mediaSrc: "/media/workflow-export.webp", + mediaAlt: + "Parcel output view showing a polished Capture and local export options", + tone: "mist", + }, +] as const; + +export const proofPoints = [ + { value: "Native", label: "SwiftUI app" }, + { value: "Local", label: "On-device Vision" }, + { value: "Zero", label: "Cloud AI calls" }, + { value: "MIT", label: "Open source" }, +] as const; + +export const focusedCapabilities = [ + { + id: "capture-modes", + eyebrow: "Capture", + title: "Choose only what matters", + body: "Capture a region, window, display, or long scrolling surface from one frozen Overlay.", + mediaSrc: "/media/workflow-overlay.webp", + mediaAlt: "Parcel window Selection in the Overlay", + }, + { + id: "annotation-tools", + eyebrow: "Annotation", + title: "Fourteen Tools, one clear Canvas", + body: "Arrows, Text, Pencil, Measure, Spotlight, Loupe, and more stay editable after creation.", + mediaSrc: "/media/workflow-editor.webp", + mediaAlt: "Parcel Annotation toolbar and Canvas", + }, + { + id: "local-vision", + eyebrow: "Censor", + title: "Protect details on your Mac", + body: "Blur, pixelate, erase, recognize text, find faces, read QR codes, and translate locally.", + mediaSrc: "/media/workflow-editor.webp", + mediaAlt: "Censor Annotation and local inspection controls in Parcel", + }, + { + id: "recording", + eyebrow: "Recording", + title: "Record the walkthrough too", + body: "Capture a region at up to 120 fps with system audio, click highlights, trimming, and GIF export.", + mediaSrc: "/media/workflow-overlay.webp", + mediaAlt: "Parcel Overlay with the Record mode available", + }, + { + id: "beautify", + eyebrow: "Beautify", + title: "Turn utility into presentation", + body: "Add padding, gradients, window chrome, radius, and shadow, then save the combination as a brand kit.", + mediaSrc: "/media/workflow-export.webp", + mediaAlt: "A polished Parcel Capture using a saved Beautify brand kit", + }, + { + id: "history-share", + eyebrow: "History", + title: "Re-edit instead of starting over", + body: "Open past Capture documents with their Annotations and settings, then save locally or upload by choice.", + mediaSrc: "/media/workflow-export.webp", + mediaAlt: "Parcel output controls for copying, saving, and optional sharing", + }, +] as const; + export const stats = [ { value: "14", label: "Annotation tools" }, { value: "30", label: "Beautify gradients" }, - { value: "4", label: "Export formats" }, + { value: "120", label: "Max fps recording" }, { value: "0", label: "Cloud AI calls" }, ]; @@ -14,53 +135,90 @@ export const logos = [ "Supabase", ]; +export const annotationTools = [ + "Arrow ×5 styles", + "Rectangle", + "Ellipse", + "Text", + "Pencil", + "Highlighter", + "Number", + "Censor ×4 modes", + "Stamp", + "Measure", + "Spotlight", + "Loupe", + "Eyedropper", +]; + export const features = [ { id: "capture", title: "Instant Capture", - body: "Global hotkey freezes every display. Drag a region, snap to windows, or stitch all screens — pixels stay full resolution.", + body: "Global hotkey freezes every display. Drag a region, snap to windows with Tab, or stitch all screens — pixels stay full resolution.", size: "wide" as const, icon: "camera", }, + { + id: "edit", + title: "Click-to-edit annotations", + body: "Select any Annotation and edit stroke, style, color, and fill in real time. Full undo/redo on the Layer stack — rotate, resize, and reposition without switching Tools.", + size: "sm" as const, + icon: "mouse", + }, { id: "tools", title: "14 annotation tools", - body: "Arrows ×5 styles, ellipse, censor ×4 modes, stamps, spotlight, measure, and utility loupe + eyedropper.", + body: "Arrows ×5 styles, ellipse, censor ×4 modes, stamps, spotlight, measure, and utility loupe + eyedropper — all in one toolbar.", size: "sm" as const, icon: "pen", }, { id: "scroll", title: "Scroll Capture", - body: "Select a tall region from the Overlay, scroll the source, and stitch with on-device Vision registration.", + body: "Select a tall region from the Overlay, scroll the source, and stitch with on-device Vision registration. Live preview as frames stack.", size: "sm" as const, icon: "scroll", }, { id: "record", title: "Screen recording", - body: "MP4 at 30/60/120 fps with system audio, trim editor, and local GIF export — no subscription recorder.", + body: "Select a region from the Overlay, then record MP4 at 30/60/120 fps with system audio, microphone on macOS 15+, click highlights, trim editor, and local GIF export.", size: "wide" as const, icon: "video", }, + { + id: "censor", + title: "Smart censor", + body: "Pixelate, blur, solid fill, or erase. Auto-redact regex PII, censor detected faces, and erase mode matches surrounding Capture pixels.", + size: "sm" as const, + icon: "shield", + }, { id: "beautify", title: "Beautify", - body: "30 gradient backgrounds, window chrome, padding, radius, shadow, and saved brand kits for consistent ship-ready Captures.", + body: "30 gradient backgrounds, window chrome with traffic lights, padding, radius, shadow, and saved brand kits for ship-ready Captures.", size: "sm" as const, icon: "sparkles", }, + { + id: "ocr", + title: "OCR & translate", + body: "Extract text with Apple Vision. Copy to clipboard, translate on-device (macOS 26+), or censor sensitive lines — all local.", + size: "sm" as const, + icon: "scan", + }, { id: "vision", title: "Local Vision", - body: "OCR, QR, face detection, regex PII censoring, and on-device translation — nothing leaves your Mac unless you upload.", + body: "QR detection, face finding, and regex PII inspection — nothing leaves your Mac unless you choose to upload.", size: "tall" as const, icon: "eye", }, { id: "history", title: "Capture history", - body: "Disk-backed documents restore annotations, adjustments, beautify, and output format — re-edit any past Capture.", + body: "Disk-backed documents restore annotations, adjustments, beautify, and output format — re-edit any past Capture from ⌘⇧H.", size: "sm" as const, icon: "history", }, @@ -71,6 +229,13 @@ export const features = [ size: "sm" as const, icon: "cloud", }, + { + id: "native", + title: "Lightweight & native", + body: "Pure SwiftUI + ScreenCaptureKit. No Electron, no web views, no bloat. Lives quietly in your menu bar with Sparkle updates.", + size: "wide" as const, + icon: "cpu", + }, ]; export const workflow = [ @@ -93,10 +258,13 @@ export const workflow = [ export const shortcuts = [ { keys: "⌘⇧2", action: "Capture region (configurable in Preferences)" }, + { keys: "⌘⇧H", action: "Open Capture history" }, { keys: "⌘C", action: "Copy Capture from Editor" }, { keys: "⌘S", action: "Save Capture" }, { keys: "⌘Z / ⇧⌘Z", action: "Undo / redo annotations" }, { keys: "Tab", action: "Window snap in Overlay" }, + { keys: "Shift", action: "Constrain shape while drawing" }, + { keys: "Space", action: "Reposition shape while drawing" }, { keys: "Esc", action: "Cancel Overlay or exit utility tool" }, ]; @@ -109,6 +277,18 @@ export const guides = [ 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.", }, + { + 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.", + }, + { + 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; on macOS 15+ microphone and click highlights are available.", + }, + { + title: "Translate text on-device", + body: "Inspect Capture → Recognize Text → Translate On-Device. Uses Apple Translation on macOS 26+ — nothing leaves your Mac.", + }, { title: "Save a brand kit", body: "Enable Beautify, tune padding and gradient, then save a named kit from the Beautify panel.", @@ -117,6 +297,10 @@ export const guides = [ title: "Upload to Supabase", body: "Paste project URL, anon key, and bucket in Preferences. Upload from the Editor copies the link.", }, + { + title: "Export a recording as GIF", + body: "After stopping a recording, open the trim window and export GIF for lightweight sharing.", + }, ]; export const faqs = [ @@ -136,6 +320,10 @@ export const faqs = [ q: "How do I configure Supabase upload?", a: "Create a public Storage bucket, paste the project URL and anon key in Preferences, and set an optional custom public base URL.", }, + { + q: "What annotation tools are included?", + a: "Fourteen Tools: Select plus Arrow (5 styles), Rectangle, Ellipse, Text, Pencil, Highlighter, Number, Censor (blur/pixelate/solid/erase), Stamp, Measure, Spotlight, Loupe, and Eyedropper.", + }, ]; export const DOWNLOAD_URL = "/downloads/Parcel.zip"; @@ -143,3 +331,6 @@ export const HOMEBREW_CMD = "brew install --cask parcel"; export const GITHUB_URL = "https://github.com/bswxyz/notable"; export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://parcel.parable.dev"; + +/** Brand theme re-export — edit lib/theme.ts to change site-wide colors */ +export { theme }; diff --git a/Website/lib/theme.ts b/Website/lib/theme.ts new file mode 100644 index 0000000..23d010c --- /dev/null +++ b/Website/lib/theme.ts @@ -0,0 +1,33 @@ +/** + * Parcel brand theme — change colors here, everything else follows. + * Used by components via CSS variables set in globals.css. + */ +export const theme = { + /** Primary accent — CTAs, active states, glow */ + accent: "#5ee4b5", + /** Secondary accent — gradients, icons */ + secondary: "#8b5cf6", + /** Tertiary — gradient stops, highlights */ + tertiary: "#ec4899", + /** Warm highlight — stamps, badges */ + gold: "#f5c451", + + /** Page backgrounds */ + ink: "#070708", + surface: "#111113", + elevated: "#18181b", + + /** Hero aurora gradient stops */ + aurora: ["#8b5cf6", "#5ee4b5", "#ec4899"] as const, + + /** Typography */ + display: "var(--font-instrument-serif)", + sans: "var(--font-geist-sans)", + mono: "var(--font-geist-mono)", + + /** Motion */ + easeOut: "cubic-bezier(0.22, 1, 0.36, 1)", + easeSnap: "cubic-bezier(0.16, 1, 0.3, 1)", +} as const; + +export type Theme = typeof theme; diff --git a/Website/next.config.ts b/Website/next.config.ts index a7d4cbc..903e1f3 100644 --- a/Website/next.config.ts +++ b/Website/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export", images: { unoptimized: true }, + experimental: { inlineCss: true }, }; export default nextConfig; diff --git a/Website/public/assets/hero-marketing.png b/Website/public/assets/hero-marketing.png new file mode 100644 index 0000000..188e4b5 Binary files /dev/null and b/Website/public/assets/hero-marketing.png differ diff --git a/Website/public/downloads/Parcel.zip b/Website/public/downloads/Parcel.zip index d8af1e1..1e7b5e6 100644 Binary files a/Website/public/downloads/Parcel.zip and b/Website/public/downloads/Parcel.zip differ diff --git a/Website/public/media/hero-workflow-poster.webp b/Website/public/media/hero-workflow-poster.webp new file mode 100644 index 0000000..35d9742 Binary files /dev/null and b/Website/public/media/hero-workflow-poster.webp differ diff --git a/Website/public/media/hero-workflow.mp4 b/Website/public/media/hero-workflow.mp4 new file mode 100644 index 0000000..6bf379d Binary files /dev/null and b/Website/public/media/hero-workflow.mp4 differ diff --git a/Website/public/media/workflow-editor.svg b/Website/public/media/workflow-editor.svg new file mode 100644 index 0000000..838b1b0 --- /dev/null +++ b/Website/public/media/workflow-editor.svg @@ -0,0 +1,74 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900" role="img" aria-labelledby="title desc"> + <title id="title">Parcel Editor with Annotations + A staged Parcel Editor showing a Capture, Annotation toolbar, Censor, Layers, and export controls. + + + + + + + + + + + + + + + Parcel + + + + + CREATE + + Arrow + Rectangle + Text + Pencil + Censor + Spotlight + Loupe + + + Copy + Save + Export + + + + + + + + + + + + + + + + + + + + + + + LAYERS + + Arrow + Rectangle + Censor + Spotlight + + STYLE + + + + Bring forward + + 1440 × 900 pt · Arrow selected · on screen = saved + + diff --git a/Website/public/media/workflow-editor.webp b/Website/public/media/workflow-editor.webp new file mode 100644 index 0000000..a256a3d Binary files /dev/null and b/Website/public/media/workflow-editor.webp differ diff --git a/Website/public/media/workflow-export.svg b/Website/public/media/workflow-export.svg new file mode 100644 index 0000000..2638577 --- /dev/null +++ b/Website/public/media/workflow-export.svg @@ -0,0 +1,52 @@ + + Parcel polished export + A staged Parcel export with a Beautify background, window chrome, output settings, and completed copy action. + + + + + + + + + + + + + + + + + Launch notes + + + + + + + + + + + + + + + + Output + FORMAT + PNG + BEAUTIFY + Parable glowsaved brand kit + Copy Capture + Save locally + Copy & finish + + + + + + Capture copied + What you saw is what was saved. + + diff --git a/Website/public/media/workflow-export.webp b/Website/public/media/workflow-export.webp new file mode 100644 index 0000000..7da5866 Binary files /dev/null and b/Website/public/media/workflow-export.webp differ diff --git a/Website/public/media/workflow-overlay.svg b/Website/public/media/workflow-overlay.svg new file mode 100644 index 0000000..d59bd94 --- /dev/null +++ b/Website/public/media/workflow-overlay.svg @@ -0,0 +1,72 @@ + + Parcel Overlay choosing a window + A staged Parcel interface showing a frozen macOS desktop, window Selection, and Capture mode controls. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Region + Window + Display + Scroll + OCR + Record + + + + + + Click a window to Capture it + Tab snaps · Esc cancels · frozen full resolution + + + Selection + diff --git a/Website/public/media/workflow-overlay.webp b/Website/public/media/workflow-overlay.webp new file mode 100644 index 0000000..dfd1d5e Binary files /dev/null and b/Website/public/media/workflow-overlay.webp differ diff --git a/Website/public/og.svg b/Website/public/og.svg index 16add50..197edb3 100644 --- a/Website/public/og.svg +++ b/Website/public/og.svg @@ -1,19 +1,38 @@ - + + Parcel for macOS + Parcel product card with a staged Capture Editor and the message Capture anything. Make it unmistakable. - - - - - - - + + + + + - + - - - Parcel - Local Capture for macOS — annotate, record, redact - - Download free + + + + P + Parcel + + Capture anything. + Make it unmistakable. + Native macOS Capture · Local Vision · No cloud AI + + + + + Parcel Editor + + Arrow + + + + + + + + Download free for macOS + MIT licensed · macOS 13+ · Apple Silicon and Intel diff --git a/docs/MACOS13_VM_QA.md b/docs/MACOS13_VM_QA.md index 0e8e9a9..de7fda7 100644 --- a/docs/MACOS13_VM_QA.md +++ b/docs/MACOS13_VM_QA.md @@ -27,7 +27,7 @@ Additional checks: - [ ] Confirm Capture does **not** crash (SCStream fallback) - [ ] Confirm recording produces playable MP4 with audio (AVFoundation writer) -- [ ] Translation button hidden or disabled (requires macOS 15+) +- [ ] Translation button hidden or disabled (requires macOS 26+) ## Logging diff --git a/docs/QA_CHECKLIST.md b/docs/QA_CHECKLIST.md index a4f153e..561b7ce 100644 --- a/docs/QA_CHECKLIST.md +++ b/docs/QA_CHECKLIST.md @@ -2,6 +2,42 @@ Run on macOS 26 with **Screen Recording** granted. Use a **Release + sandboxed** build for final sign-off. +## 2026-08-12 Final Verification Status + +Evidence folder: `qa-evidence/final-ship-2026-08-12/` + +- Build: PASS — `xcodegen generate`, Debug, and Release builds completed. +- Release app checks: PASS for bundle ID, display name, minimum macOS version, menu-bar mode, Sparkle feed URL, universal binary, sandbox entitlement, no `get-task-allow`, Sparkle public key, and release smoke checks. +- Export codecs: PASS for selected PNG/JPEG/HEIC/TIFF/WebP output paths; PNG/JPEG/TIFF use system bitmap encoders, HEIC uses ImageIO, and WebP uses local libwebp. +- Recording writer/export: PASS for deterministic low-level MP4 finalization with H.264 video, AAC audio, and `moov` metadata; production writer unit coverage verifies pause skip behavior and temporary-file install before replacing the final output; trim model coverage exports readable MP4 and animated GIF outputs without a save panel. +- History: PASS for create/restore/save/delete, filter predicate, and retention pruning against the persisted local index. +- No-network-AI invariant: PASS — `Scripts/verify-no-network-ai.sh` reports no cloud/network AI identifiers, network client APIs limited to optional Supabase upload, and Apple local Vision/Translation framework use. +- Website: PASS for `npm run lint` and `npm run build`. +- Website export artifact: PASS — `Scripts/verify-website-export-artifact.sh` confirms static `Website/out` contains byte-identical `downloads/Parcel.zip` and `appcast.xml` from `Website/public`. +- Appcast structure: PASS — `Scripts/verify-release.sh` validates well-formed XML, feed title, parseable `pubDate`, `sparkle:os`, enclosure type, version/build, and download URL. +- Release script syntax: PASS — `Scripts/final-local-qa.sh` validates the release/appcast/preflight shell scripts with `bash -n`. +- Workflow syntax: PASS — `local-qa-current/release/workflow-syntax.log` parses GitHub workflow YAML and validates each embedded `run:` block with `bash -n`. +- CI non-gated coverage: PASS — push/PR CI runs script syntax, no-network-AI verification, Debug build, `ParcelUnit`, website lint, and website build. +- GitHub release signing/setup: PASS/GATED — tag-release CI has explicit `contents: write`, imports a base64 Developer ID Application `.p12` into an ephemeral keychain, removes the temporary `.p12`, runs the same release preflight/build/notary/appcast verifier path, builds and verifies the website export payload, then creates the GitHub Release; final release still gates until the certificate/notary/manual-QA secrets are configured. +- Release preflight hook: PASS/GATED — public `Scripts/release.sh` runs `Scripts/verify-release-gates.sh` before archive/export work and exits early when release-machine gates are missing. +- Appcast updater: PASS/GATED — dry-run with a fake Sparkle signer derives version/build/title/pubDate from a temp app ZIP, writes ZIP length/signature into a temp appcast, rejects malformed appcast input without modifying it, and leaves `Website/public/appcast.xml` unchanged; final real appcast update remains gated until the notarized ZIP exists. +- Sparkle public key consistency: PASS — source `SUPublicEDKey`, the Sparkle Keychain account public key, and the current website ZIP app key match. +- Real Sparkle signing dry-run: PASS — temp-copy signing uses the installed Sparkle `sign_update`, derives app metadata, updates a temp appcast, matches ZIP length, populates an EdDSA signature, verifies it against the ZIP, and leaves the real appcast unchanged. +- Release gate preflight: PASS/GATED — `Scripts/verify-release-gates.sh` checks Developer ID, notary credentials, Sparkle signing material, Screen Recording proof, second-display access, Supabase live credentials, and macOS 13 VM availability before a public ZIP attempt. +- Release gate handoff: PASS/GATED — `Scripts/write-release-gate-handoff.sh` writes a release-machine evidence packet for the external gates that cannot be closed on this machine. +- Release gate evidence verifier: PASS/GATED — `Scripts/verify-release-gate-evidence.sh` checks completed handoff folders and currently reports 2 pass / 0 fail / 15 gated on the local packet. +- Release gate evidence verifier self-test: PASS — synthetic complete evidence passes, missing manual metadata gates, and failed final logs fail. +- Local QA runner: PASS/GATED — `Scripts/final-local-qa.sh` completed build, unit, media, website/export, release-gate preflight, and release-verifier stages; only credential/environment gates remain. +- Ship status summary: PASS — `Scripts/ship-status.sh` summarizes the final evidence folder into local completion, public-release unblocked percentage, and remaining gates. +- Public claims audit: PASS/GATED — `qa-evidence/final-ship-2026-08-12/CLAIMS_AUDIT.md` maps README, parity, and checklist claims to direct evidence or explicit gates; no contradictory claim evidence was found. +- Release verifier: PASS/GATED — `Scripts/verify-release.sh` reports 23 pass / 0 fail / 5 gated on the current website ZIP. +- XCTest host stability: PASS — app-hosted `ParcelUnit` launches skip coordinator, Sparkle, hotkey, and onboarding side effects, so the final harness reaches XCTest reliably. +- Unit tests: PASS — `ParcelUnit` covers 51 local model tests: export-format claims, selected export encoder containers, sRGB export conversion, Retina scale-down Capture resizing, Capture preference defaults/toggles, shutter sound feedback policy, extra Capture Area hotkey bindings, Selection aspect preset cycling/ratio geometry/Shift bypass, window snap hit testing, all-display stitch desktop arrangement, countdown display/DND policy behavior, Pin opacity/close gesture mapping, Pin lock/visibility state, recently-closed restore stack behavior, clipboard image import, print payload sizing/pagination, share payload item/anchor behavior, production recording writer finalization/pause behavior, recording temporary-file install, recording trim MP4/GIF export, recording max-resolution geometry, keystroke HUD label/PiP placement behavior, previous Capture/recording area preference state, Scroll Capture vertical/horizontal stitching and no-overlap rejection, window Capture matte removal, brand kit save/reload/remove persistence, color swatch persistence/dedupe/capping, smart highlighter snap-to-text-box behavior, Quick Access shortcut/swipe mapping, 14-Tool inventory, Arrow/Censor style inventories, Censor erase outside-ring Retina sampling, URL-scheme actions, after-Capture action planning, hotkey/recording preference models, OCR strip-line-break formatting, WebP bytes, `.parcel` round-trip, History create/restore/save/delete/filter/retention prune, Upload disabled/not-configured behavior, Supabase upload request/success URL handling, Supabase HTTP error handling, local Vision QR detection and Capture-point coordinate mapping, local PII classification, annotation undo/redo + Layer order, document settings outside undo, transform remapping, crop transform remapping, expand/combine transform placement, and filename templates. +- UI automation: PARTIAL — app launch and menu contents passed; capture/recording UI tests are blocked until Screen Recording is granted to the exact test/release app path. +- Computer Use: BLOCKED — `node_repl` + `@oai/sky` can inspect a normal System Settings window, proving the runtime is alive, but Parcel bundle-id targeting is ambiguous because several local builds share `dev.parable.Parcel`; exact disposable app-path targeting and SystemUIServer/menu-bar targeting time out. Refreshed evidence is captured at `qa-evidence/final-ship-2026-08-12/local-qa-current/ui/computer-use-sky-refresh-current.md`, with prior details in `computer-use-sky-normal-window-sanity-current.md`, `computer-use-sky-focused-probes-current.md`, the earlier Parcel state JSON files, and the timeout log. +- Public ZIP release: BLOCKED — no Developer ID Application identity and no notary credentials. Current website ZIP is Apple Development signed and rejected by Gatekeeper; the real Sparkle appcast signature should be generated only after the final notarized ZIP exists. +- Hardware/service gates: BLOCKED — second display, live Supabase credentials, and macOS 13 VM were unavailable. + **Build under test:** _______________ **Tester:** _______________ **Date:** _______________ @@ -82,7 +118,7 @@ Tools: Select, Arrow, Rectangle, Ellipse, Text, Pencil, Censor, Number, Stamp, H ## 6. Adjustments + Beautify + Brand Kits -- [ ] Adjustments panel: change exposure/contrast — Canvas updates +- [ ] Adjustments panel: change brightness/contrast — Canvas updates - [ ] Preset applies correctly - [ ] Reset restores neutral - [ ] Beautify: enable gradient, padding, radius, shadow, chrome @@ -101,7 +137,7 @@ Tools: Select, Arrow, Rectangle, Ellipse, Text, Pencil, Censor, Number, Stamp, H - [ ] Face in Capture — face regions detected - [ ] Censor Detected Sensitive Text — PII regions censored - [ ] Censor Detected Faces — face blur censors added -- [ ] Translate (macOS 15+) — on-device translation in Vision panel +- [ ] Translate (macOS 26+) — on-device translation in Vision panel - [ ] No network traffic during Vision (except optional Upload) **Notes:** _______________ @@ -137,6 +173,9 @@ Tools: Select, Arrow, Rectangle, Ellipse, Text, Pencil, Censor, Number, Stamp, H ## 10. Supabase Upload (Optional) +- [x] Local mock: disabled/not-configured path returns a user-facing configuration error +- [x] Local mock: configured upload builds the expected Supabase Storage request and public URL +- [x] Local mock: bad-credential/HTTP error includes status code and response body - [ ] Preferences: project URL, anon key, bucket configured - [ ] Editor: Upload — progress shown - [ ] Link copied to clipboard — URL opens in browser @@ -177,6 +216,25 @@ Tools: Select, Arrow, Rectangle, Ellipse, Text, Pencil, Censor, Number, Stamp, H --- +## 13. v1.1–v1.6 Parity Smoke + +- [ ] Capture Previous Area (menu / ⌘⇧5) after one region Capture +- [ ] Preferences: Ask for name + after-Capture Copy / Pin / Editor toggles +- [ ] Hold ⇧ while dragging Selection — aspect preset ignored +- [ ] Recording: countdown, pause, resume, max resolution, mono audio +- [ ] Quick Access: ⌘C ⌘S ⌘E ⌘W; swipe down discards +- [ ] Pin: scroll opacity, middle-click close, Hide/Show Overlays, Close All Pins +- [ ] Restore Recently Closed + Open from Clipboard +- [ ] History: filter, Pin from row, retention prune +- [ ] `parcel://capture/region` and `parcel://capture/previous` (URL scheme enabled) +- [ ] Editor: transform menu (rotate/flip/expand), WebP export, Print, Share +- [ ] Save / open `.parcel` project round-trip +- [ ] Filename template tokens apply on Save / Upload + +**Notes:** _______________ + +--- + ## Release Hardening - [ ] Sandbox enabled — Capture, recording, save panel, history all work diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md new file mode 100644 index 0000000..cd01633 --- /dev/null +++ b/docs/RELEASE_READINESS.md @@ -0,0 +1,144 @@ +# Parcel Release Readiness + +Use this runbook when preparing the public website ZIP. It keeps local checks, credential gates, +and final artifact verification separate so a missing external dependency is explicit. + +## 1. Preflight External Gates + +```sh +Scripts/verify-release-gates.sh +Scripts/write-release-gate-handoff.sh +Scripts/verify-release-gate-evidence.sh qa-evidence/final-ship-YYYY-MM-DD/release-gate-handoff-current +``` + +The preflight checks local tools, Developer ID identity, notary credential availability, Sparkle +signing material, appcast/ZIP write access, Screen Recording verification, second-display access, +Supabase live-test credentials, and macOS 13 fallback availability. +The handoff script writes a dated `RELEASE_GATE_HANDOFF.md` packet with the exact proof files a +release machine should capture for Developer ID, notarization, TCC, second-display, Supabase, +macOS 13, appcast, final ZIP, and website deployment gates. +The evidence verifier checks that a completed packet has the expected final logs and that manual +proof notes are explicitly marked `VERIFIED: yes`. Manual proof templates are created with +`VERIFIED: no`; only change that line after the checks and metadata fields in the template are +complete. The template's packet ZIP hash is informational; fill the blank final ZIP hash after the +Developer ID signed and notarized `Website/public/downloads/Parcel.zip` is produced. + +For Computer Use UI proof, capture `node_repl` + `@oai/sky` evidence against the exact final/test +`Parcel.app` path. If Sky cannot drive Parcel's menu-bar/Overlay surfaces on the release machine, +include both the failed Parcel/SystemUIServer probe output and a normal-window sanity probe proving +whether Sky itself can inspect another app window. + +Expected local-only output on an uncredentialed machine is `0 failed` with one or more `GATE` +items. A public release machine should reach `0 failed, 0 gated`. + +Useful inputs: + +```sh +export DEVELOPMENT_TEAM=QFH99B6X5V +export NOTARYTOOL_PROFILE=parcel-release +# or: +export APPLE_ID=you@example.com +export APPLE_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx + +export SPARKLE_ED_KEY_FILE=/secure/path/parcel-sparkle.key +# or SPARKLE_ED_PRIVATE_KEY / Keychain account parcel.parable.dev +export SPARKLE_KEYCHAIN_ACCOUNT=parcel.parable.dev + +export PARCEL_SCREEN_RECORDING_VERIFIED=1 +export PARCEL_SECOND_DISPLAY_VERIFIED=1 +export PARCEL_SUPABASE_URL=https://example.supabase.co +export PARCEL_SUPABASE_ANON_KEY=... +export PARCEL_SUPABASE_BUCKET=captures +export PARCEL_MACOS13_VM_VERIFIED=1 +``` + +## 2. Run Local Verification + +```sh +STAMP=$(date +%Y-%m-%d-final-local) \ +EVIDENCE_DIR="$PWD/qa-evidence/final-ship-$(date +%Y-%m-%d)/local-qa-current" \ +DERIVED_DATA=/tmp/ParcelFinalLocalQA \ +Scripts/final-local-qa.sh +``` + +This runs XcodeGen, Debug/Release builds, `ParcelUnit`, release smoke checks, no-network-AI +verification, recording finalization, appcast updater dry-run, release gate preflight, website +lint/build, website export asset verification, and public ZIP verification. + +## 3. Build The Public ZIP + +With gates satisfied: + +```sh +UPDATE_APPCAST=1 \ +DEVELOPMENT_TEAM=QFH99B6X5V \ +NOTARYTOOL_PROFILE=parcel-release \ +Scripts/release.sh +``` + +`Scripts/release.sh` archives, exports with Developer ID, notarizes, staples, zips, copies +`build/Parcel.zip` to `Website/public/downloads/Parcel.zip`, and can update +`Website/public/appcast.xml` when Sparkle signing material is available. The appcast updater +derives title/version/build/pubDate from the final ZIP app before writing length/signature, then +validates a temporary updated appcast before replacing the source file. For a non-`SKIP_NOTARIZE` +release, it runs `Scripts/verify-release-gates.sh` before archiving and `Scripts/verify-release.sh` +against the website ZIP before exiting. + +If using the Keychain-stored Sparkle key, approve the `sign_update` Keychain prompt on the release +machine. For non-interactive release jobs, provide `SPARKLE_ED_KEY_FILE` or +`SPARKLE_ED_PRIVATE_KEY`. Use `VERIFY_RELEASE=0` only for intentionally gated local packaging +experiments. Use `RUN_RELEASE_PREFLIGHT=0` only when intentionally bypassing the full +release-machine gate check. + +## 4. Verify The Final Artifact + +```sh +Scripts/verify-release.sh Website/public/downloads/Parcel.zip +npm --prefix Website run lint +npm --prefix Website run build +Scripts/verify-website-export-artifact.sh +``` + +The release verifier must report `0 failed, 0 gated` before deploying the website. Public +`Scripts/release.sh` runs the gate preflight and final verifier automatically; this manual command +is the explicit post-release double-check. `Scripts/verify-website-export-artifact.sh` proves the +static export payload contains the current `downloads/Parcel.zip` and `appcast.xml`. + +## 5. Publish The Website Payload + +`Website/out` is the deployable static website payload. Deploy that exact output after the final +artifact verifier and website export verifier pass. The tag-release GitHub workflow builds and +verifies this payload, uploads it as the `website-dist` artifact, and only then creates the GitHub +Release. A connected Vercel project can deploy the same payload, or you can deploy it manually from +the release runner. Do not deploy from stale repository contents unless the final generated +`Website/public/downloads/Parcel.zip` and `Website/public/appcast.xml` have also been committed. + +## GitHub Release Secrets + +Tagged `v*` releases use `.github/workflows/release.yml`. Configure these secrets before relying +on tag-triggered public releases: + +| Secret | Notes | +|--------|-------| +| `DEVELOPMENT_TEAM` | Apple Developer Team ID. | +| `DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64` | Base64-encoded Developer ID Application `.p12` certificate for CI signing. | +| `DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD` | Password for the Developer ID Application `.p12`. | +| `KEYCHAIN_PASSWORD` | Optional CI keychain password; the workflow generates an ephemeral one if omitted. | +| `APPLE_ID` + `APPLE_APP_PASSWORD` | Notary credentials for CI. | +| `SPARKLE_ED_PRIVATE_KEY` | Sparkle EdDSA private key for non-interactive appcast signing. | +| `SPARKLE_KEYCHAIN_ACCOUNT` | Optional; defaults to `parcel.parable.dev`. | +| `PARCEL_SCREEN_RECORDING_VERIFIED` | Set to `1` only after exact release/test app path QA passes. | +| `PARCEL_SECOND_DISPLAY_VERIFIED` | Set to `1` only after second-display QA passes. | +| `PARCEL_SUPABASE_URL`, `PARCEL_SUPABASE_ANON_KEY`, `PARCEL_SUPABASE_BUCKET` | Live Supabase upload QA gate. | +| `PARCEL_MACOS13_VM_VERIFIED` | Set to `1` only after macOS 13 fallback QA passes. | + +## Current Gates + +The 2026-08-12 local verification evidence is in `qa-evidence/final-ship-2026-08-12/`. +Public release remains gated until a release machine provides Developer ID signing, +notarization credentials, Screen Recording verification for the exact app path, second-display +QA proof, live Supabase credentials, and macOS 13 fallback QA. Sparkle signing now passes on this +machine against a temp appcast; the real appcast should only be signed after the final Developer ID +signed and notarized ZIP is produced. +Computer Use on this machine can inspect a normal System Settings window, but exact Parcel app-path +and SystemUIServer/menu-bar probes time out, so final Parcel UI proof remains a release-machine gate. diff --git a/docs/integrations.md b/docs/integrations.md index aced111..0e0a280 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,6 +1,6 @@ -# Connected services — Parcel / notable repo +# Connected services — Parcel -Repo: [github.com/bswxyz/notable](https://github.com/bswxyz/notable) +Repo: this Parcel repository. | Service | Purpose | Where configured | |---------|---------|------------------| @@ -15,14 +15,32 @@ Repo: [github.com/bswxyz/notable](https://github.com/bswxyz/notable) ## GitHub - **Default branch:** `master` -- **Release:** tag `v*` → `release.yml` builds signed `Parcel.zip` + website artifact -- **CI:** push/PR → `build.yml` compiles Parcel Debug +- **Release:** tag `v*` → `release.yml` runs release-gate preflight, builds signed/notarized + `Parcel.zip`, updates Sparkle appcast, verifies the website ZIP, builds `Website/out`, and + uploads the deployable `website-dist` artifact before creating the GitHub Release +- **CI:** push/PR → `build.yml` compiles Parcel Debug and verifies website export assets + +Required release secrets/vars: + +| Name | Purpose | +|------|---------| +| `DEVELOPMENT_TEAM` | Apple Developer Team ID | +| `DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64` | Base64-encoded Developer ID Application `.p12` for CI signing | +| `DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD` | Password for the Developer ID Application `.p12` | +| `KEYCHAIN_PASSWORD` | Optional temporary CI keychain password; generated by the workflow if omitted | +| `APPLE_ID` + `APPLE_APP_PASSWORD` | Notary credentials, unless a keychain profile is used locally | +| `SPARKLE_ED_PRIVATE_KEY` | Non-interactive Sparkle appcast signing key | +| `SPARKLE_KEYCHAIN_ACCOUNT` | Optional; defaults to `parcel.parable.dev` | +| `PARCEL_SCREEN_RECORDING_VERIFIED` | Set to `1` only after exact release/test app path QA passes | +| `PARCEL_SECOND_DISPLAY_VERIFIED` | Set to `1` only after second-display QA passes | +| `PARCEL_SUPABASE_URL`, `PARCEL_SUPABASE_ANON_KEY`, `PARCEL_SUPABASE_BUCKET` | Live upload QA gate | +| `PARCEL_MACOS13_VM_VERIFIED` | Set to `1` only after macOS 13 fallback QA passes | Set repository **About → Website** to `https://parcel.parable.dev`. ## Vercel -1. Import `bswxyz/notable` in Vercel. +1. Import the Parcel repository in Vercel. 2. Set **Root Directory** to `Website`. 3. Framework preset: **Next.js** (auto-detected; static export to `out/`). 4. Custom domain: `parcel.parable.dev`. diff --git a/docs/parity.md b/docs/parity.md index a214c97..5ae8d88 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,52 +1,103 @@ -# macshot vs Parcel — Feature Parity - -Working checklist for beating [macshot](https://macshot.io/) while keeping Parcel's local-first privacy story. - -| Capability | macshot | Parcel | Status | Owner | -|---|---|---|---|---| -| Region / window capture | Yes | Yes | Done | — | -| Global hotkey | Configurable | Configurable (Preferences) | Done | App | -| Ellipse annotation | Yes | Yes | Done | App | -| Arrow ×5 styles | Yes | Yes | Done | — | -| Censor blur / pixelate / solid | Yes | Yes | Done | — | -| Smart erase censor | Yes | Yes | Done | App | -| Click-to-edit annotations | Yes | Yes | Done | — | -| Layer z-order controls | Yes | Yes | Done | App | -| Annotation rotation | Yes | Yes | Done | App | -| Undo / redo | Yes | Yes (annotations) | Done | — | -| Scroll capture | Overlay + preview | Overlay + menu bar | Done | App | -| MP4 / GIF recording + trim | Yes | Yes | Done | — | -| Recording fps presets | Up to 120fps | 30 / 60 / 120 | Done | App | -| Beautify (30 gradients) | Yes | Yes | Done | — | -| Brand kits | Partial | Yes | **Better** | — | -| OCR | Yes (Vision) | Yes (Vision) | Done | — | -| OCR translate | Cloud / Google | On-device (macOS 15+) | Done | App | -| PII / face censor | Yes | Yes (local regex + Vision) | Done | — | -| Cloud upload | Drive / imgbb / S3 | Supabase Storage | Done | App | -| Re-editable history | Yes | Yes (disk-backed) | **Better** | — | -| HEIC / TIFF export | Partial | Yes | **Better** | — | -| Configurable hotkeys UI | Yes | Yes | Done | App | -| Homebrew install | Yes | Cask in repo | Done | Repo | -| Marketing site | macshot.io | Astro + Parable | Done | Web | -| 40-language app UI | Yes | English (Phase 2) | Deferred | — | -| Network LLM / AI search | Google AI | **Never** (hard constraint) | N/A | — | -| iOS companion | No | No | Deferred | — | - -## Parcel differentiators (market harder than macshot) - -1. **No network AI** — Vision, OCR, translation, and redaction stay on-device. -2. **Re-editable local History** — full annotation + adjustment + beautify state restored. -3. **Beautify brand kits** — named presets saved locally. -4. **Honest UX** — no fake upload buttons; Supabase only when configured. -5. **Native SwiftUI** — no Electron, no web views in the app shell. - -## Verification - -- [ ] Capture region, window snap, all-display stitch -- [ ] Every Tool creates and exports correctly (including Ellipse, erase Censor) -- [ ] Layer order + rotation on selected Annotation -- [ ] Hotkey change in Preferences persists across relaunch -- [ ] Scroll capture from Overlay + menu bar finish flow -- [ ] Supabase upload copies URL to clipboard -- [ ] On-device translation (macOS 15+) in Vision panel -- [ ] Website deploys to static host with download link +# Competitive parity — Parcel vs CleanShot / macshot + +Working checklist for local-first Capture parity. Parcel keeps privacy differentiators and never ships network AI or a CleanShot Cloud clone. + +## Decision + +- **Local-first:** Capture / Editor / Recording / History / Overlay stay on-device +- **Upload:** user-configured Supabase only (no proprietary cloud, push, SSO, or view/comment social) +- **Glossary:** Capture, Selection, Overlay, Editor, Annotation, Tool, Canvas, Layer +- **Never:** network LLM, Raycast AI Chat, CleanShot trademarks/assets/copy + +## Feature matrix (v1.1–v1.6 shipped in app) + +| Capability | Status | +|---|---| +| Region / window / display / all-displays Capture | Done | +| Freeze-then-select Overlay + All-in-One bar | Done | +| Previous-area Capture | Done | +| Ask for name before Save | Done | +| After-Capture action matrix (copy / editor / pin / upload / save) | Done | +| Capture Area & … hotkeys + Previous ⌘⇧5 | Done | +| ⇧ Shift bypass aspect preset while dragging | Done | +| Scroll Capture (vertical + horizontal stitch) | Done | +| OCR Capture + Editor Vision (text / faces / QR / PII) | Done | +| Quick Access + keyboard shortcuts + swipe discard | Done | +| Pin + lock + opacity scroll + middle-click close + hide/close all | Done | +| Restore recently closed / open from clipboard | Done | +| Editor Annotations (arrow×5, shapes, text, pencil, censor, etc.) | Done | +| Capture transforms (crop / resize / rotate / flip / expand / combine) | Done | +| Color swatches + smart highlighter snap (on-device) | Done | +| Beautify + Adjustments + brand kits | Done | +| Recording pause/resume, countdown, max resolution, mono, DND | Done | +| Keystroke HUD + webcam PiP + GIF export | Done | +| History filter + retention + Pin from History | Done | +| `parcel://` URL scheme (disable in Preferences) | Done | +| Filename templates `{date}` `{time}` `{month}` `{index}` `{app}` `{window}` | Done | +| Export PNG / JPEG / HEIC / TIFF / WebP + sRGB option | Done | +| Print + Share + `.parcel` project files | Done | +| Shutter sound preference | Done | +| Recording previous area | Done | +| Keystroke HUD position preference | Done | +| Remove window Capture backdrop (matte) | Done | +| OCR strip line breaks preference | Done | + +## Explicitly excluded + +- CleanShot Cloud Pro (custom domain, branding, team SSO, push on view/comment, self-destruct product) +- Network AI / Raycast AI Chat +- App Store submission (tracked separately) +- 40-language UI (deferred) +- AVIF codec (deferred) + +## Parcel differentiators + +1. **No network AI** — Vision, OCR, translation, and redaction stay on-device +2. **Re-editable local History** — full annotation + adjustment + beautify state +3. **Beautify brand kits** — named presets saved locally +4. **Honest upload UX** — Supabase only when configured +5. **Native SwiftUI** — no Electron shell + +## Verification smoke list + +Claim-level release evidence is tracked in +`qa-evidence/final-ship-2026-08-12/CLAIMS_AUDIT.md`. The matrix above records implementation +status; the claim audit separates source/model evidence from gated UI, hardware, service, and +release-machine proof. + +2026-08-12 local evidence: `ParcelUnit` covers URL-scheme action parsing, transform remapping, +selected PNG/JPEG/HEIC/TIFF/WebP export encoder containers, sRGB export conversion, +Retina scale-down Capture resizing, Selection aspect preset cycling/ratio geometry/Shift bypass, +expand/combine transform pixel and Annotation placement, Censor erase outside-ring Retina sampling, +WebP format metadata/bytes, +production recording writer finalization/pause behavior, recording max-resolution geometry, +countdown display/DND policy behavior, +keystroke HUD label/PiP placement behavior, +recording temporary-file install, +recording trim MP4/GIF export, Scroll Capture vertical/horizontal stitching and no-overlap +rejection, all-display stitch desktop arrangement, window Capture matte removal, +brand kit save/reload/remove persistence, +`.parcel` round-trip, History create/restore/save/delete/filter and retention prune, filename +template tokens, Capture preference defaults/toggles, extra Capture Area hotkey bindings, +shutter sound feedback policy, recently-closed restore stack behavior, clipboard image import, +window snap hit testing, Pin opacity/close gesture mapping, Pin lock/visibility state, +print payload sizing/pagination, share payload item/anchor behavior, +Quick Access shortcut/swipe mapping, Tool inventory, Arrow/Censor style inventories, Upload disabled/not-configured behavior, +mocked Supabase request/success/error handling, local PII classification, and hotkey/recording +preference models, plus after-Capture action planning, OCR strip-line-break formatting, +previous Capture/recording area preference state, color swatch persistence/dedupe/capping, +smart highlighter snap-to-text-box behavior, local Vision QR detection, and Capture-point coordinate +mapping. +`Scripts/verify-no-network-ai.sh` covers the no-network-AI source invariant. Screen Recording, +second-display, live Supabase upload, and macOS 13 fallback checks remain gated in +`docs/QA_CHECKLIST.md`. + +- [ ] Previous area after one region Capture +- [ ] After-Capture toggles in Preferences +- [ ] ⌘⇧5 previous / ⌥⌘⇧C copy intent +- [ ] Hold ⇧ while dragging Selection (free aspect) +- [ ] Recording pause / resume / countdown +- [ ] Quick Access ⌘C ⌘S ⌘E ⌘W +- [ ] `parcel://capture/region` and `parcel://capture/previous` +- [ ] Editor transform menu + WebP save + `.parcel` round-trip +- [ ] History filter + retention prune diff --git a/project.yml b/project.yml index bd86993..36d6517 100644 --- a/project.yml +++ b/project.yml @@ -11,6 +11,9 @@ packages: Sparkle: url: https://github.com/sparkle-project/Sparkle from: "2.6.4" + LibWebP: + url: https://github.com/the-swift-collective/libwebp.git + from: "1.4.0" settings: base: @@ -18,6 +21,7 @@ settings: CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "5.0" CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "-" DEVELOPMENT_TEAM: "" ENABLE_HARDENED_RUNTIME: YES DEAD_CODE_STRIPPING: YES @@ -34,9 +38,12 @@ targets: excludes: - "Resources/Info.plist" - "Resources/Parcel.entitlements" + - "Resources/Parcel.Debug.entitlements" dependencies: - package: Sparkle product: Sparkle + - package: LibWebP + product: WebP settings: base: PRODUCT_NAME: Parcel @@ -51,6 +58,53 @@ targets: - "@executable_path/../Frameworks" OTHER_LDFLAGS: - "-framework Sparkle" + configs: + Debug: + CODE_SIGN_ENTITLEMENTS: Sources/Parcel/Resources/Parcel.Debug.entitlements + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: NO + Release: + CODE_SIGN_ENTITLEMENTS: Sources/Parcel/Resources/Parcel.entitlements + ENABLE_HARDENED_RUNTIME: YES + # Prevent Xcode from injecting get-task-allow into Release (breaks distribution). + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO + + ParcelUITests: + type: bundle.ui-testing + platform: macOS + deploymentTarget: "13.0" + sources: + - path: Tests/ParcelUITests + dependencies: + - target: Parcel + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.parable.ParcelUITests + TEST_TARGET_NAME: Parcel + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + DEVELOPMENT_TEAM: QFH99B6X5V + ENABLE_HARDENED_RUNTIME: NO + MACOSX_DEPLOYMENT_TARGET: "14.0" + + ParcelTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "13.0" + sources: + - path: Tests/ParcelTests + dependencies: + - target: Parcel + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.parable.ParcelTests + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: NO schemes: Parcel: @@ -59,5 +113,19 @@ schemes: Parcel: all run: config: Debug + test: + targets: + - ParcelTests + - ParcelUITests archive: config: Release + ParcelUnit: + build: + targets: + Parcel: all + ParcelTests: test + run: + config: Debug + test: + targets: + - ParcelTests