diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f26fe67..d2e2d72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,7 +154,6 @@ jobs: - name: Build Tauri app uses: tauri-apps/tauri-action@v0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VITE_PRIVY_APP_ID: ${{ secrets.VITE_PRIVY_APP_ID }} VITE_PRIVY_CLIENT_ID: ${{ secrets.VITE_PRIVY_CLIENT_ID }} VITE_SESSION_RELAY_URL: ${{ secrets.VITE_SESSION_RELAY_URL }} @@ -166,11 +165,6 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_ASC_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }} with: - tagName: ${{ github.ref_name }} - releaseName: 'DataConnect v__VERSION__' - releaseBody: 'See the assets to download this version and install.' - releaseDraft: false - prerelease: false args: --target ${{ matrix.target }} - name: Free disk space before finalization @@ -190,14 +184,19 @@ jobs: df -h / || true shell: bash - - name: Copy native modules and finalize bundles + - name: Finalize bundles env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PATH: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PATH }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - set -x # Enable verbose debugging + set -euo pipefail - # Copy node_modules into macOS .app bundles (preserving directory structure) + # Re-sign the completed macOS app and recreate the DMG from the final app. + # personal-server/dist/node_modules is already bundled by Tauri resources. if [ "${{ matrix.platform }}" = "macos-latest" ]; then + echo "=== Finalizing macOS bundles for ${{ matrix.target }} ===" # Debug: Show what's in personal-server/dist echo "=== Contents of personal-server/dist ===" ls -la personal-server/dist/ || echo "dist not found" @@ -214,34 +213,19 @@ jobs: "$node_binary" done - for app in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app; do - [ -e "$app" ] || { echo "No .app found at $app"; continue; } - echo "=== Processing $app ===" - - # Show current state - echo "Before copy - Resources contents:" - ls -la "$app/Contents/Resources/personal-server/dist/" || echo "personal-server/dist not in Resources" - - dest="$app/Contents/Resources/personal-server/dist/node_modules" - mkdir -p "$dest" - - # Copy with verbose output - if [ -d "personal-server/dist/node_modules" ]; then - cp -Rv personal-server/dist/node_modules/* "$dest/" - echo "=== After copy - node_modules contents ===" - ls -la "$dest/" || echo "copy failed" - else - echo "ERROR: personal-server/dist/node_modules does not exist!" - exit 1 - fi - - echo "Copied node_modules to $dest" - done - # Re-sign nested binaries with their entitlements, then re-sign the .app for app in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app; do [ -e "$app" ] || continue + echo "=== Processing $app ===" + app_name=$(basename "$app" .app) + version=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed 's/.*: "\(.*\)".*/\1/') + arch=$(echo "${{ matrix.target }}" | cut -d- -f1) + updater_name="${app_name}_${version}_${arch}.app.tar.gz" + echo "Bundled resource contents:" + ls -la "$app/Contents/Resources/personal-server/dist/" || echo "personal-server/dist not in Resources" + ls -la "$app/Contents/Resources/personal-server/dist/node_modules/" || { echo "ERROR: node_modules NOT in app bundle!"; exit 1; } + # Sign personal-server binary with JIT entitlements (--deep would strip them) ps_bin="$app/Contents/Resources/personal-server/dist/${{ matrix.ps_binary_name }}" if [ -f "$ps_bin" ]; then @@ -264,6 +248,35 @@ jobs: --sign "Developer ID Application: Corsali, Inc (${{ secrets.APPLE_TEAM_ID }})" \ "$app" echo "Re-signed $app" + + # Notarize/staple the finalized .app before packaging the updater tarball. + APPLE_NOTARY_KEY_PATH="$APPLE_API_KEY_PATH" \ + APPLE_NOTARY_KEY_ID="${{ secrets.APPLE_ASC_API_KEY_ID }}" \ + APPLE_NOTARY_ISSUER="${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }}" \ + node scripts/notarize-macos-app.mjs \ + --app "$app" \ + --output-dir "src-tauri/target/${{ matrix.target }}/release/bundle/macos" + + # Create updater artifacts from the finalized notarized .app, not the pre-finalization Tauri output. + if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] || [ -n "$TAURI_SIGNING_PRIVATE_KEY_PATH" ]; then + node scripts/build-macos-updater-artifacts.mjs \ + --app "$app" \ + --output-dir "src-tauri/target/${{ matrix.target }}/release/bundle/macos" \ + --artifact-name "$updater_name" + + # Smoke-check the updater payload after tar packaging. This must stay a hard gate. + updater_smoke_dir="/tmp/updater_smoke_${arch}" + rm -rf "$updater_smoke_dir" + mkdir -p "$updater_smoke_dir" + tar -xzf "src-tauri/target/${{ matrix.target }}/release/bundle/macos/$updater_name" -C "$updater_smoke_dir" + extracted_app="$updater_smoke_dir/$(basename "$app")" + xcrun stapler validate "$extracted_app" + spctl --assess -vv "$extracted_app" + codesign --verify --strict "$extracted_app" + rm -rf "$updater_smoke_dir" + else + echo "::notice::Skipping finalized macOS updater artifact generation because no Tauri updater signing key is configured" + fi done # Recreate DMG with updated .app (including Applications symlink) @@ -314,7 +327,7 @@ jobs: # Notarize the DMG using App Store Connect API key echo "=== Notarizing $dmg_path ===" if xcrun notarytool submit "$dmg_path" \ - --key "${{ env.APPLE_API_KEY_PATH }}" \ + --key "$APPLE_API_KEY_PATH" \ --key-id "${{ secrets.APPLE_ASC_API_KEY_ID }}" \ --issuer "${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }}" \ --wait 2>&1 | tee /tmp/notarize_${arch}.log; then @@ -328,7 +341,7 @@ jobs: if [ -n "$submission_id" ]; then echo "=== Fetching notarization log for $submission_id ===" xcrun notarytool log "$submission_id" \ - --key "${{ env.APPLE_API_KEY_PATH }}" \ + --key "$APPLE_API_KEY_PATH" \ --key-id "${{ secrets.APPLE_ASC_API_KEY_ID }}" \ --issuer "${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }}" || true fi @@ -378,6 +391,13 @@ jobs: echo "Uploaded $(basename "$f")" fi done + for f in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz \ + src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig; do + if [ -f "$f" ]; then + gh release upload "${{ github.ref_name }}" "$f" --clobber + echo "Uploaded $(basename "$f")" + fi + done fi # Upload Linux artifacts diff --git a/docs/260311-tauri-auto-update-phase2-feasibility.md b/docs/260311-tauri-auto-update-phase2-feasibility.md new file mode 100644 index 0000000..01f20db --- /dev/null +++ b/docs/260311-tauri-auto-update-phase2-feasibility.md @@ -0,0 +1,421 @@ +# 260311: Tauri auto-update phase 2 feasibility + +## Goal + +Ship true in-app app updates for the desktop build with this UX: + +- app checks for updates in the background +- app downloads the update silently after startup settles +- user sees nothing until the update is fully staged +- user gets a single `Restart to update` style toast +- clicking the toast installs/applies the staged update and relaunches the app + +This is an updater/distribution pipeline project, not mainly a UI project. + +## Decisions so far + +- Platform rollout: macOS first is acceptable. +- Feed/distribution: use GitHub Releases if it works; do not build a custom update service unless forced. +- Download timing: start after idle/startup settles, not immediately at launch. +- UX target: silent background download, then one-click restart/apply. +- Packaging assumption: treat the macOS post-build `node_modules` copy step as dead unless runtime or notarization evidence disproves it. +- Spike order: move next to updater plumbing; keep the nested in-app re-sign loop question as a narrower CI notarization follow-up. + +## Spike outcome summary (2026-03-11) + +What we did: + +1. Proved raw Tauri macOS bundling already includes `personal-server/dist/node_modules`. +2. Removed the redundant macOS copy step from local and CI build paths. +3. Proved locally that pre-signed nested binaries keep their signatures/entitlements when Tauri bundles them. + +What that means: + +- The old “can Tauri package `node_modules` at all?” question is answered enough to unblock the next spike. +- The remaining packaging uncertainty is now much smaller: whether Apple notarization in CI still succeeds if we later remove the nested in-app re-sign loop. +- Updater plugin and updater artifact plumbing can now be planned against the simplified assumption that the copy step is gone. + +## Current repo state + +Missing today: + +- no `tauri-plugin-updater` dependency in Rust +- no `@tauri-apps/plugin-updater` dependency in JS +- no updater plugin registration in `src-tauri/src/lib.rs` +- no updater config in `src-tauri/tauri.conf.json` +- no `bundle.createUpdaterArtifacts` +- no updater signing key setup in release automation +- no published updater metadata asset (`latest.json` or equivalent) + +Relevant files: + +- `package.json` +- `src-tauri/Cargo.toml` +- `src-tauri/src/lib.rs` +- `src-tauri/capabilities/default.json` +- `src-tauri/tauri.conf.json` +- `.github/workflows/release.yml` +- `scripts/build-prod.js` +- `src/hooks/app-update/check-app-update.ts` +- `src/hooks/use-app-update.tsx` +- `src/components/ui/sonner.tsx` + +## Answer: can we generate updater artifacts/signatures from the final post-processed bundles? + +Not with the current release flow as written. + +Why: + +1. Tauri updater artifacts/signatures are generated during the Tauri build step. +2. Our workflow mutates the app bundle after that step: + - copies `personal-server/dist/node_modules` into the bundle + - re-signs nested binaries + - re-signs the outer macOS app + - recreates the DMG +3. Updater signatures must match the exact bytes of the artifact being served. + +Implication: + +- Any updater bundle/signature generated before those post-build mutations cannot be trusted as the final updater artifact. +- For macOS specifically, the updater uses a `.app.tar.gz` updater bundle, not the DMG, so the critical question is whether that `.app.tar.gz` was produced before or after the bundle was finalized. In the current flow, it would be produced too early. + +What is still possible: + +- We can likely make it work if we fully own final updater artifact generation/signing after post-processing. +- That means either: + - stop mutating bundles after Tauri build, or + - replace the current `tauri-action` “build then mutate” flow with a custom pipeline that creates the final updater bundle and signature from the finalized app. + +Conclusion: + +- **Current workflow:** no +- **In principle with custom final-artifact signing:** yes + +## Answer: can we stop post-processing bundles by changing how `personal-server` resources are packaged? + +For macOS resource copying, yes. + +Why I think that: + +- Tauri 2 resource docs explicitly support recursive directory bundling with preserved structure. +- `bundle.resources` supports `"dir/"` for recursive copy and object mapping for explicit target paths. +- The current repo comments still talk about old `dist/*` behavior (“only copies files”), but the actual config already uses object mapping, which suggests the current workaround may be partly stale or based on an older failure mode. + +What we needed to prove: + +- whether Tauri can already package `personal-server/dist/node_modules` into the raw `.app` +- whether the repo's current custom copy step is actually doing anything essential on macOS + +Current remaining issue: + +- `personal-server` is a `pkg` binary that still needs real filesystem `node_modules` beside it for native addons like `better-sqlite3`. +- the repo still needs a final signing/notarization strategy for the completed macOS app bundle + +Best-case outcome: + +- keep bundling `personal-server/dist` through normal Tauri resources +- remove the macOS resource-copy step from the custom post-processing path +- keep a final-sign step on the completed app bundle before creating updater artifacts / DMG + +If that works, updater support gets much simpler because the Tauri-built bundle becomes the final shipped bundle. + +Conclusion: + +- **Yes for macOS resource copying** +- **Still need final app signing after bundle completion** + +## GitHub Releases as feed + +GitHub Releases is acceptable. + +Important nuance: + +- “GitHub Releases as feed” does **not** mean “only upload binaries”. +- Tauri updater still needs metadata describing the latest version and per-platform signed artifact URLs/signatures. + +Practical shape: + +- keep release assets on GitHub Releases +- publish updater metadata as a release asset too (`latest.json` or equivalent) +- point Tauri updater endpoints at that static metadata URL + +This keeps the system backend-free while still using the real updater protocol. + +## MacOS-first rollout + +macOS first is a good idea. + +Why: + +- current release workflow is already most customized on macOS +- the desired UX matters most there right now +- it reduces surface area while we prove the packaging/signing/updater model + +Recommended rollout: + +1. prove packaging spike on macOS +2. ship updater on macOS only +3. extend to Windows/Linux once packaging and signing are stable + +## Download timing + +`after idle/startup settles` is safer than immediate launch download. + +Recommended behavior: + +- startup: check availability without blocking startup-critical work +- after app settles / idle delay: start silent download if update exists +- once fully staged: show `Restart to update` + +This avoids competing with startup, connector initialization, and personal-server startup. + +## What blocks shipping today + +The main ship/no-ship questions are: + +1. Can the updater plumbing be added cleanly now that the macOS copy step is removed? +2. Can GitHub Actions publish updater artifacts plus updater metadata cleanly for a macOS-first rollout? +3. Does Apple notarization in CI still pass if we later remove the nested in-app re-sign loop? + +Question 3 is now the only unresolved macOS packaging-specific follow-up. +It should not block the updater plumbing spike. + +## Recommended next steps + +### Track A: packaging spike + +Goal: remove post-processing. + +#### Result (2026-03-11) + +I ran a raw local macOS bundle build with no custom post-copy step: + +- command: `CI=true npm run tauri -- build --bundles app` +- output: `src-tauri/target/release/bundle/macos/DataConnect.app` + +Observed: + +- raw Tauri packaging already included: + - `Contents/Resources/personal-server/dist/personal-server` + - `Contents/Resources/personal-server/dist/node_modules/better-sqlite3` + - `Contents/Resources/personal-server/dist/node_modules/bindings` + - `Contents/Resources/personal-server/dist/node_modules/file-uri-to-path` +- the same resource tree also existed earlier in Tauri's staging directory at `src-tauri/target/release/personal-server/dist` +- the bundled `personal-server` binary verified successfully with `codesign --verify --strict` +- the bundled `better_sqlite3.node` addon also verified successfully with `codesign --verify --strict` + +Important failure: + +- the outer raw `.app` failed `codesign --verify --strict` +- `codesign -dv --verbose=4` showed `Sealed Resources=none` +- ad-hoc re-signing the completed `.app` fixed verification immediately and produced `Sealed Resources version=2` + +Interpretation: + +- the current macOS resource-copy workaround is not needed to get `node_modules` into the final `.app` +- the remaining macOS finalization need is signing the completed app bundle after all resources are in place + +Current status: + +- status: partially proven +- raw macOS app already contains `personal-server/dist/node_modules` +- next proof still needed: launch/runtime validation from the packaged app +- if runtime passes, delete the macOS copy step and keep only final-sign/final-artifact steps + +#### Follow-up cleanup (2026-03-11) + +Implemented the first cleanup pass: + +- removed the redundant macOS `node_modules` copy step from `scripts/build-prod.js` +- removed the redundant macOS `node_modules` copy step from `.github/workflows/release.yml` +- kept final app signing in place + +Validation: + +- `node scripts/build-prod.js` completed successfully +- `codesign --verify --strict` passed on the final `.app` +- bundled `personal-server/dist/node_modules` was still present in the final `.app` +- the packaged app binary stayed up during a short local smoke run + +Open question that remains after this cleanup: + +- whether CI still needs the nested in-app re-sign loop for `personal-server` and `.node` files, or whether pre-build signing plus final outer-app signing is sufficient + +#### Nested-signature preservation check (2026-03-11) + +Ran a follow-up preservation test: + +- ad-hoc signed `personal-server/dist/personal-server` with `personal-server/entitlements.plist` +- ad-hoc signed `personal-server/dist/node_modules/better-sqlite3/build/Release/better_sqlite3.node` +- built a raw macOS app with `CI=true npm run tauri -- build --bundles app` +- inspected the bundled copies inside `DataConnect.app` + +Observed: + +- the bundled `personal-server` copy preserved the same signature metadata and CDHash as the pre-signed source binary +- the bundled `personal-server` copy preserved the JIT entitlements from `personal-server/entitlements.plist` +- the bundled `better_sqlite3.node` copy verified successfully with `codesign --verify --strict` + +Interpretation: + +- Tauri resource bundling preserves the nested binary signatures/entitlements we apply before build +- the current CI in-app nested re-sign loop is likely redundant + +Remaining uncertainty: + +- local ad-hoc signature preservation is proven +- Apple notarization with Developer ID signatures is still not yet proven in CI + +Working recommendation: + +- keep pre-build signing of `personal-server` and `.node` files +- keep final outer-app signing +- defer removing the CI in-app nested re-sign loop until one notarization-backed CI run proves it is unnecessary + +### Track B: updater pipeline spike + +Goal: prove updater mechanics on macOS once Track A works. + +Implementation plan: + +- `docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md` + +#### Scope decision + +Proceed on the simplified assumption that: + +- the macOS copy step is gone +- pre-build signing of nested binaries stays +- final outer-app signing stays +- the nested in-app re-sign loop remains temporarily in CI until notarization evidence proves it can be removed + +#### Exact files to change + +- `package.json` + - add `@tauri-apps/plugin-updater` + - likely add `@tauri-apps/plugin-process` if the relaunch step is owned in JS +- `src-tauri/Cargo.toml` + - add `tauri-plugin-updater` +- `src-tauri/src/lib.rs` + - register the updater plugin on the Tauri builder +- `src-tauri/capabilities/default.json` + - add updater capability permissions (`updater:default`) +- `src-tauri/tauri.conf.json` + - enable `bundle.createUpdaterArtifacts` + - add `plugins.updater.pubkey` + - add `plugins.updater.endpoints` +- `.github/workflows/release.yml` + - inject updater signing private key env vars during build + - upload updater bundle assets and generated signatures + - publish/update static updater metadata asset on the GitHub Release +- `scripts/build-prod.js` + - optionally mirror the updater-artifact path for local macOS smoke builds if we want local end-to-end update testing outside CI +- `src/hooks/app-update/check-app-update.ts` + - either replace the GitHub Releases polling path on macOS or split “release page check” from “Tauri updater check” +- `src/hooks/use-app-update.tsx` + - evolve from `check -> external release URL` into `check -> idle download -> restart toast` +- `src/components/ui/sonner.tsx` + - reuse existing toast surface for the staged `Restart to update` UX + +#### Updater keys and config + +- generate a dedicated updater signing keypair with `npm run tauri signer generate` +- store the private key and optional password in CI secrets +- put the public key content directly in `src-tauri/tauri.conf.json` under `plugins.updater.pubkey` +- do not rely on `.env` files for the private key during build; Tauri reads it from environment variables at build time + +#### GitHub Releases / metadata shape + +For Tauri v2 static metadata, the endpoint can point directly at a JSON asset on GitHub Releases, for example: + +- `https://github.com/vana-com/data-connect/releases/latest/download/latest.json` + +That JSON should contain: + +- top-level `version` +- optional `notes` +- optional `pub_date` +- `platforms` map keyed by platform-arch, for example: + - `darwin-aarch64` + - `darwin-x86_64` +- each platform entry needs: + - `url` pointing to the updater bundle asset + - `signature` containing the literal `.sig` file contents, not a URL + +Important constraint: + +- Tauri validates the JSON before version comparison, so every platform key present in the file must be complete and correct. +- For a macOS-first rollout, the safest static metadata is a macOS-only updater JSON until Windows/Linux updater artifacts are also supported. + +Expected macOS release assets: + +- normal installer: + - `.dmg` +- updater assets: + - `.app.tar.gz` + - `.app.tar.gz.sig` +- static updater metadata: + - `latest.json` + +#### Runtime state machine + +Target runtime behavior for macOS phase 2: + +1. Startup: + - call updater `check()` + - do not block startup-critical work +2. No update: + - stay idle +3. Update available: + - record the available update + - wait for startup-settled / idle delay +4. Idle download: + - call updater download/install path in background + - keep UI silent while downloading +5. Download staged: + - show one persistent toast: `Restart to update` +6. User clicks restart: + - install/apply if needed + - relaunch app +7. Failure at any step: + - fail soft + - log for diagnostics + - do not interrupt normal app usage + +Recommended implementation note: + +- keep the existing `useAppUpdate` provider as the single app-shell orchestration point +- split decision states so phase 1 (`external update available`) and phase 2 (`update downloading`, `update ready to restart`) are not conflated + +#### Exit criteria + +- macOS build produces updater artifacts and signatures from the finalized signed app pipeline +- release workflow uploads `.app.tar.gz`, `.sig`, and `latest.json` +- an older macOS build updates in-app to a newer macOS build through the full staged-download flow +- non-macOS platforms remain on the phase-1 external release flow until explicitly migrated + +### Track C: fallback if Track A fails + +Goal: keep current packaging but still support updater. + +- replace current `tauri-action` usage with a custom final-artifact pipeline +- mutate bundle first +- re-sign final app +- create updater bundle from the finalized app +- sign the updater bundle +- publish matching metadata + +This is more work and should only be used if Track A fails. + +## Current recommendation + +Do not start by wiring updater APIs into the app UI. + +Start with the updater plumbing spike, not another broad packaging spike. + +Current alignment: + +- yes, the macOS resource-copy subproblem is solved enough to proceed +- updater plugin + updater artifact plumbing is the next concrete spike +- the only remaining packaging follow-up is whether CI notarization later lets us delete the nested in-app re-sign loop too +- keep that notarization question scoped as a follow-up validation, not as the blocker for updater planning diff --git a/docs/plans/260311-macos-updater-ci-proof-run.md b/docs/plans/260311-macos-updater-ci-proof-run.md new file mode 100644 index 0000000..06201b9 --- /dev/null +++ b/docs/plans/260311-macos-updater-ci-proof-run.md @@ -0,0 +1,150 @@ +# 260311 plan: macOS updater CI proof run + +Goal: + +- run one real release workflow proof for the macOS updater artifact path +- verify asset publishing, notarization ordering, and post-tar validation on GitHub runners + +Important constraint: + +- with current repo tooling, a real proof run is a real version/tag/release +- `scripts/release-github.mjs` enforces: + - clean worktree + - current branch must equal `--target` + - new version must be greater than latest remote tag + - real `gh release create` +- so this proof consumes a real version number unless we later add a separate test-release workflow + +## Preconditions + +- branch pushed and clean +- Apple notarization secrets configured in GitHub Actions +- updater signing key secrets configured in GitHub Actions +- all release-path commits for this spike merged into the branch you are proving + +### Exact GitHub Actions secrets required + +Already required for the current release flow: + +- `APPLE_BUILD_CERTIFICATE_BASE64` +- `APPLE_BUILD_CERTIFICATE_PASSWORD` +- `APPLE_ASC_API_KEY_KEY_BASE64` +- `APPLE_ASC_API_KEY_ID` +- `APPLE_ASC_API_KEY_ISSUER_UUID` +- `APPLE_TEAM_ID` +- `VITE_PRIVY_APP_ID` +- `VITE_PRIVY_CLIENT_ID` +- `VITE_SESSION_RELAY_URL` +- `VITE_GATEWAY_URL` + +Required specifically for the updater proof path: + +- `TAURI_SIGNING_PRIVATE_KEY` + - value: the full contents of your Tauri updater private key file + - preferred over path-based config in GitHub Actions +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` + - value: the password for that private key + - if the key has no password, set this to an empty string or omit only if you have confirmed the CLI accepts that in CI + +Do not rely on `TAURI_SIGNING_PRIVATE_KEY_PATH` in GitHub Actions for this flow. +The runner does not automatically have your local key file path. + +## Exact proof command + +If proving on the current feature branch: + +```bash +git checkout callum1/bui-249-auto-update-the-app-2 +git pull --ff-only origin callum1/bui-249-auto-update-the-app-2 +npm run release:github -- --version --target callum1/bui-249-auto-update-the-app-2 +``` + +Choose `` as a real unused semver greater than the latest remote tag. + +## Expected release assets + +Minimum macOS proof assets that must exist on the GitHub Release: + +- `DataConnect__aarch64.dmg` +- `DataConnect__x86_64.dmg` +- `DataConnect__aarch64.app.tar.gz` +- `DataConnect__aarch64.app.tar.gz.sig` +- `DataConnect__x64.app.tar.gz` +- `DataConnect__x64.app.tar.gz.sig` + +Baseline non-macOS artifacts may also be present: + +- Windows installer assets +- Linux `.deb` +- Linux `.AppImage` + +## Exact log checks + +For each macOS matrix job, confirm logs contain: + +- `=== Finalizing macOS bundles for aarch64-apple-darwin ===` or `x86_64-apple-darwin` +- `Re-signed` +- `Submitting` +- `Stapling accepted ticket onto` +- `Validating stapled ticket on` +- `Created updater artifacts:` +- `xcrun stapler validate` +- `spctl --assess -vv` +- `codesign --verify --strict` +- `Created DataConnect__.dmg` +- `Notarized and stapled` +- `Uploaded DataConnect__.app.tar.gz` +- `Uploaded DataConnect__.app.tar.gz.sig` +- `Uploaded DataConnect__.dmg` + +Red flags: + +- `Skipping finalized macOS updater artifact generation` +- `Notarization FAILED` +- `does not have a ticket stapled` +- `rejected` +- `invalid` +- `Permission denied` +- generic macOS updater assets from `tauri-action`, for example: + - `DataConnect.app.tar.gz` + - `DataConnect_aarch64.app.tar.gz` + - `DataConnect_x64.app.tar.gz` without matching `.sig` +- any overwrite/clobber behavior on macOS updater assets + +## Exact post-run inspection commands + +```bash +# inspect release assets +gh release view v --json assets + +# locate the workflow run +gh run list --workflow Release --limit 10 + +# dump full logs for archive/review +gh run view --log > "/tmp/dataconnect-release-proof-v.log" + +# filter the log for proof markers +rg -n "Finalizing macOS bundles|Submitting|Stapling accepted ticket|Validating stapled ticket|Created updater artifacts|spctl --assess|codesign --verify|Uploaded DataConnect_|Notarized and stapled|Skipping finalized macOS updater artifact generation|Notarization FAILED|does not have a ticket stapled|rejected|invalid" "/tmp/dataconnect-release-proof-v.log" +``` + +## Pass criteria + +- both macOS jobs pass +- all 6 required macOS assets exist on the release +- no macOS updater asset overwrite/clobber +- updater tarball smoke gate passes after untar +- app notarization/stapling passes before updater packaging +- DMG notarization/stapling passes afterward + +## If the proof is just for validation + +After review, decide whether to keep or clean up the proof release/tag. + +Cleanup is manual: + +- delete the GitHub Release +- delete the tag locally/remotely if you do not want to keep the proof artifact history + +But note: + +- the consumed version number stays consumed for practical purposes once pushed/shared diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md new file mode 100644 index 0000000..3d6bfba --- /dev/null +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -0,0 +1,261 @@ +# 260311 plan: Tauri auto-update phase 2 Track B + +Source docs: + +- `docs/260311-tauri-auto-update-phase2-feasibility.md` +- `docs/260226-tauri-app-update-toast-overview.md` +- `docs/plans/260226-app-update-toast-phase1-implementation-plan.md` +- `docs/plans/260311-macos-updater-ci-proof-run.md` + +Use this doc in two modes: + +- Strategy lock before implementation. +- Execution contract during implementation. + +## Strategy Lock + +### Execution status update (2026-03-11) + +New discovery from execution: + +- Directly enabling Tauri `createUpdaterArtifacts` is not safe in the current macOS release flow. +- Reason: updater artifacts are generated during `tauri build`, but this repo final-signs the macOS `.app` after `tauri build`. +- That means the generated `.app.tar.gz` can drift from the final shipped app bytes. + +Immediate strategy adjustment: + +- Implement a custom post-finalization macOS updater-asset path first. +- Defer updater plugin/config/runtime wiring until the final-asset path is proven. + +Focused notarization proof: + +- DMG notarization is sufficient for first-install from the DMG. +- It is not sufficient evidence for the separately downloaded updater `.app.tar.gz` path. +- Apple `notarytool` only accepts UDIF disk images, signed flat installer packages, and zip files. +- That means the updater `.app.tar.gz` cannot itself be notarized directly. + +Current working implication: + +- The updater-served `.app` must be notarized/stapled before it is packaged into the updater `.app.tar.gz`. +- Therefore the current post-finalization updater script ordering is still incomplete if it runs before app notarization/stapling proof exists. +- Next execution slice should prove or implement: final-sign app -> notarize/staple app-compatible submission -> package stapled app into updater tarball -> sign tarball -> publish metadata. + +First real CI proof result: + +- release job passed overall +- DMG notarization path worked +- custom updater artifact path did not run because updater signing secrets were missing +- `tauri-action` still uploaded its own macOS updater tarballs, which polluted the release asset contract + +Follow-up adjustment: + +- stop `tauri-action` from uploading release assets +- keep all release uploads in the explicit manual upload step +- re-run proof only after updater signing secrets are configured + +### Goal + +Ship macOS-first phase 2 app updates in DataConnect: + +- startup check through Tauri updater +- silent background download after startup settles +- single `Restart to update` toast after staging completes +- apply/relaunch from inside the app + +### Scope + +In scope: + +- Tauri updater plugin wiring in Rust, JS, config, and capabilities +- updater signing-key contract +- macOS updater artifact generation +- GitHub Release asset upload for updater bundles and static metadata +- app-shell runtime state machine for `check -> idle download -> restart` +- focused tests and manual smoke for macOS phase 2 + +Out of scope: + +- Windows/Linux phase 2 rollout +- custom update backend/service +- removal of the CI nested in-app re-sign loop +- broad redesign of the phase-1 toast surface + +### Invariants + +- The deleted macOS post-build copy step stays deleted. +- Final updater artifacts/signatures must match the final shipped bytes. +- macOS phase 2 must not block startup-critical work. +- Non-macOS platforms stay on the existing phase-1 external-release flow. +- Runtime failures fail soft: log, keep app usable, no blocking modal flow. +- `useAppUpdate` remains the single app-shell orchestration seam. + +### Dependencies + +| Dependency | Status | Owner | Target date | Notes | +| ---------- | ------ | ----- | ----------- | ----- | +| Tauri updater signing keypair generated and stored securely | SOFT BLOCKED | release owner | before implementation finish | Need private key + optional password in CI; public key embedded in config | +| GitHub Release workflow can upload updater bundle assets plus `latest.json` | UNBLOCKED | repo/CI | during implementation | Current workflow already uploads release artifacts; needs updater asset/metadata extension | +| Tauri default updater artifact generation happens before repo final-sign step | BLOCKED for direct adoption | implementation | discovered 2026-03-11 | Current workflow cannot safely rely on raw `createUpdaterArtifacts` output alone | +| Custom post-finalization macOS updater asset generation path | SOFT BLOCKED | implementation | first execution slice | Must generate `.app.tar.gz` and `.sig` from a finalized notarized/stapled `.app`, not merely a finalized signed `.app` | +| Real upgrade smoke path from old macOS build to new macOS build | SOFT BLOCKED | implementation/release | before merge/release | Need a reproducible way to test one released build upgrading to another | +| CI notarization result for removing nested in-app re-sign loop | UNBLOCKED for Track B, unresolved for follow-up | release owner | after Track B or alongside first CI proof | Not a blocker for updater plumbing | + +### Approach + +Chosen approach: + +- macOS-first Tauri v2 updater +- custom post-finalization macOS updater asset generation before any runtime updater wiring +- package the updater tarball from a notarized/stapled `.app`, not only a signed `.app` +- static `latest.json` metadata hosted as a GitHub Release asset +- keep GitHub Releases as the only distribution surface +- keep phase-1 release-page check as the fallback path for non-macOS +- extend `useAppUpdate` instead of creating a second app-update provider +- generate updater metadata in a repo script, not inline shell glue in the workflow + +Rejected alternatives: + +- dynamic update server now: too much new infrastructure for this spike +- cross-platform phase 2 in one pass: adds avoidable surface area +- deleting the nested in-app re-sign loop in the same pass: separate notarization proof question +- replacing the whole release flow before proving static GitHub metadata works: too much churn + +### Replan triggers + +- `createUpdaterArtifacts` outputs do not match the finalized signed macOS app path we need to ship. +- We cannot produce a notarized/stapled app bundle before creating the updater `.app.tar.gz`. +- Static GitHub Release metadata cannot express the macOS-first rollout cleanly. +- Updater plugin permissions/config force broader Tauri capability changes than expected. +- Runtime updater API shape forces a larger state-model rewrite than `useAppUpdate` can absorb cleanly. + +## Execution Contract + +### Ordered implementation steps + +1. Prove or implement an app-level notarization/stapling path compatible with updater delivery. +2. Implement a repo-owned script that creates and signs a macOS updater bundle from the finalized notarized/stapled `.app`. +3. Extend `.github/workflows/release.yml` to: + - call that script after app notarization/stapling + - upload `.app.tar.gz` and `.app.tar.gz.sig` +4. Only after the custom macOS asset path works, add updater dependencies and Tauri capability/config wiring. +5. Add a repo-owned script to build `latest.json` from final updater assets and `.sig` contents. +6. Extend `.github/workflows/release.yml` to: + - inject updater signing key env vars + - upload `latest.json` +7. Add a dedicated runtime seam around the Tauri updater plugin. +8. Refactor `useAppUpdate` from phase-1 `release available` logic into: + - platform-aware decision path + - startup check + - idle download + - staged restart toast +9. Preserve phase-1 behavior for non-macOS and for failure fallback. +10. Add focused tests for config, state transitions, and action behavior. +11. Run local macOS artifact smoke, then release/upgrade proof. + +### Mandatory file edit contract + +Fill `Status` with `PASS` / `NO-OP` / `FAIL` during execution. + +| File | Required change | Status | Evidence | +| ---- | --------------- | ------ | -------- | +| `package.json` | add `@tauri-apps/plugin-updater`; add `@tauri-apps/plugin-process` if relaunch stays in JS | | | +| `src-tauri/Cargo.toml` | add `tauri-plugin-updater` | | | +| `src-tauri/src/lib.rs` | register updater plugin; move runtime config here only if config file is insufficient | | | +| `src-tauri/capabilities/default.json` | add updater permissions (`updater:default`) | | | +| `src-tauri/tauri.conf.json` | add `bundle.createUpdaterArtifacts`; add `plugins.updater.pubkey`; add `plugins.updater.endpoints` after post-finalization asset path is proven | | | +| `scripts/notarize-macos-app.mjs` | new script to submit a zip of the finalized `.app`, wait for notarization, staple the ticket back onto the `.app`, and validate it | PASS | repo script added; uses `ditto` + `xcrun notarytool submit` + `xcrun stapler` | +| `scripts/build-macos-updater-artifacts.mjs` | new script to archive/sign finalized macOS `.app` into `.app.tar.gz` and `.sig` | PASS | repo script added; uses `tauri signer sign` on finalized tarball | +| `scripts/build-updater-manifest.mjs` | new script to generate `latest.json` from release asset inputs | | | +| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | PASS | workflow now avoids xtrace around updater secrets, disables `tauri-action` release uploads, notarizes/staples the finalized `.app` before packaging the updater tarball, and hard-fails if the extracted updater payload fails stapler/spctl/codesign checks | +| `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | NO-OP | local build path intentionally unchanged in this slice | +| `src/hooks/app-update/check-app-update.ts` | preserve or narrow phase-1 external-release check as fallback path | | | +| `src/hooks/app-update/tauri-updater.ts` | new seam around `@tauri-apps/plugin-updater` APIs | | | +| `src/hooks/use-app-update.tsx` | orchestrate phase-2 state machine and preserve non-macOS fallback | | | +| `src/components/ui/sonner.tsx` | reuse existing toast surface; change only if restart UX requires it | | | +| `src/**/*.test.ts(x)` | add/adjust tests for updater seam and restart-toast flow | | | + +### Verification commands + +Use these exact checks during execution: + +```bash +# file-wiring scan +rg -n "plugin-updater|plugin-process|createUpdaterArtifacts|pubkey|endpoints|updater:default" \ + package.json src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/tauri.conf.json src-tauri/capabilities/default.json + +# runtime-surface scan +rg -n "check\\(|downloadAndInstall|relaunch|Restart to update|Update available|useAppUpdate" \ + src/hooks src/components src/pages + +# focused tests +npm run test -- \ + src/hooks/use-app-update.test.tsx \ + src/hooks/app-update/check-app-update.test.ts \ + src/hooks/app-update/app-update-ui-debug.test.ts + +# static confidence +npm run typecheck +npm run lint + +# local macOS artifact smoke +TAURI_SIGNING_PRIVATE_KEY="..." TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." npm run tauri -- build --bundles app + +# inspect generated updater artifacts +rg -n "\\.app\\.tar\\.gz|latest\\.json" src-tauri/target .github/workflows scripts +``` + +Release/upgrade proof commands: + +```bash +# inspect release assets after workflow run +gh release view vX.Y.Z --json assets + +# fetch generated metadata for inspection +gh release download vX.Y.Z --pattern "latest.json" --dir /tmp/dataconnect-updater-check +``` + +### Gate checklist + +- [ ] Code-path gates passed +- [ ] Behavior/runtime gates passed +- [ ] Build/test/lint gates passed +- [ ] CI/release gates passed +- [ ] Real upgrade smoke passed on macOS + +### PR evidence table + +| Gate | Command/evidence | Expected | Actual summary | Status | +| ---- | ---------------- | -------- | -------------- | ------ | +| Config | updater dependency/config/capability scan | all required updater touch points present | | | +| Build | local updater-artifact build | macOS build emits `.app.tar.gz` and `.sig` | | | +| Release | workflow asset upload | Release contains `.dmg`, `.app.tar.gz`, `.sig`, `latest.json` | | | +| Runtime | startup check stays non-blocking | app remains usable while updater checks | | | +| Runtime | idle download path | update downloads without immediate startup contention | | | +| Runtime | staged restart toast | single persistent `Restart to update` toast after staging | | | +| Runtime | restart action | click applies/relaunches successfully | | | +| Fallback | non-macOS behavior | non-macOS still uses phase-1 external-release path | | | +| Build | test/typecheck/lint | no new failures beyond repo baseline | | | + +### Done criteria + +1. No `FAIL` rows in file contract or PR evidence table. +2. macOS updater artifacts and `latest.json` are produced and published by the release flow. +3. `useAppUpdate` supports phase-2 staged update flow on macOS without regressing the phase-1 fallback path elsewhere. +4. A real macOS upgrade proof exists from an older build to a newer build. +5. The nested in-app re-sign loop question remains explicitly scoped as follow-up unless separately proven. + +### Strategy delta + +Record here if implementation changes: + +- updater metadata host +- relaunch ownership (JS vs Rust) +- macOS-only rollout boundary +- release workflow shape + +## Unresolved questions + +- Should `latest.json` live as a single `releases/latest/download/latest.json` asset only, or also be uploaded per tag for audit/debug? +- Should relaunch be owned in JS via `@tauri-apps/plugin-process`, or should Rust own the final restart/apply command path? +- What is the cleanest repeatable upgrade-proof path: disposable tagged releases, a private test repo, or a local static feed? +- Do we want the first Track B pass to show download progress anywhere, or stay intentionally silent until the staged restart toast? diff --git a/scripts/build-macos-updater-artifacts.mjs b/scripts/build-macos-updater-artifacts.mjs new file mode 100644 index 0000000..6cf9438 --- /dev/null +++ b/scripts/build-macos-updater-artifacts.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; + +function printHelp() { + console.log(`Usage: node scripts/build-macos-updater-artifacts.mjs --app [--output-dir ] [--artifact-name ] + +Create a finalized macOS updater tarball from a notarized/stapled .app bundle +and sign it with the Tauri updater private key. + +Required environment: + TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH +Optional environment: + TAURI_SIGNING_PRIVATE_KEY_PASSWORD`); +} + +function parseArgs(argv) { + const args = { app: null, outputDir: null, artifactName: null }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + + if (arg === '--app') { + args.app = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (arg === '--output-dir') { + args.outputDir = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (arg === '--artifact-name') { + args.artifactName = argv[index + 1] ?? null; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return args; +} + +function log(message) { + console.log(`\n🔨 ${message}`); +} + +function run(command, args, options = {}) { + console.log(` $ ${command} ${args.join(' ')}`); + execFileSync(command, args, { stdio: 'inherit', ...options }); +} + +function resolveNpmCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +function hasSigningKey() { + return Boolean( + process.env.TAURI_SIGNING_PRIVATE_KEY || + process.env.TAURI_SIGNING_PRIVATE_KEY_PATH + ); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + if (!args.app) { + throw new Error('Missing required --app argument'); + } + + if (!hasSigningKey()) { + throw new Error( + 'Missing updater signing key. Set TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH.' + ); + } + + const appPath = resolve(args.app); + if (!existsSync(appPath)) { + throw new Error(`App bundle not found: ${appPath}`); + } + if (!appPath.endsWith('.app')) { + throw new Error(`Expected a .app bundle, received: ${appPath}`); + } + + const outputDir = resolve(args.outputDir ?? dirname(appPath)); + mkdirSync(outputDir, { recursive: true }); + + const appName = basename(appPath); + const tarballName = args.artifactName ?? `${appName}.tar.gz`; + if (!tarballName.endsWith('.app.tar.gz')) { + throw new Error( + `Expected --artifact-name to end with .app.tar.gz, received: ${tarballName}` + ); + } + const tarballPath = join(outputDir, tarballName); + const signaturePath = `${tarballPath}.sig`; + + rmSync(tarballPath, { force: true }); + rmSync(signaturePath, { force: true }); + + log(`Creating updater tarball for ${appName}`); + run('tar', ['-czf', tarballPath, '-C', dirname(appPath), appName]); + + log(`Signing updater tarball ${basename(tarballPath)}`); + run(resolveNpmCommand(), ['run', 'tauri', 'signer', 'sign', '--', tarballPath]); + + if (!existsSync(tarballPath)) { + throw new Error(`Updater tarball was not created: ${tarballPath}`); + } + if (!existsSync(signaturePath)) { + throw new Error(`Updater signature was not created: ${signaturePath}`); + } + + console.log(`\nCreated updater artifacts: +- ${tarballPath} +- ${signaturePath}`); +} + +try { + main(); +} catch (error) { + console.error(`\n❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/scripts/build-prod.js b/scripts/build-prod.js index ae96e01..2310a3a 100644 --- a/scripts/build-prod.js +++ b/scripts/build-prod.js @@ -7,16 +7,12 @@ * 1. Builds the playwright-runner into a standalone binary * 2. Builds the personal-server into a standalone binary * 3. Builds the Tauri .app bundle - * 4. Injects personal-server native addons (node_modules/) into the .app + * 4. Re-signs the completed .app so macOS seals bundled resources * 5. Creates the DMG from the complete .app - * - * Tauri's resource glob flattens subdirectories, so we can't include - * node_modules/ via tauri.conf.json. Instead we build the .app first, - * copy node_modules/ in, then create the DMG ourselves. */ import { execSync } from 'child_process'; -import { existsSync, cpSync, readdirSync, mkdirSync, readFileSync } from 'fs'; +import { existsSync, readdirSync, mkdirSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { platform, arch } from 'os'; @@ -42,21 +38,6 @@ function getVersion() { return conf.version; } -/** Copy personal-server native addons (node_modules/) into .app Resources */ -function copyNativeModulesIntoApp(appPath) { - const srcNodeModules = join(PERSONAL_SERVER, 'dist', 'node_modules'); - - if (!existsSync(srcNodeModules)) { - log('WARNING: personal-server dist/node_modules not found, skipping copy'); - return; - } - - const destNodeModules = join(appPath, 'Contents', 'Resources', 'personal-server', 'dist', 'node_modules'); - log(` Copying native addons to ${destNodeModules}`); - mkdirSync(dirname(destNodeModules), { recursive: true }); - cpSync(srcNodeModules, destNodeModules, { recursive: true }); -} - /** Find the .app bundle in the macos bundle directory */ function findAppBundle() { const macosBundle = join(ROOT, 'src-tauri', 'target', 'release', 'bundle', 'macos'); @@ -69,6 +50,11 @@ function findAppBundle() { return null; } +function finalizeAppBundle(appPath) { + log(`Finalizing app signature for ${appPath}...`); + exec(`codesign --force --sign - "${appPath}"`); +} + async function build() { log('Building DataConnect for production...'); @@ -103,19 +89,15 @@ async function build() { exec('npm run build'); // 6. Build the .app bundle only (no DMG). - // Tauri's resource glob flattens directory structures, so node_modules/ - // can't be included via tauri.conf.json. We build .app first, inject - // node_modules, then create the DMG ourselves. log('Building Tauri .app bundle...'); - exec('npx tauri build --bundles app'); + exec('CI=true npm run tauri -- build --bundles app'); - // 7. Inject personal-server native addons into the .app bundle. + // 7. Re-sign the completed .app so the bundled resources are sealed. const appPath = findAppBundle(); if (!appPath) { throw new Error('.app bundle not found after build'); } - log(`Injecting native addons into ${appPath}...`); - copyNativeModulesIntoApp(appPath); + finalizeAppBundle(appPath); // 8. Create DMG from the complete .app. if (PLAT === 'darwin') { diff --git a/scripts/notarize-macos-app.mjs b/scripts/notarize-macos-app.mjs new file mode 100644 index 0000000..b044e71 --- /dev/null +++ b/scripts/notarize-macos-app.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; + +function printHelp() { + console.log(`Usage: node scripts/notarize-macos-app.mjs --app [--output-dir ] + +Submit a signed macOS .app for notarization via a temporary zip archive, then +staple the accepted ticket back onto the .app bundle. + +Required environment: + APPLE_NOTARY_KEY_PATH + APPLE_NOTARY_KEY_ID + APPLE_NOTARY_ISSUER`); +} + +function parseArgs(argv) { + const args = { app: null, outputDir: null, help: false }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + + if (arg === '--app') { + args.app = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (arg === '--output-dir') { + args.outputDir = argv[index + 1] ?? null; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return args; +} + +function log(message) { + console.log(`\n🔨 ${message}`); +} + +function run(command, args, options = {}) { + console.log(` $ ${command} ${args.join(' ')}`); + execFileSync(command, args, { stdio: 'inherit', ...options }); +} + +function runWithCapturedOutput(command, args, options = {}) { + console.log(` $ ${command} ${args.join(' ')}`); + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'pipe'], + ...options, + }); +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function extractSubmissionId(output) { + const match = output.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + ); + return match?.[0] ?? null; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + if (!args.app) { + throw new Error('Missing required --app argument'); + } + + const keyPath = requireEnv('APPLE_NOTARY_KEY_PATH'); + const keyId = requireEnv('APPLE_NOTARY_KEY_ID'); + const issuer = requireEnv('APPLE_NOTARY_ISSUER'); + + const appPath = resolve(args.app); + if (!existsSync(appPath)) { + throw new Error(`App bundle not found: ${appPath}`); + } + if (!appPath.endsWith('.app')) { + throw new Error(`Expected a .app bundle, received: ${appPath}`); + } + + const outputDir = resolve(args.outputDir ?? dirname(appPath)); + mkdirSync(outputDir, { recursive: true }); + + const zipPath = join(outputDir, `${basename(appPath)}.notary.zip`); + rmSync(zipPath, { force: true }); + + log(`Creating notarization zip for ${basename(appPath)}`); + run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath]); + + log(`Submitting ${basename(zipPath)} for notarization`); + let notarizeOutput = ''; + try { + notarizeOutput = runWithCapturedOutput('xcrun', [ + 'notarytool', + 'submit', + zipPath, + '--key', + keyPath, + '--key-id', + keyId, + '--issuer', + issuer, + '--wait', + ]); + process.stdout.write(notarizeOutput); + } catch (error) { + const stdout = + typeof error?.stdout === 'string' ? error.stdout : error?.stdout?.toString?.() ?? ''; + const stderr = + typeof error?.stderr === 'string' ? error.stderr : error?.stderr?.toString?.() ?? ''; + notarizeOutput = `${stdout}\n${stderr}`; + process.stdout.write(stdout); + process.stderr.write(stderr); + + const submissionId = extractSubmissionId(notarizeOutput); + if (submissionId) { + console.error(`\n=== Fetching notarization log for ${submissionId} ===`); + try { + run('xcrun', [ + 'notarytool', + 'log', + submissionId, + '--key', + keyPath, + '--key-id', + keyId, + '--issuer', + issuer, + ]); + } catch { + // Best effort only; keep original failure. + } + } + + throw new Error(`Notarization failed for ${zipPath}`); + } finally { + rmSync(zipPath, { force: true }); + } + + log(`Stapling accepted ticket onto ${basename(appPath)}`); + run('xcrun', ['stapler', 'staple', appPath]); + + log(`Validating stapled ticket on ${basename(appPath)}`); + run('xcrun', ['stapler', 'validate', appPath]); +} + +try { + main(); +} catch (error) { + console.error(`\n❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d46b8ea..2fa9001 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "DataConnect", - "version": "0.7.33", + "version": "0.7.34", "identifier": "dev.dataconnect", "build": { "frontendDist": "../dist",