From b40be3eeba8a5df878df3a3839b84a3beb9e07a9 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 10 Aug 2026 20:47:57 +0800 Subject: [PATCH] chore(release): prepare v1.0.0-beta.4 --- .github/workflows/release.yml | 719 ++++- CONTRIBUTING.md | 2 +- Cargo.lock | 543 ++-- Cargo.toml | 2 +- README.md | 7 +- README.zh-CN.md | 2 +- crates/opentake-media/Cargo.toml | 10 +- crates/opentake-media/src/ort_worker/mod.rs | 8 +- crates/opentake-ops/src/command.rs | 14 +- crates/opentake-ops/tests/command_apply.rs | 25 + crates/opentake-project/tests/roundtrip.rs | 35 +- docs/INDEX.md | 2 +- docs/architecture/INDEX.md | 1 + docs/architecture/PLAYBACK-ENGINE.md | 22 +- docs/architecture/UPDATER.md | 61 + .../2026-08-10/final-module-validation.md | 77 + docs/releases/1.0.0-beta.4.md | 65 + .../2026-08-10-opentake-beta4-release.md | 129 + scripts/check_release_workflow.py | 678 +++- scripts/test_check_release_workflow.py | 295 +- scripts/test_write_updater_attestation.py | 173 + scripts/test_write_updater_manifest.py | 463 +++ scripts/write_updater_attestation.py | 184 ++ scripts/write_updater_manifest.py | 388 +++ src-tauri/Cargo.toml | 11 +- src-tauri/src/account.rs | 10 +- src-tauri/src/advanced.rs | 82 +- src-tauri/src/captions.rs | 17 + src-tauri/src/chat.rs | 78 +- src-tauri/src/codex.rs | 15 +- src-tauri/src/commands.rs | 222 +- src-tauri/src/feedback.rs | 2 + src-tauri/src/generation.rs | 162 +- src-tauri/src/home.rs | 16 + src-tauri/src/lib.rs | 84 +- src-tauri/src/library.rs | 12 + src-tauri/src/lut.rs | 7 +- src-tauri/src/media.rs | 562 +++- src-tauri/src/media/prewarm.rs | 92 +- src-tauri/src/motion.rs | 65 +- src-tauri/src/playback/audio.rs | 224 +- src-tauri/src/playback/commands.rs | 507 ++- src-tauri/src/playback/engine.rs | 87 +- src-tauri/src/playback/session.rs | 24 +- src-tauri/src/render.rs | 33 +- src-tauri/src/samples.rs | 4 + src-tauri/src/search.rs | 4 + src-tauri/src/secret.rs | 14 +- src-tauri/src/storage.rs | 2 + src-tauri/src/transcribe.rs | 4 + src-tauri/src/updater.rs | 2849 +++++++++++++++++ src-tauri/tauri.conf.json | 11 +- src-tauri/tests/security_config.rs | 2 +- web/package.json | 2 +- web/src/App.lifecycle.test.tsx | 8 +- web/src/App.tsx | 10 +- web/src/components/media/MediaPanel.test.tsx | 3 + web/src/components/media/MediaTabBar.test.tsx | 131 + web/src/components/media/MediaTabBar.tsx | 2 + .../components/media/TransitionTab.test.tsx | 38 +- web/src/components/media/TransitionTab.tsx | 4 + .../preview/RustFrameBuffer.dom.test.tsx | 42 + .../components/preview/RustFrameBuffer.tsx | 16 +- .../preview/nativePlaybackSession.test.ts | 52 + .../preview/nativePlaybackSession.ts | 23 +- .../components/preview/previewEngine.test.ts | 27 + web/src/components/preview/previewEngine.ts | 35 +- .../preview/rustFrameBuffer.test.ts | 13 + web/src/components/preview/rustFrameBuffer.ts | 10 + web/src/components/settings/SettingsView.tsx | 2 + .../components/settings/UpdateDialog.test.tsx | 162 + web/src/components/settings/UpdateDialog.tsx | 247 ++ .../shell/ExportDialog.interaction.test.tsx | 18 +- web/src/components/shell/ExportDialog.tsx | 10 +- .../shell/TitleBar.interaction.test.tsx | 3 + web/src/components/shell/ViewMenu.test.tsx | 150 +- web/src/components/shell/ViewMenu.tsx | 69 +- .../ui/PanelShell.interaction.test.tsx | 4 +- web/src/components/ui/PanelShell.tsx | 22 +- web/src/hooks/useKeyboardShortcuts.test.ts | 79 +- web/src/hooks/useKeyboardShortcuts.ts | 15 +- web/src/i18n/dict.ts | 46 +- web/src/i18n/index.test.ts | 8 + web/src/lib/api.ts | 47 + web/src/lib/api.updateRecovery.test.ts | 32 + web/src/lib/updateScheduler.test.ts | 43 + web/src/lib/updateScheduler.ts | 26 + web/src/releaseParity.test.tsx | 4 +- web/src/store/uiStore.ts | 10 +- web/src/store/updateStore.test.ts | 220 ++ web/src/store/updateStore.ts | 227 ++ 91 files changed, 10377 insertions(+), 590 deletions(-) create mode 100644 docs/architecture/UPDATER.md create mode 100644 docs/audit/2026-08-10/final-module-validation.md create mode 100644 docs/releases/1.0.0-beta.4.md create mode 100644 docs/superpowers/plans/2026-08-10-opentake-beta4-release.md create mode 100644 scripts/test_write_updater_attestation.py create mode 100644 scripts/test_write_updater_manifest.py create mode 100644 scripts/write_updater_attestation.py create mode 100644 scripts/write_updater_manifest.py create mode 100644 src-tauri/src/updater.rs create mode 100644 web/src/components/settings/UpdateDialog.test.tsx create mode 100644 web/src/components/settings/UpdateDialog.tsx create mode 100644 web/src/lib/api.updateRecovery.test.ts create mode 100644 web/src/lib/updateScheduler.test.ts create mode 100644 web/src/lib/updateScheduler.ts create mode 100644 web/src/store/updateStore.test.ts create mode 100644 web/src/store/updateStore.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99a1f30b..3997f295 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,7 @@ jobs: shell: bash run: | set -euo pipefail + test "$GITHUB_REPOSITORY" = "appergb/OpenTake" git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}" source_sha="$(git rev-parse "${RELEASE_TAG}^{commit}" | tr '[:upper:]' '[:lower:]')" actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" @@ -68,10 +69,11 @@ jobs: identifier = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" SEMVER_RE = re.compile( rf"^v{numeric}\.{numeric}\.{numeric}" - rf"(?:-{identifier}(?:\.{identifier})*)?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" + rf"(?:-{identifier}(?:\.{identifier})*)?$" ) tag = os.environ["RELEASE_TAG"] + if "+" in tag: + raise SystemExit("SemVer build metadata is unsupported for updater asset URLs") if SEMVER_RE.fullmatch(tag) is None: raise SystemExit(f"release tag is not v: {tag}") version = tag[1:] @@ -86,14 +88,14 @@ jobs: if versions != {version}: raise SystemExit(f"tag/version mismatch: {tag} != {sorted(versions)}") wix_version = tauri["bundle"]["windows"]["wix"]["version"] - if wix_version != "1.0.0.3": + if wix_version != "1.0.0.4": raise SystemExit(f"unexpected Windows installer version: {wix_version}") notes = Path("docs/releases") / f"{version}.md" if not notes.is_file() or not notes.read_text(encoding="utf-8").strip(): raise SystemExit(f"release notes are missing or empty: {notes}") prerelease = "-" in version.split("+", 1)[0] - if version == "1.0.0-beta.3" and not prerelease: - raise SystemExit("OpenTake 1.0.0-beta.3 must remain a prerelease") + if version == "1.0.0-beta.4" and not prerelease: + raise SystemExit("OpenTake 1.0.0-beta.4 must remain a prerelease") if not prerelease: raise SystemExit("this release workflow publishes prereleases only") @@ -216,6 +218,8 @@ jobs: python3 -B -m unittest discover -s scripts -p 'test_check_windows_product_ci.py' python3 -B scripts/check_release_workflow.py python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py' + python3 -B -m unittest discover -s scripts -p 'test_write_updater_attestation.py' + python3 -B -m unittest discover -s scripts -p 'test_write_updater_manifest.py' - name: Provisioner unit tests run: python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' @@ -273,6 +277,8 @@ jobs: timeout-minutes: 120 env: TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} APPLE_SIGNING_IDENTITY: '-' CI: true steps: @@ -292,6 +298,16 @@ jobs: git cat-file -e "${expected}^{commit}" test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Require updater signing secrets + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -euo pipefail + test -n "${TAURI_SIGNING_PRIVATE_KEY:-}" + test -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" + - name: Install Rust toolchain run: rustup component add rustfmt clippy @@ -333,21 +349,31 @@ jobs: git diff --cached --quiet --exit-code HEAD -- test -z "$(git status --porcelain=v1 --untracked-files=all)" - - name: Build ad-hoc Tauri app and DMG + - name: Build ad-hoc Tauri app, DMG, and signed updater + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: >- ./web/node_modules/.bin/tauri build --ci --target aarch64-apple-darwin --bundles app,dmg - --config '{"bundle":{"macOS":{"signingIdentity":"-"}}}' + --config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"signingIdentity":"-"}}}' - - name: Verify complete app, sidecars, and DMG + - name: Verify complete app, sidecars, DMG, and signed updater shell: bash run: | set -euo pipefail bundle_root="target/aarch64-apple-darwin/release/bundle" test "$(find "$bundle_root/macos" -maxdepth 1 -type d -name '*.app' | wc -l | tr -d ' ')" -eq 1 test "$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' | wc -l | tr -d ' ')" -eq 1 + test "$(find "$bundle_root/macos" -maxdepth 1 -type f -name '*.app.tar.gz' | wc -l | tr -d ' ')" -eq 1 + test "$(find "$bundle_root/macos" -maxdepth 1 -type f -name '*.app.tar.gz.sig' | wc -l | tr -d ' ')" -eq 1 app="$(find "$bundle_root/macos" -maxdepth 1 -type d -name '*.app' -print -quit)" dmg="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + updater="$(find "$bundle_root/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit)" + updater_signature="$(find "$bundle_root/macos" -maxdepth 1 -type f -name '*.app.tar.gz.sig' -print -quit)" + test "$updater_signature" = "$updater.sig" + test -s "$updater" + test -s "$updater_signature" test -f "$app/Contents/MacOS/ffmpeg" test -f "$app/Contents/MacOS/ffprobe" codesign --verify --deep --strict --verbose=2 "$app" @@ -389,6 +415,29 @@ jobs: git diff --cached --quiet --exit-code HEAD -- test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Create and sign macOS updater attestation + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -euo pipefail + bundle_root="target/aarch64-apple-darwin/release/bundle/macos" + test "$(find "$bundle_root" -maxdepth 1 -type f -name '*.app.tar.gz' | wc -l | tr -d ' ')" -eq 1 + updater="$(find "$bundle_root" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit)" + attestation="$updater.attestation.json" + python3 scripts/write_updater_attestation.py \ + --repository appergb/OpenTake \ + --tag "$RELEASE_TAG" \ + --version "$RELEASE_VERSION" \ + --source-sha "$TARGET_SHA" \ + --platform darwin-aarch64 \ + --artifact "$updater" \ + --output "$attestation" + ./web/node_modules/.bin/tauri signer sign "$attestation" + test -s "$attestation" + test -s "$attestation.sig" + - name: Create macOS exact-SHA receipt env: RECEIPT_SHA: ${{ needs.validate.outputs.source_sha }} @@ -406,34 +455,67 @@ jobs: digest.update(chunk) return digest.hexdigest() - matches = list(Path("target/aarch64-apple-darwin/release/bundle/dmg").glob("*.dmg")) - if len(matches) != 1: - raise SystemExit(f"expected exactly one DMG, found {len(matches)}") - artifact = matches[0] + bundle = Path("target/aarch64-apple-darwin/release/bundle") + dmgs = list((bundle / "dmg").glob("*.dmg")) + updaters = list((bundle / "macos").glob("*.app.tar.gz")) + signatures = list((bundle / "macos").glob("*.app.tar.gz.sig")) + attestations = list((bundle / "macos").glob("*.app.tar.gz.attestation.json")) + attestation_signatures = list( + (bundle / "macos").glob("*.app.tar.gz.attestation.json.sig") + ) + if ( + len(dmgs) != 1 + or len(updaters) != 1 + or len(signatures) != 1 + or len(attestations) != 1 + or len(attestation_signatures) != 1 + ): + raise SystemExit("expected one DMG and one signed updater attestation set") + if signatures[0] != Path(f"{updaters[0]}.sig"): + raise SystemExit("macOS updater signature is not the archive companion") + if attestations[0] != Path(f"{updaters[0]}.attestation.json"): + raise SystemExit("macOS attestation is not the archive companion") + if attestation_signatures[0] != Path(f"{attestations[0]}.sig"): + raise SystemExit("macOS attestation signature is not its companion") + artifacts = [ + { + "name": artifact.name, + "sha256": sha256(artifact), + "bytes": artifact.stat().st_size, + } + for artifact in ( + dmgs[0], + updaters[0], + signatures[0], + attestations[0], + attestation_signatures[0], + ) + ] receipt = { - "schema": "opentake-macos-arm64-receipt-v1", + "schema": "opentake-macos-arm64-receipt-v2", "repository": os.environ["GITHUB_REPOSITORY"], "run_id": os.environ["GITHUB_RUN_ID"], "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], "source_sha": os.environ["RECEIPT_SHA"], - "signature_mode": "ad-hoc", - "artifact": { - "name": artifact.name, - "sha256": sha256(artifact), - "bytes": artifact.stat().st_size, - }, + "platform_signing_mode": "ad-hoc", + "updater_signature_mode": "tauri-minisign", + "artifacts": artifacts, } Path("macos-arm64-receipt.json").write_text( json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) PY - - name: Upload exact-SHA macOS package + - name: Upload exact-SHA macOS packages and updater uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: opentake-macos-arm64-${{ needs.validate.outputs.source_sha }} path: | target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz + target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig + target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.attestation.json + target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.attestation.json.sig macos-arm64-receipt.json if-no-files-found: error retention-days: 30 @@ -445,6 +527,8 @@ jobs: timeout-minutes: 120 env: TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} CI: true steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -463,6 +547,16 @@ jobs: git cat-file -e "${expected}^{commit}" test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Require updater signing secrets + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -euo pipefail + test -n "${TAURI_SIGNING_PRIVATE_KEY:-}" + test -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" + - name: Install Rust toolchain run: rustup component add rustfmt clippy @@ -531,20 +625,30 @@ jobs: git diff --cached --quiet --exit-code HEAD -- test -z "$(git status --porcelain=v1 --untracked-files=all)" - - name: Build native MSI and NSIS installers + - name: Build native MSI, NSIS, and signed updater artifacts shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | Remove-Item 'target/release/bundle/msi' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item 'target/release/bundle/nsis' -Recurse -Force -ErrorAction SilentlyContinue - & .\web\node_modules\.bin\tauri.cmd build --ci --bundles msi,nsis + & .\web\node_modules\.bin\tauri.cmd build --ci --bundles msi,nsis --config '{"bundle":{"createUpdaterArtifacts":true}}' - - name: Install NSIS and smoke installed app and sidecars + - name: Install NSIS and smoke installed app, sidecars, and updater artifacts shell: pwsh run: | $msi = @(Get-ChildItem 'target/release/bundle/msi/*.msi' -File) $installer = @(Get-ChildItem 'target/release/bundle/nsis/*.exe' -File) + $msiSignature = @(Get-ChildItem 'target/release/bundle/msi/*.msi.sig' -File) + $nsisSignature = @(Get-ChildItem 'target/release/bundle/nsis/*.exe.sig' -File) if ($msi.Count -ne 1) { throw 'expected exactly one MSI installer' } if ($installer.Count -ne 1) { throw 'expected exactly one NSIS installer' } + if ($msiSignature.Count -ne 1) { throw 'expected exactly one MSI updater signature' } + if ($nsisSignature.Count -ne 1) { throw 'expected exactly one NSIS updater signature' } + if ($msiSignature[0].FullName -ne "$($msi[0].FullName).sig") { throw 'MSI signature is not the installer companion' } + if ($nsisSignature[0].FullName -ne "$($installer[0].FullName).sig") { throw 'NSIS signature is not the installer companion' } + if ($msiSignature[0].Length -le 0 -or $nsisSignature[0].Length -le 0) { throw 'updater signature is empty' } $install = Start-Process -FilePath $installer[0].FullName -ArgumentList '/S' -Wait -PassThru if ($install.ExitCode -ne 0) { throw "silent NSIS install failed: $($install.ExitCode)" } $candidates = @( @@ -587,6 +691,45 @@ jobs: git diff --cached --quiet --exit-code HEAD -- test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Create and sign Windows updater attestations + shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + $msi = @(Get-ChildItem 'target/release/bundle/msi/*.msi' -File) + $nsis = @(Get-ChildItem 'target/release/bundle/nsis/*.exe' -File) + if ($msi.Count -ne 1) { throw 'expected exactly one MSI updater' } + if ($nsis.Count -ne 1) { throw 'expected exactly one NSIS updater' } + $msiAttestation = "$($msi[0].FullName).attestation.json" + python scripts/write_updater_attestation.py ` + --repository appergb/OpenTake ` + --tag $env:RELEASE_TAG ` + --version $env:RELEASE_VERSION ` + --source-sha $env:TARGET_SHA ` + --platform windows-x86_64-msi ` + --artifact $msi[0].FullName ` + --output $msiAttestation + if ($LASTEXITCODE -ne 0) { throw 'Windows MSI updater attestation generation failed' } + & .\web\node_modules\.bin\tauri.cmd signer sign $msiAttestation + if ($LASTEXITCODE -ne 0) { throw 'Windows MSI updater attestation signing failed' } + $nsisAttestation = "$($nsis[0].FullName).attestation.json" + python scripts/write_updater_attestation.py ` + --repository appergb/OpenTake ` + --tag $env:RELEASE_TAG ` + --version $env:RELEASE_VERSION ` + --source-sha $env:TARGET_SHA ` + --platform windows-x86_64-nsis ` + --artifact $nsis[0].FullName ` + --output $nsisAttestation + if ($LASTEXITCODE -ne 0) { throw 'Windows NSIS updater attestation generation failed' } + & .\web\node_modules\.bin\tauri.cmd signer sign $nsisAttestation + if ($LASTEXITCODE -ne 0) { throw 'Windows NSIS updater attestation signing failed' } + foreach ($attestation in @($msiAttestation, $nsisAttestation)) { + if (-not (Test-Path $attestation -PathType Leaf)) { throw "Windows updater attestation is missing: $attestation" } + if (-not (Test-Path "$attestation.sig" -PathType Leaf)) { throw "Windows updater attestation signature is missing: $attestation.sig" } + } + - name: Create Windows exact-SHA receipt shell: pwsh env: @@ -594,9 +737,27 @@ jobs: run: | $msi = @(Get-ChildItem 'target/release/bundle/msi/*.msi' -File) $nsis = @(Get-ChildItem 'target/release/bundle/nsis/*.exe' -File) + $msiSignature = @(Get-ChildItem 'target/release/bundle/msi/*.msi.sig' -File) + $nsisSignature = @(Get-ChildItem 'target/release/bundle/nsis/*.exe.sig' -File) + $msiAttestation = @(Get-ChildItem 'target/release/bundle/msi/*.msi.attestation.json' -File) + $msiAttestationSignature = @(Get-ChildItem 'target/release/bundle/msi/*.msi.attestation.json.sig' -File) + $nsisAttestation = @(Get-ChildItem 'target/release/bundle/nsis/*.exe.attestation.json' -File) + $nsisAttestationSignature = @(Get-ChildItem 'target/release/bundle/nsis/*.exe.attestation.json.sig' -File) if ($msi.Count -ne 1) { throw 'expected exactly one MSI installer' } if ($nsis.Count -ne 1) { throw 'expected exactly one NSIS installer' } - $artifacts = @($msi + $nsis) | ForEach-Object { + if ($msiSignature.Count -ne 1) { throw 'expected exactly one MSI updater signature' } + if ($nsisSignature.Count -ne 1) { throw 'expected exactly one NSIS updater signature' } + if ($msiAttestation.Count -ne 1) { throw 'expected exactly one MSI updater attestation' } + if ($msiAttestationSignature.Count -ne 1) { throw 'expected exactly one MSI attestation signature' } + if ($nsisAttestation.Count -ne 1) { throw 'expected exactly one NSIS updater attestation' } + if ($nsisAttestationSignature.Count -ne 1) { throw 'expected exactly one NSIS attestation signature' } + if ($msiSignature[0].FullName -ne "$($msi[0].FullName).sig") { throw 'MSI signature is not the installer companion' } + if ($nsisSignature[0].FullName -ne "$($nsis[0].FullName).sig") { throw 'NSIS signature is not the installer companion' } + if ($msiAttestation[0].FullName -ne "$($msi[0].FullName).attestation.json") { throw 'MSI attestation is not the installer companion' } + if ($msiAttestationSignature[0].FullName -ne "$($msiAttestation[0].FullName).sig") { throw 'MSI attestation signature is not its companion' } + if ($nsisAttestation[0].FullName -ne "$($nsis[0].FullName).attestation.json") { throw 'NSIS attestation is not the installer companion' } + if ($nsisAttestationSignature[0].FullName -ne "$($nsisAttestation[0].FullName).sig") { throw 'NSIS attestation signature is not its companion' } + $artifacts = @($msi + $nsis + $msiSignature + $nsisSignature + $msiAttestation + $msiAttestationSignature + $nsisAttestation + $nsisAttestationSignature) | ForEach-Object { [ordered]@{ name = $_.Name sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() @@ -604,24 +765,32 @@ jobs: } } $receipt = [ordered]@{ - schema = 'opentake-windows-release-receipt-v1' + schema = 'opentake-windows-release-receipt-v2' repository = '${{ github.repository }}' run_id = '${{ github.run_id }}' run_attempt = '${{ github.run_attempt }}' runner_os = '${{ runner.os }}' runner_arch = '${{ runner.arch }}' source_sha = $env:RECEIPT_SHA + platform_signing_mode = 'unsigned-authenticode' + updater_signature_mode = 'tauri-minisign' artifacts = @($artifacts) } $receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding utf8NoBOM windows-x64-receipt.json - - name: Upload exact-SHA Windows packages + - name: Upload exact-SHA Windows packages and updater signatures uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: opentake-windows-x64-${{ needs.validate.outputs.source_sha }} path: | target/release/bundle/msi/*.msi + target/release/bundle/msi/*.msi.sig + target/release/bundle/msi/*.msi.attestation.json + target/release/bundle/msi/*.msi.attestation.json.sig target/release/bundle/nsis/*.exe + target/release/bundle/nsis/*.exe.sig + target/release/bundle/nsis/*.exe.attestation.json + target/release/bundle/nsis/*.exe.attestation.json.sig windows-x64-receipt.json if-no-files-found: error retention-days: 30 @@ -669,6 +838,14 @@ jobs: esac printf 'PUBLISH_ROOT=%s\n' "$publish_root" >> "$GITHUB_ENV" + - name: Install Minisign verifier + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends minisign + command -v minisign >/dev/null + - name: Download macOS artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -689,18 +866,40 @@ jobs: "$RUNNER_TEMP"/opentake-release-*) ;; *) echo "publish root must be under RUNNER_TEMP" >&2; exit 1 ;; esac - mapfile -d '' dmgs < <(find "$PUBLISH_ROOT/input" -type f -name '*.dmg' -print0) - mapfile -d '' msis < <(find "$PUBLISH_ROOT/input" -type f -name '*.msi' -print0) - mapfile -d '' exes < <(find "$PUBLISH_ROOT/input" -type f -name '*.exe' -print0) - mapfile -d '' mac_receipts < <(find "$PUBLISH_ROOT/input" -type f -name 'macos-arm64-receipt.json' -print0) - mapfile -d '' windows_receipts < <(find "$PUBLISH_ROOT/input" -type f -name 'windows-x64-receipt.json' -print0) + mapfile -d '' dmgs < <(find "$PUBLISH_ROOT/input/macos" -type f -name '*.dmg' -print0) + mapfile -d '' mac_updaters < <(find "$PUBLISH_ROOT/input/macos" -type f -name '*.app.tar.gz' -print0) + mapfile -d '' mac_signatures < <(find "$PUBLISH_ROOT/input/macos" -type f -name '*.app.tar.gz.sig' -print0) + mapfile -d '' mac_attestations < <(find "$PUBLISH_ROOT/input/macos" -type f -name '*.app.tar.gz.attestation.json' -print0) + mapfile -d '' mac_attestation_signatures < <(find "$PUBLISH_ROOT/input/macos" -type f -name '*.app.tar.gz.attestation.json.sig' -print0) + mapfile -d '' msis < <(find "$PUBLISH_ROOT/input/windows" -type f -name '*.msi' -print0) + mapfile -d '' exes < <(find "$PUBLISH_ROOT/input/windows" -type f -name '*.exe' -print0) + mapfile -d '' windows_signatures < <(find "$PUBLISH_ROOT/input/windows" -type f \( -name '*.msi.sig' -o -name '*.exe.sig' \) -print0) + mapfile -d '' msi_attestations < <(find "$PUBLISH_ROOT/input/windows" -type f -name '*.msi.attestation.json' -print0) + mapfile -d '' msi_attestation_signatures < <(find "$PUBLISH_ROOT/input/windows" -type f -name '*.msi.attestation.json.sig' -print0) + mapfile -d '' nsis_attestations < <(find "$PUBLISH_ROOT/input/windows" -type f -name '*.exe.attestation.json' -print0) + mapfile -d '' nsis_attestation_signatures < <(find "$PUBLISH_ROOT/input/windows" -type f -name '*.exe.attestation.json.sig' -print0) + mapfile -d '' mac_receipts < <(find "$PUBLISH_ROOT/input/macos" -type f -name 'macos-arm64-receipt.json' -print0) + mapfile -d '' windows_receipts < <(find "$PUBLISH_ROOT/input/windows" -type f -name 'windows-x64-receipt.json' -print0) test "${#dmgs[@]}" -eq 1 + test "${#mac_updaters[@]}" -eq 1 + test "${#mac_signatures[@]}" -eq 1 + test "${#mac_attestations[@]}" -eq 1 + test "${#mac_attestation_signatures[@]}" -eq 1 test "${#msis[@]}" -eq 1 test "${#exes[@]}" -eq 1 + test "${#windows_signatures[@]}" -eq 2 + test "${#msi_attestations[@]}" -eq 1 + test "${#msi_attestation_signatures[@]}" -eq 1 + test "${#nsis_attestations[@]}" -eq 1 + test "${#nsis_attestation_signatures[@]}" -eq 1 test "${#mac_receipts[@]}" -eq 1 test "${#windows_receipts[@]}" -eq 1 mkdir -p "$PUBLISH_ROOT/assets" - cp "${dmgs[0]}" "${msis[0]}" "${exes[0]}" "$PUBLISH_ROOT/assets/" + cp "${dmgs[0]}" "${mac_updaters[0]}" "${mac_signatures[0]}" "$PUBLISH_ROOT/assets/" + cp "${mac_attestations[0]}" "${mac_attestation_signatures[0]}" "$PUBLISH_ROOT/assets/" + cp "${msis[0]}" "${exes[0]}" "${windows_signatures[@]}" "$PUBLISH_ROOT/assets/" + cp "${msi_attestations[0]}" "${msi_attestation_signatures[0]}" "$PUBLISH_ROOT/assets/" + cp "${nsis_attestations[0]}" "${nsis_attestation_signatures[0]}" "$PUBLISH_ROOT/assets/" cp "${mac_receipts[0]}" "$PUBLISH_ROOT/assets/macos-arm64-receipt.json" cp "${windows_receipts[0]}" "$PUBLISH_ROOT/assets/windows-x64-receipt.json" @@ -719,47 +918,387 @@ jobs: root = Path(os.environ["PUBLISH_ROOT"]) / "assets" files = [path for path in root.iterdir() if path.is_file()] - expected = {"dmg": 1, "msi": 1, "exe": 1, "json": 2} - actual = { - "dmg": sum(path.suffix.lower() == ".dmg" for path in files), - "msi": sum(path.suffix.lower() == ".msi" for path in files), - "exe": sum(path.suffix.lower() == ".exe" for path in files), - "json": sum(path.suffix.lower() == ".json" for path in files), - } - if actual != expected or len(files) != 5: - raise SystemExit(f"unexpected staged release payload: {actual}") + dmgs = [path for path in files if path.name.endswith(".dmg")] + mac_updaters = [path for path in files if path.name.endswith(".app.tar.gz")] + mac_signatures = [path for path in files if path.name.endswith(".app.tar.gz.sig")] + mac_attestations = [ + path for path in files + if path.name.endswith(".app.tar.gz.attestation.json") + ] + mac_attestation_signatures = [ + path for path in files + if path.name.endswith(".app.tar.gz.attestation.json.sig") + ] + msis = [path for path in files if path.name.endswith(".msi")] + exes = [path for path in files if path.name.endswith(".exe")] + windows_signatures = [ + path for path in files + if path.name.endswith(".msi.sig") or path.name.endswith(".exe.sig") + ] + msi_attestations = [ + path for path in files if path.name.endswith(".msi.attestation.json") + ] + msi_attestation_signatures = [ + path for path in files + if path.name.endswith(".msi.attestation.json.sig") + ] + nsis_attestations = [ + path for path in files if path.name.endswith(".exe.attestation.json") + ] + nsis_attestation_signatures = [ + path for path in files + if path.name.endswith(".exe.attestation.json.sig") + ] + receipts = [path for path in files if path.name.endswith("-receipt.json")] + counts = tuple( + len(group) + for group in ( + dmgs, + mac_updaters, + mac_signatures, + mac_attestations, + mac_attestation_signatures, + msis, + exes, + windows_signatures, + msi_attestations, + msi_attestation_signatures, + nsis_attestations, + nsis_attestation_signatures, + receipts, + ) + ) + if counts != (1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 2) or len(files) != 15: + raise SystemExit(f"unexpected staged release payload counts: {counts}") + if mac_signatures[0] != Path(f"{mac_updaters[0]}.sig"): + raise SystemExit("macOS updater signature is not the archive companion") + if {path.name for path in windows_signatures} != { + f"{msis[0].name}.sig", + f"{exes[0].name}.sig", + }: + raise SystemExit("Windows updater signatures are not installer companions") + + if mac_attestations[0] != Path(f"{mac_updaters[0]}.attestation.json"): + raise SystemExit("macOS attestation is not the updater companion") + if mac_attestation_signatures[0] != Path(f"{mac_attestations[0]}.sig"): + raise SystemExit("macOS attestation signature is not its companion") + if msi_attestations[0] != Path(f"{msis[0]}.attestation.json"): + raise SystemExit("MSI attestation is not the updater companion") + if msi_attestation_signatures[0] != Path(f"{msi_attestations[0]}.sig"): + raise SystemExit("MSI attestation signature is not its companion") + if nsis_attestations[0] != Path(f"{exes[0]}.attestation.json"): + raise SystemExit("NSIS attestation is not the updater companion") + if nsis_attestation_signatures[0] != Path( + f"{nsis_attestations[0]}.sig" + ): + raise SystemExit("NSIS attestation signature is not its companion") source_sha = os.environ["RELEASE_SHA"] - mac = json.loads((root / "macos-arm64-receipt.json").read_text(encoding="utf-8")) - if mac.get("source_sha") != source_sha or mac.get("signature_mode") != "ad-hoc": - raise SystemExit("macOS receipt is not bound to source SHA and ad-hoc signing") - mac_artifact = mac.get("artifact", {}) - dmg = next(path for path in files if path.suffix.lower() == ".dmg") - if mac_artifact.get("name") != dmg.name: - raise SystemExit("macOS receipt names a different DMG") - if mac_artifact.get("sha256") != sha256(dmg): - raise SystemExit("macOS receipt DMG checksum mismatch") - if mac_artifact.get("bytes") != dmg.stat().st_size: - raise SystemExit("macOS receipt DMG byte count mismatch") - - windows = json.loads((root / "windows-x64-receipt.json").read_text(encoding="utf-8")) - if windows.get("source_sha") != source_sha: - raise SystemExit("Windows receipt is not bound to source SHA") - installers = [path for path in files if path.suffix.lower() in {".msi", ".exe"}] - entries = windows.get("artifacts") - if not isinstance(entries, list) or len(entries) != 2: - raise SystemExit("Windows receipt must contain exactly two installer entries") - by_name = {entry.get("name"): entry for entry in entries} - if set(by_name) != {path.name for path in installers}: - raise SystemExit("Windows receipt installer names mismatch") - for installer in installers: - entry = by_name[installer.name] - if entry.get("sha256") != sha256(installer): - raise SystemExit(f"Windows receipt checksum mismatch: {installer.name}") - if entry.get("bytes") != installer.stat().st_size: - raise SystemExit(f"Windows receipt byte count mismatch: {installer.name}") + release_tag = os.environ["RELEASE_TAG"] + release_version = os.environ["RELEASE_VERSION"] + + attestation_keys = { + "schemaVersion", + "repository", + "tag", + "version", + "sourceSha", + "platform", + "assetName", + "size", + "sha256", + } + + def verify_attestation( + path: Path, + artifact: Path, + platform: str, + ) -> None: + attestation = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(attestation, dict) or set(attestation) != attestation_keys: + raise SystemExit(f"attestation fields are not exact: {path.name}") + string_fields = ( + "repository", + "tag", + "version", + "sourceSha", + "platform", + "assetName", + "sha256", + ) + if ( + type(attestation["schemaVersion"]) is not int + or type(attestation["size"]) is not int + or attestation["size"] <= 0 + or any(type(attestation[field]) is not str for field in string_fields) + ): + raise SystemExit(f"attestation field types are invalid: {path.name}") + expected = { + "schemaVersion": 1, + "repository": "appergb/OpenTake", + "tag": release_tag, + "version": release_version, + "sourceSha": source_sha, + "platform": platform, + "assetName": artifact.name, + "size": artifact.stat().st_size, + "sha256": sha256(artifact), + } + if attestation.get("sourceSha") != source_sha: + raise SystemExit(f"attestation source SHA mismatch: {path.name}") + if attestation.get("sha256") != sha256(artifact): + raise SystemExit(f"attestation payload checksum mismatch: {path.name}") + if attestation != expected: + raise SystemExit(f"attestation identity mismatch: {path.name}") + canonical = json.dumps( + expected, sort_keys=True, separators=(",", ":") + ) + "\n" + if path.read_text(encoding="utf-8") != canonical: + raise SystemExit(f"attestation is not canonical JSON: {path.name}") + + verify_attestation( + mac_attestations[0], mac_updaters[0], "darwin-aarch64" + ) + verify_attestation( + msi_attestations[0], msis[0], "windows-x86_64-msi" + ) + verify_attestation( + nsis_attestations[0], exes[0], "windows-x86_64-nsis" + ) + + def verify_receipt( + filename: str, + schema: str, + expected_artifacts: list[Path], + platform_signing_mode: str, + ) -> None: + receipt = json.loads((root / filename).read_text(encoding="utf-8")) + if receipt.get("schema") != schema: + raise SystemExit(f"release receipt schema mismatch: {filename}") + if receipt.get("repository") != "appergb/OpenTake": + raise SystemExit(f"release receipt repository mismatch: {filename}") + if receipt.get("source_sha") != source_sha: + raise SystemExit(f"release receipt source SHA mismatch: {filename}") + if receipt.get("platform_signing_mode") != platform_signing_mode: + raise SystemExit(f"platform signing claim mismatch: {filename}") + if receipt.get("updater_signature_mode") != "tauri-minisign": + raise SystemExit(f"updater signing claim mismatch: {filename}") + entries = receipt.get("artifacts") + if not isinstance(entries, list) or len(entries) != len(expected_artifacts): + raise SystemExit(f"release receipt artifact count mismatch: {filename}") + by_name = {entry.get("name"): entry for entry in entries} + if len(by_name) != len(entries): + raise SystemExit(f"release receipt contains duplicate names: {filename}") + if set(by_name) != {artifact.name for artifact in expected_artifacts}: + raise SystemExit(f"release receipt artifact names mismatch: {filename}") + for artifact in expected_artifacts: + entry = by_name[artifact.name] + if entry.get("sha256") != sha256(artifact): + raise SystemExit(f"release receipt checksum mismatch: {artifact.name}") + if entry.get("bytes") != artifact.stat().st_size: + raise SystemExit(f"release receipt byte count mismatch: {artifact.name}") + + verify_receipt( + "macos-arm64-receipt.json", + "opentake-macos-arm64-receipt-v2", + [ + dmgs[0], + mac_updaters[0], + mac_signatures[0], + mac_attestations[0], + mac_attestation_signatures[0], + ], + "ad-hoc", + ) + verify_receipt( + "windows-x64-receipt.json", + "opentake-windows-release-receipt-v2", + [ + msis[0], + exes[0], + *windows_signatures, + msi_attestations[0], + msi_attestation_signatures[0], + nsis_attestations[0], + nsis_attestation_signatures[0], + ], + "unsigned-authenticode", + ) + PY + + - name: Verify updater signatures against embedded public key + shell: bash + run: | + set -euo pipefail + mapfile -d '' mac_updaters < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz' -print0) + mapfile -d '' mac_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz.sig' -print0) + mapfile -d '' mac_attestations < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz.attestation.json' -print0) + mapfile -d '' mac_attestation_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz.attestation.json.sig' -print0) + mapfile -d '' msis < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi' -print0) + mapfile -d '' msi_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi.sig' -print0) + mapfile -d '' msi_attestations < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi.attestation.json' -print0) + mapfile -d '' msi_attestation_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi.attestation.json.sig' -print0) + mapfile -d '' exes < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe' -print0) + mapfile -d '' exe_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe.sig' -print0) + mapfile -d '' nsis_attestations < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe.attestation.json' -print0) + mapfile -d '' nsis_attestation_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe.attestation.json.sig' -print0) + test "${#mac_updaters[@]}" -eq 1 + test "${#mac_signatures[@]}" -eq 1 + test "${#mac_attestations[@]}" -eq 1 + test "${#mac_attestation_signatures[@]}" -eq 1 + test "${#msis[@]}" -eq 1 + test "${#msi_signatures[@]}" -eq 1 + test "${#msi_attestations[@]}" -eq 1 + test "${#msi_attestation_signatures[@]}" -eq 1 + test "${#exes[@]}" -eq 1 + test "${#exe_signatures[@]}" -eq 1 + test "${#nsis_attestations[@]}" -eq 1 + test "${#nsis_attestation_signatures[@]}" -eq 1 + test "${mac_signatures[0]}" = "${mac_updaters[0]}.sig" + test "${msi_signatures[0]}" = "${msis[0]}.sig" + test "${exe_signatures[0]}" = "${exes[0]}.sig" + test "${mac_attestations[0]}" = "${mac_updaters[0]}.attestation.json" + test "${mac_attestation_signatures[0]}" = "${mac_attestations[0]}.sig" + test "${msi_attestations[0]}" = "${msis[0]}.attestation.json" + test "${msi_attestation_signatures[0]}" = "${msi_attestations[0]}.sig" + test "${nsis_attestations[0]}" = "${exes[0]}.attestation.json" + test "${nsis_attestation_signatures[0]}" = "${nsis_attestations[0]}.sig" + + verification_root="$PUBLISH_ROOT/signature-verification" + mkdir -p "$verification_root" + python3 - "$verification_root" \ + "${mac_signatures[0]}" "${msi_signatures[0]}" "${exe_signatures[0]}" \ + "${mac_attestation_signatures[0]}" "${msi_attestation_signatures[0]}" \ + "${nsis_attestation_signatures[0]}" <<'PY' + import base64 + import binascii + import json + from pathlib import Path + import sys + + verification_root = Path(sys.argv[1]) + signature_sources = [Path(value) for value in sys.argv[2:]] + config = json.loads( + Path("src-tauri/tauri.conf.json").read_text(encoding="utf-8") + ) + pubkey = config["plugins"]["updater"]["pubkey"] + if not isinstance(pubkey, str) or not pubkey: + raise SystemExit("embedded updater public key is missing") + + def decode_base64(value: str, kind: str) -> bytes: + try: + return base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as error: + raise SystemExit(f"{kind} is not canonical base64") from error + + public_key = decode_base64(pubkey, "embedded updater public key") + try: + public_lines = public_key.decode("utf-8").splitlines() + except UnicodeError as error: + raise SystemExit("embedded updater public key is not UTF-8") from error + if ( + len(public_lines) != 2 + or not public_lines[0].startswith( + "untrusted comment: minisign public key: " + ) + or not public_lines[1].startswith("RW") + ): + raise SystemExit("embedded updater public key is not Minisign armor") + (verification_root / "updater.pub").write_bytes(public_key) + + decoded_names = ( + "macos.sig", + "msi.sig", + "nsis.sig", + "macos-attestation.sig", + "msi-attestation.sig", + "nsis-attestation.sig", + ) + for source, decoded_name in zip( + signature_sources, decoded_names, strict=True + ): + encoded = source.read_text(encoding="utf-8").strip() + signature = decode_base64(encoded, f"updater signature {source.name}") + try: + signature_lines = signature.decode("utf-8").splitlines() + except UnicodeError as error: + raise SystemExit( + f"updater signature is not UTF-8: {source.name}" + ) from error + if ( + len(signature_lines) != 4 + or not signature_lines[0].startswith("untrusted comment: ") + or not signature_lines[2].startswith("trusted comment: ") + ): + raise SystemExit( + f"updater signature is not Minisign armor: {source.name}" + ) + (verification_root / decoded_name).write_bytes(signature) PY + minisign -Vm "${mac_updaters[0]}" \ + -p "$verification_root/updater.pub" -x "$verification_root/macos.sig" + minisign -Vm "${msis[0]}" \ + -p "$verification_root/updater.pub" -x "$verification_root/msi.sig" + minisign -Vm "${exes[0]}" \ + -p "$verification_root/updater.pub" -x "$verification_root/nsis.sig" + minisign -Vm "${mac_attestations[0]}" \ + -p "$verification_root/updater.pub" -x "$verification_root/macos-attestation.sig" + minisign -Vm "${msi_attestations[0]}" \ + -p "$verification_root/updater.pub" -x "$verification_root/msi-attestation.sig" + minisign -Vm "${nsis_attestations[0]}" \ + -p "$verification_root/updater.pub" -x "$verification_root/nsis-attestation.sig" + + - name: Write and verify tag-specific updater manifest + shell: bash + run: | + set -euo pipefail + mapfile -d '' mac_updaters < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz' -print0) + mapfile -d '' mac_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz.sig' -print0) + mapfile -d '' mac_attestations < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz.attestation.json' -print0) + mapfile -d '' mac_attestation_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.app.tar.gz.attestation.json.sig' -print0) + mapfile -d '' msi_installers < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi' -print0) + mapfile -d '' msi_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi.sig' -print0) + mapfile -d '' msi_attestations < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi.attestation.json' -print0) + mapfile -d '' msi_attestation_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.msi.attestation.json.sig' -print0) + mapfile -d '' nsis_installers < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe' -print0) + mapfile -d '' nsis_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe.sig' -print0) + mapfile -d '' nsis_attestations < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe.attestation.json' -print0) + mapfile -d '' nsis_attestation_signatures < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name '*.exe.attestation.json.sig' -print0) + test "${#mac_updaters[@]}" -eq 1 + test "${#mac_signatures[@]}" -eq 1 + test "${#mac_attestations[@]}" -eq 1 + test "${#mac_attestation_signatures[@]}" -eq 1 + test "${#msi_installers[@]}" -eq 1 + test "${#msi_signatures[@]}" -eq 1 + test "${#msi_attestations[@]}" -eq 1 + test "${#msi_attestation_signatures[@]}" -eq 1 + test "${#nsis_installers[@]}" -eq 1 + test "${#nsis_signatures[@]}" -eq 1 + test "${#nsis_attestations[@]}" -eq 1 + test "${#nsis_attestation_signatures[@]}" -eq 1 + python3 scripts/write_updater_manifest.py \ + --repository appergb/OpenTake \ + --tag "$RELEASE_TAG" \ + --version "$RELEASE_VERSION" \ + --source-sha "$RELEASE_SHA" \ + --darwin-artifact "${mac_updaters[0]}" \ + --darwin-signature "${mac_signatures[0]}" \ + --darwin-attestation "${mac_attestations[0]}" \ + --darwin-attestation-signature "${mac_attestation_signatures[0]}" \ + --windows-msi-artifact "${msi_installers[0]}" \ + --windows-msi-signature "${msi_signatures[0]}" \ + --windows-msi-attestation "${msi_attestations[0]}" \ + --windows-msi-attestation-signature "${msi_attestation_signatures[0]}" \ + --windows-nsis-artifact "${nsis_installers[0]}" \ + --windows-nsis-signature "${nsis_signatures[0]}" \ + --windows-nsis-attestation "${nsis_attestations[0]}" \ + --windows-nsis-attestation-signature "${nsis_attestation_signatures[0]}" \ + --output "$PUBLISH_ROOT/assets/updater-$RELEASE_TAG.json" + mapfile -d '' manifests < <(find "$PUBLISH_ROOT/assets" -maxdepth 1 -type f -name 'updater-v*.json' -print0) + test "${#manifests[@]}" -eq 1 + test "${manifests[0]}" = "$PUBLISH_ROOT/assets/updater-$RELEASE_TAG.json" + - name: Create and verify SHA256SUMS shell: bash run: | @@ -767,12 +1306,12 @@ jobs: cd "$PUBLISH_ROOT/assets" mapfile -t asset_names < <(find . -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort) payload_names=("${asset_names[@]}") - test "${#payload_names[@]}" -eq 5 + test "${#payload_names[@]}" -eq 16 sha256sum "${payload_names[@]}" > SHA256SUMS - test "$(wc -l < SHA256SUMS | tr -d ' ')" -eq 5 + test "$(wc -l < SHA256SUMS | tr -d ' ')" -eq 16 sha256sum --check SHA256SUMS printf '%s\n' "${payload_names[@]}" SHA256SUMS | LC_ALL=C sort > "$PUBLISH_ROOT/expected-assets.txt" - test "$(wc -l < "$PUBLISH_ROOT/expected-assets.txt" | tr -d ' ')" -eq 6 + test "$(wc -l < "$PUBLISH_ROOT/expected-assets.txt" | tr -d ' ')" -eq 17 - name: Prepare release notes with provenance shell: bash @@ -785,7 +1324,8 @@ jobs: - Source commit: \`$RELEASE_SHA\` - GitHub Actions run: [$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) - - Signing limits: the macOS asset uses ad-hoc signing only; it is not Developer ID signed or notarized. Windows installers are not claimed to be Authenticode-signed. + - Updater trust: updater packages are signed with the dedicated Tauri updater key; the private key is supplied only from GitHub Actions secrets and is never published. + - Platform signing limits: the macOS app uses ad-hoc signing only; it is not Developer ID signed or notarized. Windows installers are not Authenticode-signed. EOF - name: Reassert exact source before draft mutation @@ -885,7 +1425,38 @@ jobs: test "$(jq -r '.action' "$PUBLISH_ROOT/draft-state.json")" = "refresh" jq -r '.asset_names[]' "$PUBLISH_ROOT/draft-state.json" | LC_ALL=C sort > "$PUBLISH_ROOT/draft-assets.txt" cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/draft-assets.txt" - jq -e '.asset_sizes | length == 6 and all(. > 0)' "$PUBLISH_ROOT/draft-state.json" >/dev/null + jq -e '.asset_sizes | length == 17 and all(. > 0)' "$PUBLISH_ROOT/draft-state.json" >/dev/null + + python3 - <<'PY' + import json + import os + from pathlib import Path + + root = Path(os.environ["PUBLISH_ROOT"]) + state = json.loads((root / "draft-state.json").read_text(encoding="utf-8")) + names = state.get("asset_names") + sizes = state.get("asset_sizes") + if not isinstance(names, list) or not isinstance(sizes, list): + raise SystemExit("draft asset name/size mapping is missing") + if len(names) != len(sizes) or len(set(names)) != len(names): + raise SystemExit("draft asset name/size mapping is malformed") + remote_sizes = dict(zip(names, sizes, strict=True)) + local_sizes = { + path.name: path.stat().st_size + for path in (root / "assets").iterdir() + if path.is_file() + } + if remote_sizes != local_sizes: + raise SystemExit("draft asset sizes do not match local payload") + PY + + mkdir "$PUBLISH_ROOT/verified-draft" + gh release download "$RELEASE_TAG" --dir "$PUBLISH_ROOT/verified-draft" + find "$PUBLISH_ROOT/verified-draft" -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort > "$PUBLISH_ROOT/verified-draft-assets.txt" + cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/verified-draft-assets.txt" + cmp "$PUBLISH_ROOT/assets/SHA256SUMS" "$PUBLISH_ROOT/verified-draft/SHA256SUMS" + cd "$PUBLISH_ROOT/verified-draft" + sha256sum --check SHA256SUMS - name: Revalidate remote tag before publication shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2911801e..28a1c84f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ We welcome contributions! Please open an [Issue](https://github.com/appergb/Open ## Development Setup ```bash -# Prerequisites: Rust >= 1.82, Node.js >= 20, pnpm, FFmpeg >= 6.0 +# Prerequisites: Rust >= 1.96, Node.js >= 20, pnpm, FFmpeg >= 6.0 cargo build cargo test cd web && pnpm install && pnpm build diff --git a/Cargo.lock b/Cargo.lock index 5382b037..48f0737a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,12 +268,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bincode" version = "1.3.3" @@ -609,7 +603,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -629,7 +623,7 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "smallvec 1.15.2", + "smallvec", "target-lexicon", ] @@ -792,7 +786,7 @@ dependencies = [ "bitflags 2.13.0", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -871,7 +865,7 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -971,7 +965,7 @@ dependencies = [ "dtoa-short", "itoa", "phf", - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -1121,16 +1115,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1568,15 +1552,6 @@ dependencies = [ "ttf-parser 0.20.0", ] -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -1584,7 +1559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -1598,12 +1573,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1914,7 +1883,7 @@ dependencies = [ "libc", "once_cell", "pin-project-lite", - "smallvec 1.15.2", + "smallvec", "thiserror 1.0.69", ] @@ -1961,7 +1930,7 @@ dependencies = [ "libc", "memchr", "once_cell", - "smallvec 1.15.2", + "smallvec", "thiserror 1.0.69", ] @@ -2206,6 +2175,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "html5ever" version = "0.38.0" @@ -2283,7 +2258,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "smallvec 1.15.2", + "smallvec", "tokio", "want", ] @@ -2322,9 +2297,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2398,7 +2375,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec 1.15.2", + "smallvec", "zerovec", ] @@ -2456,7 +2433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec 1.15.2", + "smallvec", "utf8_iter", ] @@ -2640,6 +2617,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2782,7 +2789,7 @@ checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", "euclid", - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -2967,6 +2974,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + [[package]] name = "mach2" version = "0.4.3" @@ -3073,7 +3086,7 @@ dependencies = [ "bitflags 2.13.0", "block", "core-graphics-types 0.1.3", - "foreign-types 0.5.0", + "foreign-types", "log", "objc", "paste", @@ -3101,6 +3114,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3203,27 +3222,25 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "ndarray" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", ] [[package]] name = "ndarray" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", @@ -3303,6 +3320,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3518,6 +3553,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3590,7 +3637,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", + "jni 0.21.1", "ndk 0.8.0", "ndk-context", "num-derive", @@ -3635,52 +3682,15 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentake-agent" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "anyhow", "async-trait", @@ -3715,7 +3725,7 @@ dependencies = [ [[package]] name = "opentake-core" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "opentake-domain", "opentake-ops", @@ -3729,7 +3739,7 @@ dependencies = [ [[package]] name = "opentake-domain" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "serde", "serde_json", @@ -3737,7 +3747,7 @@ dependencies = [ [[package]] name = "opentake-gen" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "anyhow", "async-trait", @@ -3755,7 +3765,7 @@ dependencies = [ [[package]] name = "opentake-media" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "anyhow", "byteorder", @@ -3767,7 +3777,7 @@ dependencies = [ "half", "image", "libc", - "ndarray", + "ndarray 0.17.2", "opentake-domain", "opentake-process-tree", "ort", @@ -3793,7 +3803,7 @@ dependencies = [ [[package]] name = "opentake-motion" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "base64 0.22.1", "hex", @@ -3811,7 +3821,7 @@ dependencies = [ [[package]] name = "opentake-ops" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "opentake-domain", "serde_json", @@ -3819,7 +3829,7 @@ dependencies = [ [[package]] name = "opentake-process-tree" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3827,7 +3837,7 @@ dependencies = [ [[package]] name = "opentake-project" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "cap-fs-ext", "cap-std", @@ -3846,7 +3856,7 @@ dependencies = [ [[package]] name = "opentake-render" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "bytemuck", "cosmic-text", @@ -3863,7 +3873,7 @@ dependencies = [ [[package]] name = "opentake-tauri" -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" dependencies = [ "axum", "base64 0.22.1", @@ -3879,6 +3889,7 @@ dependencies = [ "image", "libc", "mime_guess", + "minisign-verify", "objc2-app-kit", "objc2-foundation", "opentake-agent", @@ -3891,8 +3902,12 @@ dependencies = [ "opentake-project", "opentake-render", "percent-encoding", + "quick-xml", "reqwest 0.12.28", + "reqwest 0.13.4", + "rustls", "same-file", + "semver", "sentry", "serde", "serde_json", @@ -3902,6 +3917,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-persisted-scope", + "tauri-plugin-updater", "tempfile", "tokio", "uuid", @@ -3917,40 +3933,53 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ort" -version = "2.0.0-rc.10" +version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" dependencies = [ - "ndarray", + "ndarray 0.17.2", "ort-sys", - "smallvec 2.0.0-alpha.10", + "smallvec", "tracing", + "ureq", ] [[package]] name = "ort-sys" -version = "2.0.0-rc.10" +version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" dependencies = [ - "flate2", - "pkg-config", - "sha2", - "tar", + "hmac-sha256", + "lzma-rust2", "ureq", ] [[package]] name = "ort-tract" -version = "0.1.0+0.21" +version = "0.2.0+0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b41450290a215a579f8a723bb255a872666f98609b37fac8f57c9affcadfd78b" +checksum = "cc391a14a6e7bed0cea5b7ac2b867327a049c06ebe604015318bf7f8dd9d1d2f" dependencies = [ "ort-sys", "parking_lot", "tract-onnx", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3995,7 +4024,7 @@ dependencies = [ "cfg-if", "libc", "redox_syscall", - "smallvec 1.15.2", + "smallvec", "windows-link 0.2.1", ] @@ -4007,18 +4036,15 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.3" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] -name = "pem-rfc7468" -version = "1.0.0" +name = "pastey" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "peniko" @@ -4029,7 +4055,7 @@ dependencies = [ "color 0.2.4", "kurbo", "peniko 0.4.1", - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -4041,7 +4067,7 @@ dependencies = [ "color 0.3.3", "kurbo", "linebender_resource_handle", - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -4753,15 +4779,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4825,7 +4856,7 @@ dependencies = [ "http", "http-body", "http-body-util", - "pastey", + "pastey 0.2.3", "pin-project-lite", "rand 0.10.2", "rmcp-macros", @@ -4925,6 +4956,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4933,6 +4965,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4943,6 +4987,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4969,7 +5040,7 @@ dependencies = [ "bitflags 2.13.0", "bytemuck", "libm", - "smallvec 1.15.2", + "smallvec", "ttf-parser 0.21.1", "unicode-bidi-mirroring", "unicode-ccc", @@ -4989,6 +5060,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" +[[package]] +name = "safetensors" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5139,7 +5220,7 @@ dependencies = [ "precomputed-hash", "rustc-hash 2.1.2", "servo_arc", - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -5478,6 +5559,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -5525,12 +5622,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "smallvec" -version = "2.0.0-alpha.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" - [[package]] name = "socket2" version = "0.6.4" @@ -5616,7 +5707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" dependencies = [ "base64 0.13.1", - "nom", + "nom 7.1.3", "serde", "unicode-segmentation", ] @@ -5778,6 +5869,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5809,7 +5921,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk 0.9.0", @@ -5877,7 +5989,7 @@ dependencies = [ "heck 0.5.0", "http", "http-range", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -6047,6 +6159,39 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -6057,7 +6202,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -6080,7 +6225,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -6605,9 +6750,9 @@ dependencies = [ [[package]] name = "tract-core" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b5347639690871b124593a8c8903f1f369531498b8abaebd18eb5c58163971" +checksum = "3f0674154b96abb73f8bdade9e6b6e22fa65f4e13da8d84c0659bd7d3c4739b9" dependencies = [ "anyhow", "anymap3", @@ -6618,22 +6763,22 @@ dependencies = [ "lazy_static", "log", "maplit", - "ndarray", + "ndarray 0.16.1", "num-complex", "num-integer", "num-traits", - "paste", + "pastey 0.1.1", "rustfft", - "smallvec 1.15.2", + "smallvec", "tract-data", "tract-linalg", ] [[package]] name = "tract-data" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a3f476a1804e05708e9bc5e2d29dcab82bad531e357d3d14d7da80fbba0b6d" +checksum = "5a0972068b06792ef536df873857854c41109aefa3ad90432e09b0b6549e7523" dependencies = [ "anyhow", "downcast-rs", @@ -6642,22 +6787,24 @@ dependencies = [ "half", "itertools 0.12.1", "lazy_static", + "libm", "maplit", - "ndarray", - "nom", + "ndarray 0.16.1", + "nom 8.0.0", + "nom-language", "num-integer", "num-traits", "parking_lot", "scan_fmt", - "smallvec 1.15.2", + "smallvec", "string-interner", ] [[package]] name = "tract-hir" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dca047ba1151fe3446fb0194d4b6ddb9ae8f361337c47a267870c53605fbafb" +checksum = "fe50ad4a84553a75c2eeba7b705ff32f862d7e4bdb5892397b4560ec59d1627d" dependencies = [ "derive-new", "log", @@ -6666,9 +6813,9 @@ dependencies = [ [[package]] name = "tract-linalg" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8e0703eb53ef1bbf77050ff261675818dd5f0d6c27044c6e48ede9b845f9e0" +checksum = "a7b582f6ef2dcf32a78f043bbbb93ebc54af247c95cd5d18d5f8b36af47a273e" dependencies = [ "byteorder", "cc", @@ -6683,10 +6830,9 @@ dependencies = [ "liquid-derive", "log", "num-traits", - "paste", - "rayon", + "pastey 0.1.1", "scan_fmt", - "smallvec 1.15.2", + "smallvec", "time", "tract-data", "unicode-normalization", @@ -6695,14 +6841,19 @@ dependencies = [ [[package]] name = "tract-nnef" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cb88a4367ec2c695610223cf886f01fc1deb5c9a82c7a74b1a5d32dc0b1466" +checksum = "d4c317f94210bdfc0b81913965c7d7439d401527cc8d9bbc85fffbb59b09af5c" dependencies = [ "byteorder", "flate2", + "liquid", + "liquid-core", "log", - "nom", + "nom 8.0.0", + "nom-language", + "safetensors", + "serde_json", "tar", "tract-core", "walkdir", @@ -6710,9 +6861,9 @@ dependencies = [ [[package]] name = "tract-onnx" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5830aa672b2aa4dc98a97a36e5988eaf77b3ecee65e2601619588d2ca557008" +checksum = "30cf71aa3c8a2ca05258ee0750e428cf2d0e8a38781ccd2ab9a0900551684c2b" dependencies = [ "bytes", "derive-new", @@ -6720,7 +6871,7 @@ dependencies = [ "memmap2", "num-integer", "prost", - "smallvec 1.15.2", + "smallvec", "tract-hir", "tract-nnef", "tract-onnx-opl", @@ -6728,9 +6879,9 @@ dependencies = [ [[package]] name = "tract-onnx-opl" -version = "0.21.10" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121d3d224c806ba3d941f4bb50943ad33b59d1da5ae704d0e4e76d2808221f96" +checksum = "a34a0c0d726b7adc04e3ae956fa1e091ac6a33d4b9bc52ef678108c5445efb7d" dependencies = [ "getrandom 0.2.17", "log", @@ -6916,7 +7067,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" dependencies = [ - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -6968,15 +7119,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", - "der", "log", - "native-tls", "percent-encoding", + "rustls", "rustls-pki-types", "socks", "ureq-proto", "utf8-zero", - "webpki-root-certs", + "webpki-roots", ] [[package]] @@ -7052,12 +7202,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "velato" version = "0.5.0" @@ -7102,7 +7246,7 @@ dependencies = [ "guillotiere", "peniko 0.3.2", "skrifa 0.26.6", - "smallvec 1.15.2", + "smallvec", ] [[package]] @@ -7415,7 +7559,7 @@ dependencies = [ "parking_lot", "profiling", "raw-window-handle", - "smallvec 1.15.2", + "smallvec", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", @@ -7444,7 +7588,7 @@ dependencies = [ "profiling", "raw-window-handle", "rustc-hash 1.1.0", - "smallvec 1.15.2", + "smallvec", "thiserror 1.0.69", "wgpu-hal", "wgpu-types", @@ -7486,7 +7630,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "rustc-hash 1.1.0", - "smallvec 1.15.2", + "smallvec", "thiserror 1.0.69", "wasm-bindgen", "web-sys", @@ -7728,6 +7872,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -7755,6 +7910,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.1.0" @@ -7774,6 +7938,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -8097,7 +8270,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk 0.9.0", "objc2", diff --git a/Cargo.toml b/Cargo.toml index a5d8b32d..72ede092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "1.0.0-beta.3" +version = "1.0.0-beta.4" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/appergb/OpenTake" diff --git a/README.md b/README.md index 13ceb125..0fe8d730 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ Key files for comparison: ### Prerequisites -- **Rust** ≥ 1.82 (via [rustup](https://rustup.rs)) +- **Rust** ≥ 1.96 (via [rustup](https://rustup.rs)) - **Node.js** ≥ 20 + **pnpm** - **FFmpeg** ≥ 6.0 (`brew install ffmpeg` / `winget install ffmpeg` / `apt install ffmpeg`) @@ -303,9 +303,9 @@ cd .. cargo tauri dev ``` -> **Current Status**: `1.0.0-beta.3` candidate. The local editing, preview, +> **Current Status**: `1.0.0-beta.4` candidate. The local editing, preview, > persistence, export, Agent, Motion Canvas, and reviewed AI workflow verticals -> are implemented. See the [Beta release notes](docs/releases/1.0.0-beta.3.md) +> are implemented. See the [Beta release notes](docs/releases/1.0.0-beta.4.md) > for validation scope and platform/provider limits. The sibling directory `palmier-pro-upstream/` contains upstream Swift sources for reference during porting. @@ -320,6 +320,7 @@ The sibling directory `palmier-pro-upstream/` contains upstream Swift sources fo | `1.0.0-beta.1` | 2026-08-01 | First installable Beta: end-to-end local editor, Agent, Motion and reviewed AI workflows | | `1.0.0-beta.2` | 2026-08-08 | Hardened Beta: official Codex login, atomic timeline gestures, secure MCP and interaction polish | | `1.0.0-beta.3` | 2026-08-09 | Playback Beta: app-wide Space transport, native HEVC source preview and release-pipeline hardening | +| `1.0.0-beta.4` | 2026-08-10 | Release candidate: timing and transition persistence, export consistency, signed updater and Windows tract security upgrade | | *(planned)* `1.0.0` | TBD | Phase 10: Full release — CapCut parity + deep Agent integration | 📖 [Full Roadmap](docs/architecture/ROADMAP.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 324e2196..3224a165 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -245,7 +245,7 @@ PRIMARY-CN/ ### 前置依赖 -- **Rust** ≥ 1.82 (via [rustup](https://rustup.rs)) +- **Rust** ≥ 1.96 (via [rustup](https://rustup.rs)) - **Node.js** ≥ 20 + **pnpm** - **FFmpeg** ≥ 6.0 diff --git a/crates/opentake-media/Cargo.toml b/crates/opentake-media/Cargo.toml index 879ed494..378c6cdd 100644 --- a/crates/opentake-media/Cargo.toml +++ b/crates/opentake-media/Cargo.toml @@ -22,7 +22,7 @@ same-file = "1.0.6" half = "2" byteorder = "1" -ndarray = "0.16" +ndarray = "0.17" tracing = "0.1" unicode-normalization = "0.1" tempfile = "3" @@ -90,19 +90,19 @@ whisper-backend = ["dep:whisper-rs"] model-download = ["dep:reqwest", "dep:zip", "dep:futures-util", "dep:sha1"] [target.'cfg(not(windows))'.dependencies.ort] -version = "=2.0.0-rc.10" +version = "=2.0.0-rc.11" default-features = false -features = ["std", "ndarray", "download-binaries", "copy-dylibs"] +features = ["std", "ndarray", "download-binaries", "copy-dylibs", "tls-rustls"] optional = true [target.'cfg(windows)'.dependencies.ort] -version = "=2.0.0-rc.10" +version = "=2.0.0-rc.11" default-features = false features = ["std", "ndarray", "alternative-backend"] optional = true [target.'cfg(windows)'.dependencies.ort-tract] -version = "=0.1.0" +version = "=0.2.0" optional = true [dependencies.whisper-rs] diff --git a/crates/opentake-media/src/ort_worker/mod.rs b/crates/opentake-media/src/ort_worker/mod.rs index c0d1a766..c4139307 100644 --- a/crates/opentake-media/src/ort_worker/mod.rs +++ b/crates/opentake-media/src/ort_worker/mod.rs @@ -673,14 +673,14 @@ mod model { pub fn io_contract(&self) -> OrtIoContract { let session = self.session.lock().unwrap(); let inputs = session - .inputs + .inputs() .iter() - .map(|input| (input.name.clone(), format!("{:?}", input.input_type))) + .map(|input| (input.name().to_owned(), format!("{:?}", input.dtype()))) .collect(); let outputs = session - .outputs + .outputs() .iter() - .map(|output| (output.name.clone(), format!("{:?}", output.output_type))) + .map(|output| (output.name().to_owned(), format!("{:?}", output.dtype()))) .collect(); (inputs, outputs) } diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 9e69854f..01e14b10 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -810,8 +810,9 @@ pub enum EditCommand { /// Overwrite-place clips (clears each destination range first). AddClips { entries: Vec }, /// Overwrite-place clips on fresh shared tracks chosen by media type. - /// Visual entries share one new visual track; audio entries share one new - /// audio track. Track insertion and placement commit as one transaction. + /// Visual entries share one new topmost visual track (index 0); audio + /// entries share one new trailing audio track. Track insertion and + /// placement commit as one transaction. AddClipsAutoTrack { entries: Vec }, /// Place each entry on its own fresh compatible track in one transaction. /// Used for aligned stems that intentionally overlap in time. @@ -2654,10 +2655,11 @@ fn add_clips_auto_track( action_name, |added| format!("Added {} clip(s): {}", added.len(), added.join(", ")), |st| { - let visual_track_index = has_visual.then(|| { - let at = st.timeline.tracks.len(); - ops::insert_track(&mut st.timeline, at, ClipType::Video, ids) - }); + // Track 0 is the topmost visual layer. Generated motion and other + // auto-placed overlays must be visible above existing footage, + // matching AddTextsAutoTrack and the editor's media-drop behavior. + let visual_track_index = + has_visual.then(|| ops::insert_track(&mut st.timeline, 0, ClipType::Video, ids)); let audio_track_index = has_audio.then(|| { let at = st.timeline.tracks.len(); ops::insert_track(&mut st.timeline, at, ClipType::Audio, ids) diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 37ffcd04..dc126a22 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -708,6 +708,31 @@ fn add_clips_auto_track_mixed_audio_video_is_one_undoable_transaction() { assert!(st.timeline.tracks.is_empty()); } +#[test] +fn add_clips_auto_track_places_visual_media_on_a_fresh_top_track() { + let mut st = state(vec![ + video_track("existing-video", true, vec![clip("base", 0, 60)]), + audio_track("existing-audio", true, vec![clip("sound", 0, 60)]), + ]); + let g = SeqIdGen::new("n-"); + + apply( + &mut st, + EditCommand::AddClipsAutoTrack { + entries: vec![entry(0, ClipType::Video, 0, 30)], + }, + &g, + ) + .unwrap(); + + assert_eq!(st.timeline.tracks.len(), 3); + assert_eq!(st.timeline.tracks[0].kind, ClipType::Video); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "m"); + assert_eq!(st.timeline.tracks[1].id, "existing-video"); + assert_eq!(st.timeline.tracks[1].clips[0].id, "base"); + assert_eq!(st.timeline.tracks[2].id, "existing-audio"); +} + // ---- split + keyframes ---------------------------------------------------- #[test] diff --git a/crates/opentake-project/tests/roundtrip.rs b/crates/opentake-project/tests/roundtrip.rs index 4154f946..996c5145 100644 --- a/crates/opentake-project/tests/roundtrip.rs +++ b/crates/opentake-project/tests/roundtrip.rs @@ -6,7 +6,8 @@ mod common; use std::path::Path; use opentake_domain::{ - Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, Transition, + TransitionKind, }; use opentake_project::{GenerationLog, GenerationLogEntry, Project}; @@ -127,6 +128,38 @@ fn save_then_open_is_lossless() { assert_eq!(thumb, b"\xff\xd8\xff\xe0JPEGDATA"); } +#[test] +fn transition_survives_project_save_and_reopen() { + let tmp = TempDir::new("transition-roundtrip"); + let bundle = tmp.child("Transition.opentake"); + let mut project = sample_project(&bundle); + project.timeline.tracks[0].clips[0].transition_out = Some(Transition { + from_clip_id: "clip-1".into(), + to_clip_id: "clip-2".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 12, + }); + + project.save().expect("save transition project"); + let project_json = + std::fs::read_to_string(bundle.join("project.json")).expect("read persisted timeline"); + assert!(project_json.contains("\"transitionOut\"")); + assert!(project_json.contains("\"fromClipId\": \"clip-1\"")); + assert!(project_json.contains("\"toClipId\": \"clip-2\"")); + + let reopened = Project::open(&bundle).expect("reopen transition project"); + assert_eq!( + reopened.timeline.tracks[0].clips[0] + .transition_out + .as_ref() + .expect("transition restored"), + project.timeline.tracks[0].clips[0] + .transition_out + .as_ref() + .unwrap() + ); +} + #[test] fn timeline_json_uses_upstream_camel_case_keys() { let tmp = TempDir::new("keys"); diff --git a/docs/INDEX.md b/docs/INDEX.md index 939d89bd..678e1ab5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -63,5 +63,5 @@ docs/ | [CHANGELOG.md](../CHANGELOG.md) | 变更历史 | | [CONTRIBUTING.md](../CONTRIBUTING.md) | 贡献指南 | | [Specs Index](specs/INDEX.md) | 已批准/历史规格目录 | -| [Beta 发布与验证](releases/1.0.0-beta.2.md) · [功能验证记录](audit/2026-08-02/beta-functional-verification.md) | ★ 当前 Beta 2 范围、发布门槛与逐项执行证据 | +| [Beta 发布与验证](releases/1.0.0-beta.4.md) · [最终模块验收](audit/2026-08-10/final-module-validation.md) | ★ 当前 Beta 4 范围、发布门槛与逐项执行证据 | | [Superpowers Recovery](superpowers/specs/2026-07-08-opentake-recovery-integration-design.md) | 历史恢复集成设计与计划入口(Beta 2 已收口) | diff --git a/docs/architecture/INDEX.md b/docs/architecture/INDEX.md index b729aa49..1b90bc26 100644 --- a/docs/architecture/INDEX.md +++ b/docs/architecture/INDEX.md @@ -12,6 +12,7 @@ |---|---| | [ARCHITECTURE.md](ARCHITECTURE.md) | 总体架构:分层 crate、数据流、单一真理状态 + 命令事务、渲染管线、Agent 集成 | | [ADVANCED-FEATURES.md](ADVANCED-FEATURES.md) | 进阶能力设计:wgpu 着色器、AI 推理、FFmpeg 音频工程、跨平台特性 | +| [UPDATER.md](UPDATER.md) | GitHub Beta 发现、Tauri 签名验证、一键安装、安全保存/任务 gate 与发布密钥契约 | ## 路线与移植 diff --git a/docs/architecture/PLAYBACK-ENGINE.md b/docs/architecture/PLAYBACK-ENGINE.md index 320bdc5e..0ae49639 100644 --- a/docs/architecture/PLAYBACK-ENGINE.md +++ b/docs/architecture/PLAYBACK-ENGINE.md @@ -1,6 +1,6 @@ # Playback engine architecture -> Current reviewed state: 2026-08-01. The original 2026-07-04 default-off +> Current reviewed state: 2026-08-10. The original 2026-07-04 default-off > MJPEG design is historical; this document records the Wave 1A implementation. ## Capability route is the sole authority @@ -77,6 +77,21 @@ revision during installation. Pause, seek, stop, events, and frame requests are accepted only for the exact identity; an exact paused revision may retain and resume its session. +Pause is an immediate publication boundary, not a render-thread barrier. The +frontend freezes the authoritative playhead before awaiting IPC and rejects +late native events/image loads. The backend closes `PublicationGate` before +audio control, enqueues the pause without waiting for an in-flight decode, and +discards that render before publication. Resume repositions the retained engine +before reopening audible output; only the exact retained session can reopen its +gate. WebKit's decoded frame may advance a pause by a fractional frame, but it +can never rewrite the playhead backwards when decoding lags. + +The cpal sample position remains the preferred master clock. Startup and resume +still require sustained callback liveness; additionally, a callback that stops +advancing during playback switches after 150 ms to a monotonic wall-clock +continuation. Recovery never rewinds the timeline, while an explicit transport +seek remains authoritative and may move backward. + Audio preparation has one persistent admitted worker and checked memory bounds; teardown uses one persistent reaper with at most two outstanding jobs. Queue, panic, cancellation, timeout, and project-boundary paths release capacity and @@ -106,7 +121,10 @@ composite releases the terminal Rust frame only after the replacement is loaded. Paused/scrub composite requests are latest-only: one request may be in flight and only the newest pending frame is retained. That paused composite stays painted during native startup and is removed only after the first live slot -loads. Stale identity, sequence, load, cleanup, and paint callbacks are ignored. +loads. Pausing cancels the pending decoder slot while preserving the promoted +canvas, so even a pre-pause image that finishes during a fast pause/resume cycle +cannot paint afterward. Stale identity, sequence, load, cleanup, and paint +callbacks are ignored. ## Project/source identity and prewarm/cache diff --git a/docs/architecture/UPDATER.md b/docs/architecture/UPDATER.md new file mode 100644 index 00000000..2e78a9e5 --- /dev/null +++ b/docs/architecture/UPDATER.md @@ -0,0 +1,61 @@ +# OpenTake 自动更新架构 + +OpenTake 使用 Tauri v2 官方 updater 完成「检查 → 下载 → 签名验证 → 安装 → 安全重启」。更新源只允许独立仓库 `appergb/OpenTake`,不复用 OpenLess 的 URL 或签名密钥。 + +## 发布与发现契约 + +- Rust 首先请求 `https://api.github.com/repos/appergb/OpenTake/releases?per_page=30`,不使用 `releases/latest`,因此 GitHub prerelease Beta 也能被发现。匿名 REST 因共享出口额度返回 403/429 时,仅回退到 GitHub 官方固定 feed `https://github.com/appergb/OpenTake/releases.atom`;不抓取 release HTML。 +- Atom 响应与 REST 一样限制为 1 MiB,最多解析 100 个 entry;`feed` 必须是文档唯一根节点,根外 entry/结构一律拒绝。Feed id/self link 必须精确对应 `appergb/OpenTake`,entry 只接受无凭据、无端口/查询/片段的 `https://github.com/appergb/OpenTake/releases/tag/v`;Atom HTML notes 不进入 UI。 +- release tag 必须是规范的 `v`。草稿、GitHub prerelease 标记与 SemVer 不一致的 release、同版本、降级和 stable → prerelease 均被忽略。 +- 每个 tag 必须包含专属清单 `updater-${tag}.json`,例如 `updater-v1.0.0-beta.4.json`。 +- 清单和安装包首跳 URL 固定在 `https://github.com/appergb/OpenTake/releases/download/${tag}/...`。重定向仅允许 HTTPS 的 GitHub release CDN 主机。 +- 平台键由发布流程生成:`darwin-aarch64`、`windows-x86_64-msi`、`windows-x86_64-nsis`;不提供会跨安装器回退的通用 Windows 键。每个平台/安装器都发布独立的包签名与签名 attestation。 + +普通开发/QA 构建的 `bundle.createUpdaterArtifacts` 固定为 `false`,不需要签名私钥。发布 workflow 仅在已注入 `TAURI_SIGNING_PRIVATE_KEY` 与非空 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 时,通过 Tauri CLI `--config` overlay 临时开启 updater artifact 生成。 + +## 运行时与资源生命周期 + +`src-tauri/src/updater.rs` 为唯一网络与安装边界: + +1. 以 15 秒超时、禁止重定向、1 MiB 响应上限读取固定 GitHub API;仅在 REST 403/429 时读取上述固定 Atom feed。 +2. 在本地完成 SemVer/asset allowlist 选择;没有更高版本时不构造、更不会请求 manifest。 +3. 对选定 tag 创建单 endpoint updater,以 20 秒超时检查签名清单。 +4. 将 Tauri `Update` 保存为 Webview Resource/RID;协调器只允许一个 check/pending/install。 +5. 用户取消时关闭准确 RID;安装开始后取走 RID,任何失败都不能复用旧资源。 +6. Rust 以有界流下载包,严格比对已签名 attestation 中的大小与 SHA-256,并用嵌入公钥复验包的 Minisign;验证后只把已验证字节交给插件安装。前端只接收进度事件,不接触安装包路径。 + +Debug Rust 构建直接返回“无更新”,不联网。前端只在 Tauri 环境启动调度:启动后延迟 4 秒静默检查,之后每 60 分钟检查;卸载时释放 timeout/interval。后台无更新或网络错误不弹窗,手动检查会显示明确结果。 + +## 代理语义 + +- 发现用的 `reqwest 0.12` 与 manifest/attestation/package 用的 `reqwest 0.13` 均显式开启 `system-proxy`,不依赖跨主版本 feature 偶然合并。 +- HTTPS 的优先级为 `HTTPS_PROXY` 大写 → 小写 → macOS/Windows 系统 HTTPS 设置 → `ALL_PROXY` 大写 → 小写。`NO_PROXY` 大写优先小写,保留 reqwest 的域名、IP/CIDR 与 `*` 语义。 +- 环境或 updater 显式代理 URL 只允许 `http://` / `https://` 且必须有 host;拒绝 userinfo 凭据、路径、query 与 fragment。校验失败只返回固定错误,不回显原始 URL 或凭据。 +- 所有远程更新 client 使用明确标识 `OpenTake-Updater` 的固定 User-Agent;该 header 同时出现在 HTTPS CONNECT 握手中,不携带账号、token 或设备信息。发现请求只对连接层错误按短间隔重试,并受 20 秒总时限约束;HTTP 状态、响应类型与内容校验失败不重试。 +- macOS 通过 SystemConfiguration/SCDynamicStore 读取手动 HTTP/HTTPS 代理,不启动 `scutil` 子进程;Windows 读取系统 Internet Settings;Linux 使用上述环境变量。PAC、SOCKS、macOS ExceptionsList 与系统代理凭据不会被自动导入。 +- 本地 tokenized manifest verifier 始终显式 `.no_proxy()`,所以 `127.0.0.1` 校验不会被环境或系统代理拦截。所有远程 URL allowlist、HTTPS 和 redirect 上限保持不变。 + +## 安装前 fail-closed gate + +安装前必须原子获得全局 `InstallAdmissionGate` 的 install lease,并取得 export 独占 lease;两者一直持有到安装及返回平台的后置保存结束,以避免 TOCTOU。所有会修改项目、manifest、全局持久化状态或在异步完成后提交结果的命令,都必须在真实工作生命周期内持有同一 gate 的 activity lease;这包括生成、动效、Agent turn、字幕/媒体分析、模型与样例物化、项目/媒体/资源库/账号/密钥写入及其后台 worker。任一 activity 或 export 尚未结束时,本次安装 fail-closed,要求任务停止后重试;安装已取得 lease 后,新写入和新长任务会在 Rust 命令边界被拒绝。播放会在项目切换边界停止。随后按最新 current project epoch/path 保存工程;保存失败不调用安装器。 + +下载、安装、重启状态下,前端 capture keydown 会阻断编辑器全局快捷键;原生菜单同时禁用自定义 action/check 并在命令边界再次拒绝 stale accelerator。Tauri 的 predefined Quit 没有 `setEnabled` API,因此 Rust 在 updater coordinator 的 Installing 生命周期内拒绝用户 `ExitRequested`,但放行 updater 自己的 programmatic restart。 + +Windows 官方 updater 在 `Update::install` 内启动安装器后直接退出进程,因此跨平台必需的保存屏障紧邻 install 之前。macOS/Linux 的 install 返回后会再读取一次 fresh epoch/path 并保存,后置保存失败则明确返回错误、不发送 restarting 事件且不重启。这里不假设 Windows 能执行安装后的代码。 + +## UI 入口 + +- App 启动后台检查(无更新/错误均静默)。 +- 原生 App 菜单“检查更新…”执行手动检查。 +- 设置 → 关于提供手动检查按钮。 +- 可用、下载、安装、重启和错误状态集中在 `updateStore`;发现更新后必须由用户点击“安装并重启”。 +- 错误页固定提供 [GitHub Releases](https://github.com/appergb/OpenTake/releases) 手动下载兜底。 + +## 签名材料 + +- 嵌入的 OpenTake 专属公钥:`dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU5QjFEQjYyQjk1OUVCClJXVHJXYmxpMjdIcEFLNml0MEU3cUJ2WXNjKzdHZ1luSFVyczZPSVArWGtIZUFjbzVYMzYrNUh1Cg==` +- 本机公钥:`~/.tauri/opentake-updater.key.pub`(0600) +- 本机加密私钥:`~/.tauri/opentake-updater.key`(0600,绝不提交) +- 私钥口令仅保存在 macOS Keychain service `OpenTake Updater Signing Key`;文档、代码、日志均不保存或输出口令。 + +更换密钥必须同时更新本机/CI 签名材料与 `src-tauri/tauri.conf.json` 公钥;不得以 placeholder、空公钥或关闭签名验证过渡。 diff --git a/docs/audit/2026-08-10/final-module-validation.md b/docs/audit/2026-08-10/final-module-validation.md new file mode 100644 index 00000000..faa57b5f --- /dev/null +++ b/docs/audit/2026-08-10/final-module-validation.md @@ -0,0 +1,77 @@ +# OpenTake 1.0.0-beta.4 最终模块验收 + +日期:2026-08-10(Asia/Shanghai) + +对应发布范围见 [Beta 4 发布说明](../../releases/1.0.0-beta.4.md)。 + +## 范围与隔离 + +- 验收工作树:`OpenTake-generation`;当时的功能冻结快照后续由 + `release/v1.0.0-beta.4` 承载发布元数据。 +- GUI 仅使用隔离包 `OpenTake QA.app`(bundle id `com.opentake.desktop.qa`)和 `/tmp` 工程副本;没有替换、退出或修改 `/Applications/OpenTake.app`。 +- QA 工程:`/tmp/opentake-gui-qa.wTAGrR/final-interaction.opentake`、`playback-av.opentake`、`export-qa.opentake`。 +- 未执行付费 AI 请求、登录/登出或 Keychain 写入;AI/Account 的真实凭据页面不做 Computer Use,避免访问生产用户共享的 Keychain service。 +- GUI 验收阶段未 commit、push、创建/移动 tag 或发布 Release;发布准备阶段仅按 + workflow 约定配置了两个 updater 签名 secret 名称,未记录或输出私钥与口令。 + +## 自动化门禁 + +冻结快照的实际结果: + +- Web:142 个文件、1210 个测试全部通过;`pnpm build` 通过。 +- Rust workspace:`cargo test --workspace --no-fail-fast --locked` exit 0;Tauri lib 607/607、播放 integration 7/7、transport integration 6/6 均通过。 +- `cargo clippy --workspace --all-targets -- -D warnings`、`cargo fmt --all -- --check`、`git diff --check` 全部通过。 +- Windows locked dependency graph 的最高 `rust-version` 为 `kstring 2.0.4` 声明的 1.96;公开 + README 与 CONTRIBUTING 的构建前置因此统一为 Rust 1.96,未继续声称 1.82/1.88 兼容。 +- Updater:35/35;安装 admission、RID 恢复、慢请求 deadline、并发 dismiss/check、系统代理、REST rate-limit Atom 回退、签名/attestation/下载上限均有回归。 +- Release 合同:release 57、attestation 5、manifest 12、Windows validator 85 全部通过;C1B、strict YAML、actionlint 与 exact 17-asset/6-signature 合同通过。 +- 9 个 ignored probe 均是显式真机/外部素材门:2 个媒体、3 个导出、4 个播放 probe;没有把 ignored 当作通过。 + +## Computer Use 真机结果 + +### 播放与画面 + +- 在 splitter、Timeline、Agent 面板等非文本区域按 Space 均能播放/暂停;搜索输入框中的 Space 只输入空格,播放头保持不变。 +- 冷启动播放 1.1 秒推进至 33 帧;暂停落在 36 帧,450 ms 后仍为 36。 +- 同一原生会话快速播放/暂停三轮分别停在 41、47、52 帧;每轮 400 ms 后完全不动,没有延迟暂停或回抽。 +- 最终重建包的额外快检从 0 帧启动后推进到 16 帧;第二次 Space 后立即停在 16,500 ms 后仍为 16。 +- 跳到结尾 1040 帧后按 Space,从 0 重新播放并停在第 9 帧;400 ms 后仍为 9。 +- 预览画面实际可见、内容正确,未观察到突然黑屏、绿色底边或解码异常。 + +### 转场与持久化 + +- 真实 AX 点击第二片段 → 转场页 → 交叉溶解时,cut pair 保持,卡片变为 `Value: on`,不再因 PanelShell focus 清空选择。 +- 从第 90 帧播放跨过第 100 帧切点,在 115 帧暂停,500 ms 后仍为 115;画面正确。 +- 保存、完全退出 QA、重启并重开后,UI 仍显示交叉溶解和移除按钮;`project.json` 的 `transitionOut.kind` 为 `crossDissolve`。 + +### Home、素材库、设置、布局与 Agent + +- Home recent 能保存并重开 4 个隔离工程。 +- 素材库的全部/视频等分类、搜索、最近收藏/最早收藏/按类型排序均可操作并能恢复初值。 +- 设置的通用、外观、导入、MCP 说明、快捷键、存储、关于逐页打开;快捷键页显示“播放/暂停 — Space”与 `GPL-3.0`。 + 该隔离 QA 包在 Beta 4 元数据迁移前构建,关于页当时显示 `1.0.0-beta.3`;Beta 4 + 的 `1.0.0-beta.4` 身份由 Cargo/Tauri/Web/WiX 合同与 exact-tag 发布构建验证,不借用旧包证明。 +- 默认/竖屏布局可切换,Media/Preview/Inspector/Timeline 均保留;最终恢复默认布局。 +- Agent 面板可开关,对话/动效入口存在,空输入时发送按钮禁用;Agent 面板背景聚焦下 Space 播放并立即暂停。 + +### 导出 + +- H.264 导出类型显示 `视频 (.mp4)`;切换 ProRes 后显示 `视频 (.mov)`。 +- 空时间线导出命令边界、字幕/交换格式和真实编码路径由 Web/Rust integration 与既有导出 probe 覆盖;本轮没有重复生成大文件。 + +### 自动更新 + +- App 启动后的后台检查保持静默;原生 App 菜单“检查更新…”和设置 → 关于“检查更新”均启用。 +- 手动检查的错误态可恢复,固定“GitHub Releases 手动下载”按钮实际通过系统浏览器打开 `https://github.com/appergb/OpenTake/releases`;没有任意 URL 参数。 +- 当前共享出口的匿名 GitHub REST API 已触发 rate limit,macOS 同时通过 `127.0.0.1:1082` 系统代理联网。最终候选在这两个真实条件下已由打包 App 返回“OpenTake 已是最新版本”;没有把网络错误当作成功。 +- 验收时 QA 包位于 `target/aarch64-apple-darwin/release/bundle/macos/OpenTake QA.app`,实际复验的 + 可执行文件 SHA-256 为 `34c1de53e29e04d8756ed1564d334c694fcc8adc54065b4341562e589d40b759`; + 后续为释放构建空间已清理该可重建的 `target/` 产物。 +- 仓库已配置两个必需的签名 secret,但仍没有较新的、由同一 OpenTake updater key 签名的 + N+1 Release,因此本轮不能执行真实 N→N+1 安装;不据此宣称安装 E2E 已通过。 + +## 已知外部边界 + +- macOS QA 包为 ad-hoc 签名,`codesign --verify --deep --strict` 通过,但没有 Developer ID 公证。 +- Windows WebView2、MSI/NSIS 原地升级和原生音频设备需由 exact-SHA Windows CI/实机最终验证;macOS 结果不能替代。 +- Windows `ort-tract` 链已从 `tract-* 0.21.10` 升到 `0.22.3`,`cargo audit --no-fetch --stale --target-os windows --target-arch x86_64` exit 0,RUSTSEC-2026-0217 已消失。macOS 主机上的 Windows 交叉编译仍被宿主缺少 MSVC C/C++ 头文件与 `lib.exe` 挡在第三方 native build,需 exact-SHA Windows CI 完成最终平台编译门禁。 diff --git a/docs/releases/1.0.0-beta.4.md b/docs/releases/1.0.0-beta.4.md new file mode 100644 index 00000000..51534f34 --- /dev/null +++ b/docs/releases/1.0.0-beta.4.md @@ -0,0 +1,65 @@ +# OpenTake 1.0.0-beta.4 + +发布日期:2026-08-10(Asia/Shanghai) + +## Beta 目标 + +在 Beta 3 已发布的播放候选之上,冻结可验证的编辑、导出与更新体验:时间线播放必须稳定地 +按帧推进和暂停,源素材预览不能受 WebView 编解码差异影响,转场与工程重开保持一致;更新候选 +则必须从固定仓库、精确 tag 和签名资产中发现、校验并安装。 + +## 主要变化 + +- 播放时钟与原生会话统一以整数帧推进;冷启动、暂停、结尾重播和连续播放/暂停不再出现延迟 + 停止、回抽或继续推进。Space 在素材、预览、Inspector、Timeline 与 Agent 等非文本区域统一 + 触发播放/暂停,而输入与可编辑文本仍保留普通空格输入。 +- 单素材视频优先使用 Rust/FFmpeg 原生流式源预览,并将当前会话、暂停 seek、切换和终止状态 + 绑定到同一控制面;WebView 只在原生能力不可用时回退。HEVC Main 与 Main 10/10-bit 素材 + 因而不依赖 WebKit 的 profile 支持,不把黑屏、绿色底边或解码异常伪装为成功。 +- 相邻片段的交叉溶解会跨切点正确播放,保存、完全退出并重开工程后仍保留在 `project.json`。 + 导出对话框随编码格式给出一致的容器提示:H.264 为 `.mp4`,ProRes 为 `.mov`;空时间线、 + 字幕/交换格式与实际编码路径继续受 Web/Rust 集成合同覆盖。 +- GitHub prerelease 更新路径固定在 `appergb/OpenTake`:只接受 HTTPS、严格递增的 SemVer、 + 精确 tag manifest、Tauri/Minisign 包签名与签名 attestation;拒绝其他仓库、draft、HTTP、 + 同版本、降级和带 build metadata 的候选。Windows MSI 与 NSIS 使用各自的更新入口,避免跨 + 安装器升级。 +- 检查更新显式支持系统代理;匿名 GitHub REST 被 403/429 限流时,只回退固定的官方 + `releases.atom` feed。所有响应仍有大小、结构和 URL allowlist 限制;本地 tokenized manifest + verifier 强制绕过代理。 +- Motion 路径只在 headless Chromium 与 FFmpeg 都可用时发布,保持受限 HTML/模板渲染、取消和 + 原子导入边界。Windows `ort-tract` 链升级到 `tract-* 0.22.3`,移除 + `RUSTSEC-2026-0217` 所涉旧依赖。 + +## GitHub 自动发布流程 + +`.github/workflows/release.yml` 只接受 `v1.0.0-beta.4` 这类现有 `v` tag;tag 必须指向 +远端 `main` 的当前 HEAD,workflow 不创建、移动或复用 tag。Cargo、Tauri 与 Web 版本均为 +`1.0.0-beta.4`,Windows WiX 安装器版本为 `1.0.0.4`,并由独立发布合约 fail closed 校验。 + +1. validate job 解析 immutable source SHA,验证版本、WiX、本文档、prerelease 语义和洁净 + checkout;质量门禁执行依赖锁定安装、audit、格式、clippy、workspace/Web 测试及工作流合同。 +2. macOS ARM64 和 Windows x64 都要求非空 `TAURI_SIGNING_PRIVATE_KEY` 与 + `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`。构建产生平台安装包、Tauri updater package、每个 + companion signature 与签名 attestation;macOS app 仍是 ad-hoc 签名,Windows 安装器仍不是 + Authenticode 签名。 +3. publish 从 exact SHA 的配置读取内置 updater 公钥,用独立 Minisign 工具验证 package 与 + attestation;manifest 将版本、tag、source SHA、平台、资产名称、大小和 SHA-256 绑定。只有 + draft 的十七项精确资产上传、下载回读、名称/大小、签名和 `SHA256SUMS` 全部相符后才公开。 + +Updater 私钥不写入 checkout、receipt、manifest、日志或发布 artifact;缺少任一 secret 时流程 +失败,绝不退化为未签名 updater。 + +## 验收与外部边界 + +- 自动化合同覆盖 release、attestation、manifest、Windows workflow、严格 YAML 与 actionlint; + GUI 验收记录见 [最终模块验收](../audit/2026-08-10/final-module-validation.md)。 +- 真实 N→N+1 安装仍要求仓库同时配置发布签名 secrets 和一个更高、由同一 updater key 签名的 + Release;没有满足这些前置条件时,不把发现/校验测试表述为安装 E2E。 +- Windows WebView2、MSI/NSIS 原地升级和原生音频设备仍由 exact-SHA Windows CI/实机验证; + macOS 结果不能替代 Windows 验证。 + +## 回滚 + +- Beta 3 的 `v1.0.0-beta.3` tag 与发布资产保持不可变,作为回滚候选;不得移动或重用历史 tag。 +- 若 Beta 4 公开后发现回归,保留 Beta 4 审计记录,回退到上一份已验证发布;修复后仅以更高的新 + prerelease tag 重新通过完整门禁。 diff --git a/docs/superpowers/plans/2026-08-10-opentake-beta4-release.md b/docs/superpowers/plans/2026-08-10-opentake-beta4-release.md new file mode 100644 index 00000000..a892d39a --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-opentake-beta4-release.md @@ -0,0 +1,129 @@ +# OpenTake 1.0.0-beta.4 Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish the tested OpenTake changes as the immutable GitHub prerelease `v1.0.0-beta.4`, including signed updater artifacts for macOS ARM64 and Windows x64. + +**Architecture:** A release branch carries the already-reviewed product changes plus a TDD version-contract migration from Beta 3 to Beta 4. The branch is merged through GitHub CI into `main`; only the resulting remote `main` SHA may receive the annotated Beta 4 tag. The tag-triggered workflow builds, signs, attests, uploads, re-downloads, and verifies all seventeen assets before publishing. + +**Tech Stack:** Rust workspace, Tauri 2, React/TypeScript/Vite, Python release validators, GitHub Actions, Minisign/Tauri updater, GitHub CLI. + +## Global Constraints + +- Release version is exactly `1.0.0-beta.4`; tag is exactly `v1.0.0-beta.4`; Windows WiX version is exactly `1.0.0.4`. +- Never move, delete, or reuse an existing release tag; the Beta 4 tag must point to the then-current remote `main` HEAD. +- Do not commit files below the three untracked `docs/audit/2026-08-07/*` asset trees, including videos, screenshots, logs, captions, or `.opentake-lock`. +- Commit the final 2026-08-10 Markdown audit, updater sources/tests/docs, all reviewed tracked product changes, and this plan. +- The workflow must fail closed unless both `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` exist as GitHub Actions secrets. +- Preserve the exact updater trust boundary: fixed `appergb/OpenTake`, HTTPS allowlist, signed attestation, package Minisign, exact SHA/size, bounded downloads, installer-specific Windows platform keys, and seventeen exact assets. +- Beta 4 remains a prerelease with macOS ad-hoc signing and no Windows Authenticode; do not claim notarization or platform publisher signing. +- No force-push, no `git add -A`, no release publication before all required CI checks and the merged-main CI are green. + +--- + +### Task 1: Migrate the release contract to Beta 4 + +**Files:** +- Modify: `scripts/test_check_release_workflow.py` +- Modify: `.github/workflows/release.yml` +- Modify: `scripts/check_release_workflow.py` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `web/package.json` +- Modify: `src-tauri/tauri.conf.json` +- Create: `docs/releases/1.0.0-beta.4.md` +- Modify: `docs/releases/1.0.0-beta.3.md` +- Modify: `README.md` +- Modify: `docs/INDEX.md` +- Modify: `docs/audit/2026-08-10/final-module-validation.md` + +**Interfaces:** +- Produces one repository-wide version identity: Cargo/Tauri/Web `1.0.0-beta.4`, WiX `1.0.0.4`, release note `docs/releases/1.0.0-beta.4.md`. +- Preserves generic updater unit fixtures that intentionally exercise historical versions; only current-release repository fixtures and contracts move to Beta 4. + +- [ ] **Step 1: Write the failing release metadata test** + + Change the repository metadata fixture in `scripts/test_check_release_workflow.py` to expect Cargo/Tauri/Web `1.0.0-beta.4`, WiX `1.0.0.4`, and `docs/releases/1.0.0-beta.4.md`. The mutation caught is a tagged candidate whose product metadata or installer identity remains Beta 3. + +- [ ] **Step 2: Verify RED** + + Run `python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py'`. Expected: failure from the still-Beta-3 production validator/workflow metadata, not a syntax/import error. + +- [ ] **Step 3: Apply the minimal Beta 4 identity** + + Update the three package versions, WiX version, workflow literals, validator current-release literals, release-note path, and approved validate-step/job digests computed from the final YAML. Regenerate `Cargo.lock` with Cargo rather than hand-editing package entries. + +- [ ] **Step 4: Move unreleased notes to Beta 4 without rewriting Beta 3 history** + + Restore `docs/releases/1.0.0-beta.3.md` to its tagged historical content and create `docs/releases/1.0.0-beta.4.md` covering playback timing, native source preview, Space transport, transition persistence, export consistency, signed updater, proxy/Atom fallback, Motion/Chromium fixes, and the `tract` security upgrade. Update README/current docs/audit headings and links to Beta 4. + +- [ ] **Step 5: Verify GREEN** + + Run the release validator/test suite, updater attestation tests, updater manifest tests, Windows workflow contract suite, strict YAML parser, and actionlint. All must exit 0. + +### Task 2: Freeze, review, and commit the Beta 4 candidate + +**Files:** +- Stage all 70 reviewed tracked product/test/release/doc changes. +- Stage only the reviewed new updater files, `docs/architecture/UPDATER.md`, `docs/audit/2026-08-10/final-module-validation.md`, the Beta 4 note, and this plan. +- Exclude every untracked `docs/audit/2026-08-07/*` runtime/evidence asset. + +- [ ] **Step 1: Run fresh full gates** + + Run `cargo test --workspace --no-fail-fast`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all -- --check`, `pnpm -C web test`, `pnpm -C web build`, release contract tests, `git diff --check`, and the Windows-target security audit. + +- [ ] **Step 2: Obtain independent review** + + Dispatch a code reviewer and a security reviewer over the final unstaged diff. Resolve all P0-P2 findings and re-run affected gates. + +- [ ] **Step 3: Configure updater signing secrets without exposing values** + + Verify the local private/public key pair and embedded public key, then stream the private key file and Keychain password directly into `gh secret set` for `appergb/OpenTake`. Confirm only the two secret names appear in `gh secret list`. + +- [ ] **Step 4: Stage explicitly and inspect** + + Use explicit paths and `git add -u` for reviewed tracked files; never use `git add -A`. Inspect `git diff --cached --stat`, secret-scan the staged diff, and verify excluded audit assets remain unstaged. + +- [ ] **Step 5: Commit** + + Commit the candidate as `chore(release): prepare v1.0.0-beta.4` on `release/v1.0.0-beta.4`. + +### Task 3: Merge the release candidate through GitHub CI + +- [ ] **Step 1: Push the release branch** + + Fetch `origin/main` and tags, ensure the release branch is based on the current remote main, then push with tracking. Never force-push. + +- [ ] **Step 2: Open a ready-for-review PR** + + Create a PR from `release/v1.0.0-beta.4` to `main` with the Beta 4 scope, verification evidence, release limitations, and rollback notes. + +- [ ] **Step 3: Wait for required checks** + + Watch all nine branch-protection checks to terminal completion. Any failure stops the merge and enters diagnosis; do not rerun blindly. + +- [ ] **Step 4: Merge and verify merged main** + + Merge with an explicit merge commit (admin bypass only if the repository's single self-CODEOWNER makes ordinary approval impossible). Wait for the merge commit's own `main` CI to pass, then record `MAIN_SHA`. + +### Task 4: Tag, publish, and verify Beta 4 + +- [ ] **Step 1: Recheck the immutable boundary** + + Confirm remote `main == MAIN_SHA`, the candidate metadata and note are Beta 4, both signing secret names exist, and neither the Beta 4 tag nor release exists. + +- [ ] **Step 2: Create and push the annotated tag** + + Run `git tag -a v1.0.0-beta.4 MAIN_SHA -m 'OpenTake 1.0.0-beta.4'` and push only `refs/tags/v1.0.0-beta.4`. + +- [ ] **Step 3: Monitor the tag-triggered Release workflow** + + Wait for validate, quality, macOS, Windows, and publish jobs to terminal completion. Never move the tag after failure; use `workflow_dispatch` only for an unchanged existing tag after fixing an external prerequisite while `main` remains at `MAIN_SHA`. + +- [ ] **Step 4: Verify the public prerelease** + + Confirm tag/target SHA, prerelease/draft/latest flags, exactly seventeen asset names, GitHub digests, `SHA256SUMS`, updater manifest platform keys and URLs, companion signatures/attestations, and downloadable package sizes. + +- [ ] **Step 5: Report rollback and residual platform limitations** + + Preserve Beta 3 as the rollback release. Report that Beta 4 is ad-hoc/not notarized on macOS and not Authenticode-signed on Windows; retain exact-SHA receipts and workflow URL. diff --git a/scripts/check_release_workflow.py b/scripts/check_release_workflow.py index 866e5f49..cc87c072 100644 --- a/scripts/check_release_workflow.py +++ b/scripts/check_release_workflow.py @@ -62,6 +62,7 @@ "macos_arm64": ( f"uses:actions/checkout@{PINNED_ACTIONS['actions/checkout']}", "name:Assert exact checked-out SHA", + "name:Require updater signing secrets", "name:Install Rust toolchain", f"uses:pnpm/action-setup@{PINNED_ACTIONS['pnpm/action-setup']}", f"uses:actions/setup-node@{PINNED_ACTIONS['actions/setup-node']}", @@ -70,15 +71,17 @@ "name:Verify pinned sidecar supply", "name:Install locked Web dependencies", "name:Reassert exact source before macOS build", - "name:Build ad-hoc Tauri app and DMG", - "name:Verify complete app, sidecars, and DMG", + "name:Build ad-hoc Tauri app, DMG, and signed updater", + "name:Verify complete app, sidecars, DMG, and signed updater", "name:Reassert exact source after macOS packaging", + "name:Create and sign macOS updater attestation", "name:Create macOS exact-SHA receipt", - "name:Upload exact-SHA macOS package", + "name:Upload exact-SHA macOS packages and updater", ), "windows_x64": ( f"uses:actions/checkout@{PINNED_ACTIONS['actions/checkout']}", "name:Assert exact checked-out SHA", + "name:Require updater signing secrets", "name:Install Rust toolchain", f"uses:pnpm/action-setup@{PINNED_ACTIONS['pnpm/action-setup']}", f"uses:actions/setup-node@{PINNED_ACTIONS['actions/setup-node']}", @@ -93,19 +96,23 @@ "name:Minimal-feature Tauri clippy", "name:Web production build", "name:Reassert exact source before Windows build", - "name:Build native MSI and NSIS installers", - "name:Install NSIS and smoke installed app and sidecars", + "name:Build native MSI, NSIS, and signed updater artifacts", + "name:Install NSIS and smoke installed app, sidecars, and updater artifacts", "name:Reassert exact source after Windows packaging", + "name:Create and sign Windows updater attestations", "name:Create Windows exact-SHA receipt", - "name:Upload exact-SHA Windows packages", + "name:Upload exact-SHA Windows packages and updater signatures", ), "publish": ( f"uses:actions/checkout@{PINNED_ACTIONS['actions/checkout']}", "name:Assert exact checked-out SHA", "name:Initialize isolated publish root", + "name:Install Minisign verifier", "name:Download macOS artifact", "name:Download Windows artifact", "name:Stage and verify the exact release payload", + "name:Verify updater signatures against embedded public key", + "name:Write and verify tag-specific updater manifest", "name:Create and verify SHA256SUMS", "name:Prepare release notes with provenance", "name:Reassert exact source before draft mutation", @@ -135,7 +142,7 @@ ("macos_arm64", "Provision checksum-pinned ARM64 FFmpeg sidecars"): "python3 scripts/provision_ffmpeg_sidecars.py --target aarch64-apple-darwin", ("macos_arm64", "Verify pinned sidecar supply"): "ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute", ("macos_arm64", "Install locked Web dependencies"): "pnpm -C web install --frozen-lockfile", - ("macos_arm64", "Build ad-hoc Tauri app and DMG"): "./web/node_modules/.bin/tauri build --ci --target aarch64-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'", + ("macos_arm64", "Build ad-hoc Tauri app, DMG, and signed updater"): "./web/node_modules/.bin/tauri build --ci --target aarch64-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"createUpdaterArtifacts\":true,\"macOS\":{\"signingIdentity\":\"-\"}}}'", ("windows_x64", "Install Rust toolchain"): "rustup component add rustfmt clippy", ("windows_x64", "Provision checksum-pinned Windows FFmpeg sidecars"): "python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc", ("windows_x64", "Verify pinned sidecar supply"): "ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute", @@ -150,47 +157,54 @@ } APPROVED_COMPLEX_RUN_SHA256 = { - ("validate", "Validate tag, source SHA, versions, and notes"): "120a1dc8347fb5989f607f6965b1f9ce5167e934a41631d1ac0cee01e0b83876", + ("validate", "Validate tag, source SHA, versions, and notes"): "a8098fcb30554344d8821c32540b2116efef5877c4fc992cd2a98b955e73a346", ("validate", "Reassert exact source after validation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("quality", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", ("quality", "Free disk space"): "5848415c4d0e696f46965d62a2e17c8b7a0dd45ae600d28102af0b04108d9bf6", ("quality", "Install system deps (ffmpeg + Tauri/GTK)"): "ee466d2d3fff1c3703d50f9dabe4d21e1cee4b399924d064c6d2714dae34d16b", ("quality", "Audit Motion Canvas dependencies and licenses"): "a3517fae1a8663e519138196c9f3721d8f4df19ac8f115c49a079c4aaa60c8b3", ("quality", "Test and reproduce Motion Canvas runner"): "8bcd55de9b045f9d7be6343163a5422cba0ab545f7844da50ca1a7c8623fe640", - ("quality", "Validate Windows and release workflow contracts"): "45c70d24aae54d1409f0594a65256d7c1397459d97d1fdacfc74d9f8d6f4105f", + ("quality", "Validate Windows and release workflow contracts"): "a3eee0e4912440340c2be717b3b635f9eb121f68e8f3dd1e3c58f19b4c2ace36", ("quality", "Live playback transport integration"): "461f79546009551e5e7adbf50f869abb9449c2ae7666a66a425c7cd3c24acea9", ("quality", "Reassert exact source after quality gates"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("macos_arm64", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", + ("macos_arm64", "Require updater signing secrets"): "d03a04c4866ac7ecb9eba9d53aabbf92de24f452e6b5448d438d863988e797c4", ("macos_arm64", "Reassert exact source before macOS build"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("macos_arm64", "Verify complete app, sidecars, and DMG"): "4122545856cbbcdec2d7f835c005007d723ffb2432ee18bb1173acc10453123b", + ("macos_arm64", "Verify complete app, sidecars, DMG, and signed updater"): "17dabc0d92b8958c44f315d7c874f4db97ae3efe1ced73d7994e8654e42e80fe", ("macos_arm64", "Reassert exact source after macOS packaging"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("macos_arm64", "Create macOS exact-SHA receipt"): "67d51abe727feb1d2593927c70e68d9c5c2f64a7db42ab1907dc3e40a4e44ab7", + ("macos_arm64", "Create and sign macOS updater attestation"): "5fd8f5af4dcf6a11740d531a39cd716f93478fb65bb4b4fcbafd88191d245d4a", + ("macos_arm64", "Create macOS exact-SHA receipt"): "06f89a7122f8257ad14b8ae5fa59426e87ecbae4936f0db69a37ee6cc748bd2a", ("windows_x64", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", + ("windows_x64", "Require updater signing secrets"): "d03a04c4866ac7ecb9eba9d53aabbf92de24f452e6b5448d438d863988e797c4", ("windows_x64", "Reassert exact source before Windows build"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("windows_x64", "Build native MSI and NSIS installers"): "3c814b49684a64df810c052dbf021890df348967d5767bb453800fc36518c6ce", - ("windows_x64", "Install NSIS and smoke installed app and sidecars"): "3e3909b29d79f338edbe186daf42b341618feb720326eacfac6ea068bf3e8c42", + ("windows_x64", "Build native MSI, NSIS, and signed updater artifacts"): "efa3fa9c3659a91dcbeb3b16e95bbb17482e50f5e71ef9ff4f037b69d7ee335f", + ("windows_x64", "Install NSIS and smoke installed app, sidecars, and updater artifacts"): "556af3fe7ee52b0dfde26824abf39b589c897782f1a311a8c64a044dc3cf7010", ("windows_x64", "Reassert exact source after Windows packaging"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("windows_x64", "Create Windows exact-SHA receipt"): "e80a5bf08f311dfb353c19c4ada500726ed9c5d8e4f817c513d12e791b19f558", + ("windows_x64", "Create and sign Windows updater attestations"): "40c7f2ea8696db15adf49a688a446637df5b1625fe0b39de32502e66af71f2c6", + ("windows_x64", "Create Windows exact-SHA receipt"): "0072244797bdcbe7a26f57a5a83e5c2ba880ffa96499f18f4c5affd30b0e54f4", ("publish", "Assert exact checked-out SHA"): "b1cd768e31e2924c14421c62357ae200ddee50b2a6c2ddc24618717a3c876267", ("publish", "Initialize isolated publish root"): "ab0b76ab253b0497067e6d8650a0fd6b37fb3a0ac72b2ff11d1738a88b46c2dd", - ("publish", "Stage and verify the exact release payload"): "69097e2a084dee0d4a0eb313be47e545b7a8ae59abfc9e52dbe0c1eccb659d11", - ("publish", "Create and verify SHA256SUMS"): "c205b22264de125ac603558c06b8a93aff726f6eddd8cda07ca7df0862641705", - ("publish", "Prepare release notes with provenance"): "e2761070b29f27142a750829e0aa831577a7016690a1012d970637983b54ecbc", + ("publish", "Install Minisign verifier"): "444027a1ab2942d223b3e16e286d2de585fdc1175e94e093b012b13aefea2416", + ("publish", "Stage and verify the exact release payload"): "4cdd6d245891e535e6f11a29371b4b0c7e343702f36d37f56262387f3523b534", + ("publish", "Verify updater signatures against embedded public key"): "d0da4c84101149e7853b764db8f770d25f61b4fa654f9f927fd813bd22604fe5", + ("publish", "Write and verify tag-specific updater manifest"): "faef05e038dd082d81e1ec12a8c6f933575e8766e73df1b4ecb28cb794156579", + ("publish", "Create and verify SHA256SUMS"): "67975ac408cc96209261e8c397402acaac47e1a689d7572d5634d3b8dabd66d8", + ("publish", "Prepare release notes with provenance"): "efa2e3d9cd3b576a5e6ef3f25788a9d2156e0c0a5c8f53d3f8f9a1dbc1bbb5ea", ("publish", "Reassert exact source before draft mutation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("publish", "Revalidate remote tag before draft mutation"): "2eac9a1203d96969b545c9447b9637c8ad16689d2704b32c7e710e9ae4bff47c", ("publish", "Create or refresh draft prerelease"): "e3cee76806b604359715ec91cf1a7c6d7c8919e05dd5b5c99f77b98686debc6b", - ("publish", "Verify draft target and exact assets"): "440e618009c30184e66a02d4f695bc25af708b09558ec25471e3c52bde417764", + ("publish", "Verify draft target and exact assets"): "16b9ac363a7f10417e01332035e2419341f762a46afb586fd2f214641142d0fe", ("publish", "Revalidate remote tag before publication"): "89cd8010bf65a4a3d7b85b2666e13eeddc4554e24e53eb700592bd6dc77cea6f", ("publish", "Reassert exact source before publication"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("publish", "Verify public release through API and checksums"): "bdd6671280f89cfe71fad7c0eb9fecb24a6f66931910ee5075c5c2e9e96f52fe", } APPROVED_JOB_SHA256 = { - "validate": "386c4e21bdcc884a2246eea1d078b12378b25bfef52043e5e9520a2845f51142", - "quality": "43599825f68088f3cc744220227267f77454e2c7ce389a52b8aee5a492491eec", - "macos_arm64": "f913508e4f041614a967f680515cc0b8779800f0b2a7b4b6ff29f24f4bb1efd2", - "windows_x64": "5f012278c20843ba1b38cffb27f3c614716bd92e3fec63a11885b41692be78b5", - "publish": "89192ac021d48cc8c8ef17b612a2da666d96c01d195c4e2b58eec24b2cc24d1b", + "validate": "36c12ee29b323f04d1ac48b046de6786d130e85bae8e89d831bb64c96f2b5c26", + "quality": "54f73cef43a8a808cf481f807cf08fccfa281112cd4c365edd06a1d7d6f68187", + "macos_arm64": "1785d765c96278190c25e312c9e610070619e17b7b2b0d922f0bd234501df525", + "windows_x64": "7a95b56230b09f34c3abf31a9d1c226e290115934285eab008568ade516145c3", + "publish": "945d07ecb63de230c8ba38f27a082a019370ee9e233adf9ae733f32669c231cb", } @@ -638,8 +652,63 @@ def validate_workflow(workflow: str) -> list[str]: if skipped: errors.append("required steps are unconditional") scalar_strings = _all_scalar_strings(document) - if any("secrets." in value for value in scalar_strings): - errors.append("no production secrets") + signing_env = { + "TAURI_SIGNING_PRIVATE_KEY": "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}", + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}", + } + secret_steps = { + ("macos_arm64", "Require updater signing secrets"), + ("macos_arm64", "Build ad-hoc Tauri app, DMG, and signed updater"), + ("macos_arm64", "Create and sign macOS updater attestation"), + ("windows_x64", "Require updater signing secrets"), + ("windows_x64", "Build native MSI, NSIS, and signed updater artifacts"), + ("windows_x64", "Create and sign Windows updater attestations"), + } + seen_secret_steps: set[tuple[str, str]] = set() + secret_scope_valid = True + for job_name, job in structured_jobs.items(): + if job is None: + continue + for step in _as_steps(job.get("steps")) or []: + step_name = step.get("name") + env = _as_mapping(step.get("env")) + secret_values = ( + [] + if env is None + else [value for value in env.values() if "secrets." in str(value)] + ) + if secret_values: + key = (job_name, str(step_name)) + if key not in secret_steps or env != signing_env: + secret_scope_valid = False + else: + seen_secret_steps.add(key) + actual_secret_scalars = sorted( + value for value in scalar_strings if "secrets." in value + ) + expected_secret_scalars = sorted( + [signing_env["TAURI_SIGNING_PRIVATE_KEY"]] * 6 + + [signing_env["TAURI_SIGNING_PRIVATE_KEY_PASSWORD"]] * 6 + ) + if not secret_scope_valid or actual_secret_scalars != expected_secret_scalars: + errors.append("updater signing secrets limited to guard and build steps") + guard_lines = ( + 'test -n "${TAURI_SIGNING_PRIVATE_KEY:-}"', + 'test -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"', + ) + guards_valid = True + for job, name in secret_steps: + if name != "Require updater signing secrets": + continue + guard = _structured_step(structured_jobs[job], name) + if ( + guard is None + or _as_mapping(guard.get("env")) != signing_env + or not _has_code_lines(guard, guard_lines) + ): + guards_valid = False + if seen_secret_steps != secret_steps or not guards_valid: + errors.append("updater signing secrets fail closed") action_pins_valid = True has_action = False @@ -770,19 +839,21 @@ def validate_workflow(workflow: str) -> list[str]: 'notes = Path("docs/releases") / f"{version}.md"', 'if versions != {version}:', 'wix_version = tauri["bundle"]["windows"]["wix"]["version"]', - 'if wix_version != "1.0.0.3":', + 'if wix_version != "1.0.0.4":', ), ): errors.append("Cargo, Tauri, and Web versions match tag") if not _has_code_lines( bind, ( + 'if "+" in tag:', + 'raise SystemExit("SemVer build metadata is unsupported for updater asset URLs")', 'if SEMVER_RE.fullmatch(tag) is None:', - 'if version == "1.0.0-beta.3" and not prerelease:', + 'if version == "1.0.0-beta.4" and not prerelease:', 'emit("prerelease", "true")', ), ): - errors.append("Beta 3 is a SemVer prerelease") + errors.append("SemVer build metadata is unsupported") if not _has_code_lines(bind, validate_order[1:5]): errors.append("validate binds exact clean checkout") @@ -852,6 +923,8 @@ def validate_workflow(workflow: str) -> list[str]: ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_check_windows_product_ci.py")), ("Validate Windows and release workflow contracts", ("python3", "-B", "scripts/check_release_workflow.py")), ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_check_release_workflow.py")), + ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_write_updater_attestation.py")), + ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_write_updater_manifest.py")), ("Provisioner unit tests", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts/tests", "-p", "test_*.py")), ("Rust formatting", ("cargo", "fmt", "--all", "--check")), ("Rust workspace clippy", ("cargo", "clippy", "--workspace", "--all-targets", "--", "-D", "warnings")), @@ -893,23 +966,62 @@ def validate_workflow(workflow: str) -> list[str]: errors.append("quality pins Ruby Psych before the release validator") macos = structured_jobs["macos_arm64"] - mac_build = _structured_step(macos, "Build ad-hoc Tauri app and DMG") - mac_verify = _structured_step(macos, "Verify complete app, sidecars, and DMG") + mac_build = _structured_step( + macos, "Build ad-hoc Tauri app, DMG, and signed updater" + ) + mac_verify = _structured_step( + macos, "Verify complete app, sidecars, DMG, and signed updater" + ) + mac_attestation = _structured_step( + macos, "Create and sign macOS updater attestation" + ) mac_receipt = _structured_step(macos, "Create macOS exact-SHA receipt") mac_uploads = _action_step(macos, "actions/upload-artifact") mac_ok = ( macos is not None and macos.get("runs-on") == "macos-14" and _as_mapping(macos.get("env", {})).get("APPLE_SIGNING_IDENTITY") == "-" + and mac_build is not None and _has_command(_structured_step(macos, "Provision checksum-pinned ARM64 FFmpeg sidecars"), ("python3", "scripts/provision_ffmpeg_sidecars.py", "--target", "aarch64-apple-darwin")) and _has_command(_structured_step(macos, "Install locked Web dependencies"), ("pnpm", "-C", "web", "install", "--frozen-lockfile")) - and _has_command(mac_build, ("./web/node_modules/.bin/tauri", "build", "--ci", "--target", "aarch64-apple-darwin", "--bundles", "app,dmg", "--config", '{"bundle":{"macOS":{"signingIdentity":"-"}}}')) + and _as_mapping(mac_build.get("env")) == signing_env + and _has_command(mac_build, ("./web/node_modules/.bin/tauri", "build", "--ci", "--target", "aarch64-apple-darwin", "--bundles", "app,dmg", "--config", '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"signingIdentity":"-"}}}')) and _has_command(mac_verify, ("codesign", "--verify", "--deep", "--strict", "--verbose=2", "$app")) and _has_command(mac_verify, ("hdiutil", "verify", "$dmg")) and _has_command(mac_verify, ("hdiutil", "attach", "$dmg", "-nobrowse", "-readonly", "-mountpoint", "$mountpoint")) and _has_command(mac_verify, ("ruby", "scripts/tests/packaged-sidecars-test.rb", "--name", "packaged_macos_windows_sidecars_resolve_and_execute", "--package", "$mounted_app")) - and _has_code_lines(mac_verify, ("codesign -dv --verbose=4 \"$app\" 2>&1 | grep -F 'Signature=adhoc'",)) - and _has_code_lines(mac_receipt, ('"schema": "opentake-macos-arm64-receipt-v1",', '"source_sha": os.environ["RECEIPT_SHA"],', '"signature_mode": "ad-hoc",', '"sha256": sha256(artifact),', '"bytes": artifact.stat().st_size,')) + and _has_code_lines(mac_verify, ("codesign -dv --verbose=4 \"$app\" 2>&1 | grep -F 'Signature=adhoc'", 'test "$updater_signature" = "$updater.sig"', 'test -s "$updater"', 'test -s "$updater_signature"')) + and mac_attestation is not None + and _as_mapping(mac_attestation.get("env")) == signing_env + and _has_command( + mac_attestation, + ( + "python3", "scripts/write_updater_attestation.py", + "--repository", "appergb/OpenTake", + "--tag", "$RELEASE_TAG", + "--version", "$RELEASE_VERSION", + "--source-sha", "$TARGET_SHA", + "--platform", "darwin-aarch64", + "--artifact", "$updater", + "--output", "$attestation", + ), + ) + and _has_command( + mac_attestation, + ("./web/node_modules/.bin/tauri", "signer", "sign", "$attestation"), + ) + and _has_code_lines( + mac_attestation, + ('attestation="$updater.attestation.json"', 'test -s "$attestation.sig"'), + ) + and _has_code_lines(mac_receipt, ('"schema": "opentake-macos-arm64-receipt-v2",', '"source_sha": os.environ["RECEIPT_SHA"],', '"platform_signing_mode": "ad-hoc",', '"updater_signature_mode": "tauri-minisign",', '"sha256": sha256(artifact),', '"bytes": artifact.stat().st_size,')) + and _has_code_lines( + mac_receipt, + ( + 'if attestations[0] != Path(f"{updaters[0]}.attestation.json"):', + 'if attestation_signatures[0] != Path(f"{attestations[0]}.sig"):', + ), + ) and len(mac_uploads) == 1 and mac_uploads[0][1].get("uses") == f"actions/upload-artifact@{PINNED_ACTIONS['actions/upload-artifact']}" ) @@ -917,8 +1029,16 @@ def validate_workflow(workflow: str) -> list[str]: errors.append("complete ad-hoc macOS ARM64 bundle gate") windows = structured_jobs["windows_x64"] - windows_build = _structured_step(windows, "Build native MSI and NSIS installers") - windows_install = _structured_step(windows, "Install NSIS and smoke installed app and sidecars") + windows_build = _structured_step( + windows, "Build native MSI, NSIS, and signed updater artifacts" + ) + windows_install = _structured_step( + windows, + "Install NSIS and smoke installed app, sidecars, and updater artifacts", + ) + windows_attestation = _structured_step( + windows, "Create and sign Windows updater attestations" + ) windows_receipt = _structured_step(windows, "Create Windows exact-SHA receipt") windows_uploads = _action_step(windows, "actions/upload-artifact") windows_commands = ( @@ -936,14 +1056,62 @@ def validate_workflow(workflow: str) -> list[str]: ) windows_ok = windows_ok and _has_command( windows_build, - ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "build", "--ci", "--bundles", "msi,nsis"), + ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "build", "--ci", "--bundles", "msi,nsis", "--config", "'{\"bundle\":{\"createUpdaterArtifacts\":true}}'"), powershell=True, ) + windows_ok = windows_ok and windows_build is not None and _as_mapping( + windows_build.get("env") + ) == signing_env + windows_ok = ( + windows_ok + and windows_attestation is not None + and _as_mapping(windows_attestation.get("env")) == signing_env + and _has_command( + windows_attestation, + ( + "python", "scripts/write_updater_attestation.py", + "--repository", "appergb/OpenTake", + "--tag", "$env:RELEASE_TAG", + "--version", "$env:RELEASE_VERSION", + "--source-sha", "$env:TARGET_SHA", + "--platform", "windows-x86_64-msi", + "--artifact", "$msi[0].FullName", + "--output", "$msiAttestation", + ), + powershell=True, + ) + and _has_command( + windows_attestation, + ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "signer", "sign", "$msiAttestation"), + powershell=True, + ) + and _has_command( + windows_attestation, + ( + "python", "scripts/write_updater_attestation.py", + "--repository", "appergb/OpenTake", + "--tag", "$env:RELEASE_TAG", + "--version", "$env:RELEASE_VERSION", + "--source-sha", "$env:TARGET_SHA", + "--platform", "windows-x86_64-nsis", + "--artifact", "$nsis[0].FullName", + "--output", "$nsisAttestation", + ), + powershell=True, + ) + and _has_command( + windows_attestation, + ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "signer", "sign", "$nsisAttestation"), + powershell=True, + ) + ) windows_ok = windows_ok and _has_code_lines( windows_install, ( "if ($msi.Count -ne 1) { throw 'expected exactly one MSI installer' }", "if ($installer.Count -ne 1) { throw 'expected exactly one NSIS installer' }", + "if ($msiSignature.Count -ne 1) { throw 'expected exactly one MSI updater signature' }", + "if ($nsisSignature.Count -ne 1) { throw 'expected exactly one NSIS updater signature' }", "$app = Start-Process -FilePath $application -PassThru", "--name packaged_macos_windows_sidecars_resolve_and_execute `", ), @@ -951,10 +1119,16 @@ def validate_workflow(workflow: str) -> list[str]: windows_ok = windows_ok and _has_code_lines( windows_receipt, ( - "schema = 'opentake-windows-release-receipt-v1'", + "schema = 'opentake-windows-release-receipt-v2'", "source_sha = $env:RECEIPT_SHA", + "platform_signing_mode = 'unsigned-authenticode'", + "updater_signature_mode = 'tauri-minisign'", "sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()", "bytes = $_.Length", + "if ($msiAttestation[0].FullName -ne \"$($msi[0].FullName).attestation.json\") { throw 'MSI attestation is not the installer companion' }", + "if ($msiAttestationSignature[0].FullName -ne \"$($msiAttestation[0].FullName).sig\") { throw 'MSI attestation signature is not its companion' }", + "if ($nsisAttestation[0].FullName -ne \"$($nsis[0].FullName).attestation.json\") { throw 'NSIS attestation is not the installer companion' }", + "if ($nsisAttestationSignature[0].FullName -ne \"$($nsisAttestation[0].FullName).sig\") { throw 'NSIS attestation signature is not its companion' }", ), ) windows_ok = windows_ok and len(windows_uploads) == 1 and windows_uploads[0][1].get( @@ -963,6 +1137,157 @@ def validate_workflow(workflow: str) -> list[str]: if not windows_ok: errors.append("complete Windows x64 installer gate") + mac_upload_with = ( + _with_mapping(mac_uploads[0][1]) if len(mac_uploads) == 1 else None + ) + windows_upload_with = ( + _with_mapping(windows_uploads[0][1]) if len(windows_uploads) == 1 else None + ) + expected_mac_upload = { + "name": "opentake-macos-arm64-${{ needs.validate.outputs.source_sha }}", + "path": ( + "target/aarch64-apple-darwin/release/bundle/dmg/*.dmg\n" + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz\n" + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig\n" + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.attestation.json\n" + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.attestation.json.sig\n" + "macos-arm64-receipt.json\n" + ), + "if-no-files-found": "error", + "retention-days": 30, + } + expected_windows_upload = { + "name": "opentake-windows-x64-${{ needs.validate.outputs.source_sha }}", + "path": ( + "target/release/bundle/msi/*.msi\n" + "target/release/bundle/msi/*.msi.sig\n" + "target/release/bundle/msi/*.msi.attestation.json\n" + "target/release/bundle/msi/*.msi.attestation.json.sig\n" + "target/release/bundle/nsis/*.exe\n" + "target/release/bundle/nsis/*.exe.sig\n" + "target/release/bundle/nsis/*.exe.attestation.json\n" + "target/release/bundle/nsis/*.exe.attestation.json.sig\n" + "windows-x64-receipt.json\n" + ), + "if-no-files-found": "error", + "retention-days": 30, + } + if ( + mac_upload_with != expected_mac_upload + or windows_upload_with != expected_windows_upload + ): + errors.append("exact signed updater artifact uploads") + upload_paths = "\n".join( + str(mapping.get("path", "")) + for mapping in (mac_upload_with, windows_upload_with) + if mapping is not None + ).lower() + if any( + marker in upload_paths + for marker in ("*.key", "*.pem", "private_key", "signing_private") + ): + errors.append("private updater signing material is never uploaded") + signed_bundles_ok = ( + mac_build is not None + and _as_mapping(mac_build.get("env")) == signing_env + and _has_command( + mac_build, + ( + "./web/node_modules/.bin/tauri", + "build", + "--ci", + "--target", + "aarch64-apple-darwin", + "--bundles", + "app,dmg", + "--config", + '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"signingIdentity":"-"}}}', + ), + ) + and windows_build is not None + and _as_mapping(windows_build.get("env")) == signing_env + and _has_command( + windows_build, + ( + "&", + ".\\web\\node_modules\\.bin\\tauri.cmd", + "build", + "--ci", + "--bundles", + "msi,nsis", + "--config", + "'{\"bundle\":{\"createUpdaterArtifacts\":true}}'", + ), + powershell=True, + ) + ) + if not signed_bundles_ok: + errors.append("signed Tauri v2 updater bundles") + + signed_attestations_ok = ( + mac_attestation is not None + and _as_mapping(mac_attestation.get("env")) == signing_env + and _has_command( + mac_attestation, + ( + "python3", "scripts/write_updater_attestation.py", + "--repository", "appergb/OpenTake", + "--tag", "$RELEASE_TAG", + "--version", "$RELEASE_VERSION", + "--source-sha", "$TARGET_SHA", + "--platform", "darwin-aarch64", + "--artifact", "$updater", + "--output", "$attestation", + ), + ) + and _has_command( + mac_attestation, + ("./web/node_modules/.bin/tauri", "signer", "sign", "$attestation"), + ) + and windows_attestation is not None + and _as_mapping(windows_attestation.get("env")) == signing_env + and _has_command( + windows_attestation, + ( + "python", "scripts/write_updater_attestation.py", + "--repository", "appergb/OpenTake", + "--tag", "$env:RELEASE_TAG", + "--version", "$env:RELEASE_VERSION", + "--source-sha", "$env:TARGET_SHA", + "--platform", "windows-x86_64-msi", + "--artifact", "$msi[0].FullName", + "--output", "$msiAttestation", + ), + powershell=True, + ) + and _has_command( + windows_attestation, + ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "signer", "sign", "$msiAttestation"), + powershell=True, + ) + and _has_command( + windows_attestation, + ( + "python", "scripts/write_updater_attestation.py", + "--repository", "appergb/OpenTake", + "--tag", "$env:RELEASE_TAG", + "--version", "$env:RELEASE_VERSION", + "--source-sha", "$env:TARGET_SHA", + "--platform", "windows-x86_64-nsis", + "--artifact", "$nsis[0].FullName", + "--output", "$nsisAttestation", + ), + powershell=True, + ) + and _has_command( + windows_attestation, + ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "signer", "sign", "$nsisAttestation"), + powershell=True, + ) + ) + if not signed_attestations_ok: + errors.append("signed updater attestations bind release identity and payload") + if publish is None or publish.get("needs") != [ "validate", "quality", "macos_arm64", "windows_x64" ]: @@ -997,24 +1322,250 @@ def validate_workflow(workflow: str) -> list[str]: stage = _structured_step(publish, "Stage and verify the exact release payload") stage_commands = ( ("test", "${#dmgs[@]}", "-eq", "1"), + ("test", "${#mac_updaters[@]}", "-eq", "1"), + ("test", "${#mac_signatures[@]}", "-eq", "1"), + ("test", "${#mac_attestations[@]}", "-eq", "1"), + ("test", "${#mac_attestation_signatures[@]}", "-eq", "1"), ("test", "${#msis[@]}", "-eq", "1"), ("test", "${#exes[@]}", "-eq", "1"), + ("test", "${#windows_signatures[@]}", "-eq", "2"), + ("test", "${#msi_attestations[@]}", "-eq", "1"), + ("test", "${#msi_attestation_signatures[@]}", "-eq", "1"), + ("test", "${#nsis_attestations[@]}", "-eq", "1"), + ("test", "${#nsis_attestation_signatures[@]}", "-eq", "1"), ("test", "${#mac_receipts[@]}", "-eq", "1"), ("test", "${#windows_receipts[@]}", "-eq", "1"), ) - if not all(_has_command(stage, command) for command in stage_commands) or not _has_code_lines( - stage, ('expected = {"dmg": 1, "msi": 1, "exe": 1, "json": 2}',) - ): + if not all(_has_command(stage, command) for command in stage_commands): errors.append("strict release asset counts") + signed_stage_commands = stage_commands[1:5] + stage_commands[7:12] + manifest = _structured_step( + publish, "Write and verify tag-specific updater manifest" + ) + manifest_counts = ( + ("test", "${#mac_updaters[@]}", "-eq", "1"), + ("test", "${#mac_signatures[@]}", "-eq", "1"), + ("test", "${#mac_attestations[@]}", "-eq", "1"), + ("test", "${#mac_attestation_signatures[@]}", "-eq", "1"), + ("test", "${#msi_installers[@]}", "-eq", "1"), + ("test", "${#msi_signatures[@]}", "-eq", "1"), + ("test", "${#msi_attestations[@]}", "-eq", "1"), + ("test", "${#msi_attestation_signatures[@]}", "-eq", "1"), + ("test", "${#nsis_installers[@]}", "-eq", "1"), + ("test", "${#nsis_signatures[@]}", "-eq", "1"), + ("test", "${#nsis_attestations[@]}", "-eq", "1"), + ("test", "${#nsis_attestation_signatures[@]}", "-eq", "1"), + ("test", "${#manifests[@]}", "-eq", "1"), + ) + if not all( + _has_command(stage, command) for command in signed_stage_commands + ) or not all(_has_command(manifest, command) for command in manifest_counts): + errors.append("strict signed updater asset counts") + + manifest_command = ( + "python3", + "scripts/write_updater_manifest.py", + "--repository", + "appergb/OpenTake", + "--tag", + "$RELEASE_TAG", + "--version", + "$RELEASE_VERSION", + "--source-sha", + "$RELEASE_SHA", + "--darwin-artifact", + "${mac_updaters[0]}", + "--darwin-signature", + "${mac_signatures[0]}", + "--darwin-attestation", + "${mac_attestations[0]}", + "--darwin-attestation-signature", + "${mac_attestation_signatures[0]}", + "--windows-msi-artifact", + "${msi_installers[0]}", + "--windows-msi-signature", + "${msi_signatures[0]}", + "--windows-msi-attestation", + "${msi_attestations[0]}", + "--windows-msi-attestation-signature", + "${msi_attestation_signatures[0]}", + "--windows-nsis-artifact", + "${nsis_installers[0]}", + "--windows-nsis-signature", + "${nsis_signatures[0]}", + "--windows-nsis-attestation", + "${nsis_attestations[0]}", + "--windows-nsis-attestation-signature", + "${nsis_attestation_signatures[0]}", + "--output", + "$PUBLISH_ROOT/assets/updater-$RELEASE_TAG.json", + ) + fixed_manifest_ok = ( + _has_code_lines(bind, ('test "$GITHUB_REPOSITORY" = "appergb/OpenTake"',)) + and _has_command(manifest, manifest_command) + and _has_code_lines( + manifest, + ( + 'test "${manifests[0]}" = "$PUBLISH_ROOT/assets/updater-$RELEASE_TAG.json"', + ), + ) + ) + if not fixed_manifest_ok: + errors.append("fixed HTTPS exact-tag updater manifest") + + receipts_agree = _has_code_lines( + stage, + ( + 'if mac_signatures[0] != Path(f"{mac_updaters[0]}.sig"):', + 'f"{msis[0].name}.sig",', + 'f"{exes[0].name}.sig",', + '"opentake-macos-arm64-receipt-v2",', + '"opentake-windows-release-receipt-v2",', + 'if entry.get("sha256") != sha256(artifact):', + 'if entry.get("bytes") != artifact.stat().st_size:', + 'if receipt.get("updater_signature_mode") != "tauri-minisign":', + 'if attestation.get("sourceSha") != source_sha:', + 'if attestation.get("sha256") != sha256(artifact):', + 'if attestation != expected:', + 'msi_attestations[0], msis[0], "windows-x86_64-msi"', + 'nsis_attestations[0], exes[0], "windows-x86_64-nsis"', + ), + ) and _has_command(manifest, manifest_command) + if not receipts_agree: + errors.append("updater receipts, digests, sizes, and signatures agree") + attestations_bind = _has_code_lines( + stage, + ( + '"schemaVersion": 1,', + '"repository": "appergb/OpenTake",', + '"tag": release_tag,', + '"version": release_version,', + '"sourceSha": source_sha,', + '"platform": platform,', + '"assetName": artifact.name,', + '"size": artifact.stat().st_size,', + '"sha256": sha256(artifact),', + 'type(attestation["schemaVersion"]) is not int', + 'or type(attestation["size"]) is not int', + 'or attestation["size"] <= 0', + 'or any(type(attestation[field]) is not str for field in string_fields)', + 'if attestation.get("sourceSha") != source_sha:', + 'if attestation.get("sha256") != sha256(artifact):', + 'if attestation != expected:', + 'if path.read_text(encoding="utf-8") != canonical:', + ), + ) and _has_command(manifest, manifest_command) + if not attestations_bind: + errors.append("attestations bind exact release identity and updater bytes") + + install_minisign = _structured_step(publish, "Install Minisign verifier") + verify_signatures = _structured_step( + publish, "Verify updater signatures against embedded public key" + ) + signatures_verify = ( + _has_command(install_minisign, ("sudo", "apt-get", "update")) + and _has_command( + install_minisign, + ( + "sudo", + "apt-get", + "install", + "--yes", + "--no-install-recommends", + "minisign", + ), + ) + and _has_command( + install_minisign, ("command", "-v", "minisign", ">/dev/null") + ) + and _has_code_lines( + verify_signatures, + ( + 'pubkey = config["plugins"]["updater"]["pubkey"]', + 'return base64.b64decode(value, validate=True)', + '"macos-attestation.sig",', + '"msi-attestation.sig",', + '"nsis-attestation.sig",', + 'test "${mac_signatures[0]}" = "${mac_updaters[0]}.sig"', + 'test "${msi_signatures[0]}" = "${msis[0]}.sig"', + 'test "${exe_signatures[0]}" = "${exes[0]}.sig"', + 'test "${mac_attestation_signatures[0]}" = "${mac_attestations[0]}.sig"', + 'test "${msi_attestation_signatures[0]}" = "${msi_attestations[0]}.sig"', + 'test "${nsis_attestation_signatures[0]}" = "${nsis_attestations[0]}.sig"', + ), + ) + and _has_command( + verify_signatures, + ( + "minisign", + "-Vm", + "${mac_updaters[0]}", + "-p", + "$verification_root/updater.pub", + "-x", + "$verification_root/macos.sig", + ), + ) + and _has_command( + verify_signatures, + ( + "minisign", + "-Vm", + "${msis[0]}", + "-p", + "$verification_root/updater.pub", + "-x", + "$verification_root/msi.sig", + ), + ) + and _has_command( + verify_signatures, + ( + "minisign", + "-Vm", + "${exes[0]}", + "-p", + "$verification_root/updater.pub", + "-x", + "$verification_root/nsis.sig", + ), + ) + and _has_command( + verify_signatures, + ( + "minisign", "-Vm", "${mac_attestations[0]}", + "-p", "$verification_root/updater.pub", + "-x", "$verification_root/macos-attestation.sig", + ), + ) + and _has_command( + verify_signatures, + ( + "minisign", "-Vm", "${msi_attestations[0]}", + "-p", "$verification_root/updater.pub", + "-x", "$verification_root/msi-attestation.sig", + ), + ) + and _has_command( + verify_signatures, + ( + "minisign", "-Vm", "${nsis_attestations[0]}", + "-p", "$verification_root/updater.pub", + "-x", "$verification_root/nsis-attestation.sig", + ), + ) + ) + if not signatures_verify: + errors.append("updater signatures verify against embedded public key") checksum = _structured_step(publish, "Create and verify SHA256SUMS") checksum_lines = ( 'payload_names=("${asset_names[@]}")', - 'test "${#payload_names[@]}" -eq 5', + 'test "${#payload_names[@]}" -eq 16', 'sha256sum "${payload_names[@]}" > SHA256SUMS', - 'test "$(wc -l < SHA256SUMS | tr -d \' \')" -eq 5', + 'test "$(wc -l < SHA256SUMS | tr -d \' \')" -eq 16', 'sha256sum --check SHA256SUMS', - 'test "$(wc -l < "$PUBLISH_ROOT/expected-assets.txt" | tr -d \' \')" -eq 6', + 'test "$(wc -l < "$PUBLISH_ROOT/expected-assets.txt" | tr -d \' \')" -eq 17', ) if not _has_code_lines(checksum, checksum_lines): errors.append("SHA256SUMS covers and verifies every payload") @@ -1065,9 +1616,33 @@ def validate_workflow(workflow: str) -> list[str]: errors.append("draft payload upload supports failed-run retry") if not ( _has_command(inspect_draft, ("python3", "scripts/check_release_workflow.py", "resolve-release-state", "--input", "$PUBLISH_ROOT/draft-release-graphql.json", "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA", "--output", "$PUBLISH_ROOT/draft-state.json")) - and _has_code_lines(inspect_draft, ('test "$(jq -r \'.action\' "$PUBLISH_ROOT/draft-state.json")" = "refresh"', 'cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/draft-assets.txt"', "jq -e '.asset_sizes | length == 6 and all(. > 0)' \"$PUBLISH_ROOT/draft-state.json\" >/dev/null")) + and _has_code_lines(inspect_draft, ('test "$(jq -r \'.action\' "$PUBLISH_ROOT/draft-state.json")" = "refresh"', 'cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/draft-assets.txt"', "jq -e '.asset_sizes | length == 17 and all(. > 0)' \"$PUBLISH_ROOT/draft-state.json\" >/dev/null")) ): errors.append("draft API verification") + draft_payload_verified = ( + _has_code_lines( + inspect_draft, + ( + "if remote_sizes != local_sizes:", + 'cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/verified-draft-assets.txt"', + 'cmp "$PUBLISH_ROOT/assets/SHA256SUMS" "$PUBLISH_ROOT/verified-draft/SHA256SUMS"', + "sha256sum --check SHA256SUMS", + ), + ) + and _has_command( + inspect_draft, + ( + "gh", + "release", + "download", + "$RELEASE_TAG", + "--dir", + "$PUBLISH_ROOT/verified-draft", + ), + ) + ) + if not draft_payload_verified: + errors.append("draft assets verified by exact name, size, and SHA-256") def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: return _has_command( @@ -1104,7 +1679,8 @@ def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: ( "- Source commit: \\`$RELEASE_SHA\\`", "- GitHub Actions run: [$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)", - "- Signing limits: the macOS asset uses ad-hoc signing only; it is not Developer ID signed or notarized. Windows installers are not claimed to be Authenticode-signed.", + "- Updater trust: updater packages are signed with the dedicated Tauri updater key; the private key is supplied only from GitHub Actions secrets and is never published.", + "- Platform signing limits: the macOS app uses ad-hoc signing only; it is not Developer ID signed or notarized. Windows installers are not Authenticode-signed.", ), ): errors.append("release notes record source, run, and signing limits") @@ -1136,11 +1712,11 @@ def validate_repository_metadata(repository_root: Path) -> list[str]: except (KeyError, OSError, TypeError, UnicodeError, ValueError): return ["readable Cargo, Tauri, and Web version metadata"] - if versions != {"1.0.0-beta.3"}: - errors.append("repository metadata is OpenTake 1.0.0-beta.3") - if wix_version != "1.0.0.3": - errors.append("Windows installer version is 1.0.0.3") - notes = repository_root / "docs" / "releases" / "1.0.0-beta.3.md" + if versions != {"1.0.0-beta.4"}: + errors.append("repository metadata is OpenTake 1.0.0-beta.4") + if wix_version != "1.0.0.4": + errors.append("Windows installer version is 1.0.0.4") + notes = repository_root / "docs" / "releases" / "1.0.0-beta.4.md" try: notes_missing = not notes.is_file() or not notes.read_text( encoding="utf-8" @@ -1148,7 +1724,7 @@ def validate_repository_metadata(repository_root: Path) -> list[str]: except (OSError, UnicodeError): notes_missing = True if notes_missing: - errors.append("Beta 3 release notes exist") + errors.append("Beta 4 release notes exist") return errors diff --git a/scripts/test_check_release_workflow.py b/scripts/test_check_release_workflow.py index 1fe5341f..89547625 100644 --- a/scripts/test_check_release_workflow.py +++ b/scripts/test_check_release_workflow.py @@ -60,11 +60,17 @@ def test_validation_must_compare_all_public_versions(self) -> None: def test_validation_must_bind_windows_installer_version(self) -> None: mutated = self.mutate( + 'if wix_version != "1.0.0.4":', 'if wix_version != "1.0.0.3":', - 'if wix_version != "1.0.0.2":', ) self.assert_rejected(mutated, "Cargo, Tauri, and Web versions match tag") + def test_release_tag_must_reject_semver_build_metadata(self) -> None: + guard = ' if "+" in tag:\n' + self.assertEqual(1, WORKFLOW.count(guard)) + mutated = self.mutate(guard, ' if False:\n') + self.assert_rejected(mutated, "SemVer build metadata is unsupported") + def test_publish_must_depend_on_every_gate(self) -> None: mutated = self.mutate( "needs: [validate, quality, macos_arm64, windows_x64]", @@ -79,6 +85,235 @@ def test_release_assets_must_have_exact_platform_counts(self) -> None: ) self.assert_rejected(mutated, "strict release asset counts") + def test_both_build_jobs_require_private_key_and_password_secrets(self) -> None: + private_key = ( + " TAURI_SIGNING_PRIVATE_KEY: " + "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n" + ) + password = ( + " TAURI_SIGNING_PRIVATE_KEY_PASSWORD: " + "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n" + ) + self.assertEqual(6, WORKFLOW.count(private_key)) + self.assertEqual(6, WORKFLOW.count(password)) + for old, new in ( + (private_key, " TAURI_SIGNING_PRIVATE_KEY: ''\n"), + (password, " TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''\n"), + ): + self.assert_rejected( + self.mutate(old, new), "updater signing secrets fail closed" + ) + + def test_updater_signing_secret_guard_checks_nonempty_values(self) -> None: + guard = ( + ' test -n "${TAURI_SIGNING_PRIVATE_KEY:-}"\n' + ' test -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"\n' + ) + self.assertEqual(2, WORKFLOW.count(guard)) + mutated = self.mutate( + ' test -n "${TAURI_SIGNING_PRIVATE_KEY:-}"\n', + ' test -z "${TAURI_SIGNING_PRIVATE_KEY:-}"\n', + ) + self.assert_rejected(mutated, "updater signing secrets fail closed") + + def test_bundlers_cannot_disable_signed_tauri_v2_updater_artifacts(self) -> None: + self.assertEqual(2, WORKFLOW.count('"createUpdaterArtifacts":true')) + mutated = self.mutate( + '"createUpdaterArtifacts":true', '"createUpdaterArtifacts":false' + ) + self.assert_rejected(mutated, "signed Tauri v2 updater bundles") + + def test_platform_uploads_require_exact_updater_packages_and_signatures(self) -> None: + for path in ( + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz\n", + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig\n", + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.attestation.json\n", + "target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.attestation.json.sig\n", + "target/release/bundle/msi/*.msi.sig\n", + "target/release/bundle/msi/*.msi.attestation.json\n", + "target/release/bundle/msi/*.msi.attestation.json.sig\n", + "target/release/bundle/nsis/*.exe.sig\n", + "target/release/bundle/nsis/*.exe.attestation.json\n", + "target/release/bundle/nsis/*.exe.attestation.json.sig\n", + ): + with self.subTest(path=path.strip()): + self.assert_rejected( + self.mutate(f" {path}", ""), + "exact signed updater artifact uploads", + ) + + def test_publish_generates_only_the_fixed_repo_exact_tag_manifest(self) -> None: + mutations = ( + ("--repository appergb/OpenTake", "--repository attacker/OpenTake"), + ( + '--tag "$RELEASE_TAG" \\\n --version "$RELEASE_VERSION"', + '--tag "v1.0.0-beta.2" \\\n --version "$RELEASE_VERSION"', + ), + ('--version "$RELEASE_VERSION"', '--version "1.0.0-beta.2"'), + ( + '--source-sha "$RELEASE_SHA"', + '--source-sha "0000000000000000000000000000000000000000"', + ), + ( + '--output "$PUBLISH_ROOT/assets/updater-$RELEASE_TAG.json"', + '--output "$PUBLISH_ROOT/assets/latest.json"', + ), + ("--darwin-artifact \"${mac_updaters[0]}\"", "--darwin-artifact \"${dmgs[0]}\""), + ("--windows-msi-artifact \"${msi_installers[0]}\"", "--windows-msi-artifact \"${nsis_installers[0]}\""), + ("--windows-nsis-artifact \"${nsis_installers[0]}\"", "--windows-nsis-artifact \"${msi_installers[0]}\""), + ( + '--darwin-attestation "${mac_attestations[0]}"', + '--darwin-attestation "${mac_updaters[0]}"', + ), + ( + '--windows-msi-attestation "${msi_attestations[0]}"', + '--windows-msi-attestation "${msi_installers[0]}"', + ), + ( + '--windows-nsis-attestation "${nsis_attestations[0]}"', + '--windows-nsis-attestation "${nsis_installers[0]}"', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate_last(old, new), + "fixed HTTPS exact-tag updater manifest", + ) + + def test_staged_payload_requires_exact_updater_asset_and_signature_counts(self) -> None: + mutations = ( + ('test "${#mac_updaters[@]}" -eq 1', 'test "${#mac_updaters[@]}" -ge 1'), + ('test "${#mac_signatures[@]}" -eq 1', 'test "${#mac_signatures[@]}" -ge 1'), + ('test "${#windows_signatures[@]}" -eq 2', 'test "${#windows_signatures[@]}" -ge 1'), + ('test "${#mac_attestations[@]}" -eq 1', 'test "${#mac_attestations[@]}" -ge 1'), + ('test "${#msi_attestations[@]}" -eq 1', 'test "${#msi_attestations[@]}" -ge 1'), + ('test "${#nsis_attestations[@]}" -eq 1', 'test "${#nsis_attestations[@]}" -ge 1'), + ('test "${#manifests[@]}" -eq 1', 'test "${#manifests[@]}" -ge 1'), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate(old, new), "strict signed updater asset counts" + ) + + def test_updater_private_key_cannot_reach_receipts_or_artifacts(self) -> None: + marker = " RECEIPT_SHA: ${{ needs.validate.outputs.source_sha }}\n" + leaked = marker + ( + " LEAKED_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n" + ) + self.assert_rejected( + self.mutate(marker, leaked), + "updater signing secrets limited to guard and build steps", + ) + upload_path = " macos-arm64-receipt.json\n" + self.assert_rejected( + self.mutate( + upload_path, + upload_path + " target/**/*.key\n", + ), + "private updater signing material is never uploaded", + ) + + def test_receipts_and_manifest_cross_check_every_updater_file(self) -> None: + mutated = self.mutate( + 'if entry.get("sha256") != sha256(artifact):', + 'if entry.get("sha256") == "":', + ) + self.assert_rejected( + mutated, "updater receipts, digests, sizes, and signatures agree" + ) + + def test_every_updater_signature_is_verified_with_the_embedded_public_key(self) -> None: + mutations = ( + ( + "sudo apt-get install --yes --no-install-recommends minisign", + "true # minisign verifier bypassed", + ), + ( + 'pubkey = config["plugins"]["updater"]["pubkey"]', + 'pubkey = "attacker-controlled-key"', + ), + ( + 'minisign -Vm "${mac_updaters[0]}"', + 'true # macOS updater signature bypassed', + ), + ( + 'minisign -Vm "${msis[0]}"', + 'true # MSI updater signature bypassed', + ), + ( + 'minisign -Vm "${exes[0]}"', + 'true # NSIS updater signature bypassed', + ), + ( + 'minisign -Vm "${mac_attestations[0]}"', + 'true # macOS attestation signature bypassed', + ), + ( + 'minisign -Vm "${msi_attestations[0]}"', + 'true # MSI attestation signature bypassed', + ), + ( + 'minisign -Vm "${nsis_attestations[0]}"', + 'true # NSIS attestation signature bypassed', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate(old, new), + "updater signatures verify against embedded public key", + ) + + def test_build_jobs_create_and_sign_release_bound_attestations(self) -> None: + mutations = ( + ("--platform darwin-aarch64", "--platform darwin-x86_64"), + ("--platform windows-x86_64-msi", "--platform windows-aarch64-msi"), + ("--platform windows-x86_64-nsis", "--platform windows-aarch64-nsis"), + ( + '--source-sha "$TARGET_SHA"', + '--source-sha "0000000000000000000000000000000000000000"', + ), + ( + './web/node_modules/.bin/tauri signer sign "$attestation"', + 'echo "attestation signing bypassed"', + ), + ( + '& .\\web\\node_modules\\.bin\\tauri.cmd signer sign $msiAttestation', + "Write-Output 'MSI attestation signing bypassed'", + ), + ( + '& .\\web\\node_modules\\.bin\\tauri.cmd signer sign $nsisAttestation', + "Write-Output 'NSIS attestation signing bypassed'", + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate(old, new), + "signed updater attestations bind release identity and payload", + ) + + def test_publish_rejects_forged_attestation_fields_or_payload_hash(self) -> None: + mutations = ( + ('"schemaVersion": 1,', '"schemaVersion": 2,'), + ( + 'if attestation.get("sha256") != sha256(artifact):', + 'if attestation.get("sha256") == "":', + ), + ( + 'if attestation.get("sourceSha") != source_sha:', + 'if not attestation.get("sourceSha"):', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate_last(old, new), + "attestations bind exact release identity and updater bytes", + ) + def test_release_must_be_draft_before_upload_and_publish(self) -> None: mutated = self.mutate( "--draft --prerelease --latest=false", @@ -93,6 +328,34 @@ def test_release_must_generate_and_verify_sha256sums(self) -> None: ) self.assert_rejected(mutated, "SHA256SUMS covers and verifies every payload") + def test_draft_assets_are_downloaded_and_verified_before_publication(self) -> None: + mutations = ( + ( + 'gh release download "$RELEASE_TAG" --dir "$PUBLISH_ROOT/verified-draft"', + 'true # draft download bypassed', + ), + ( + 'if remote_sizes != local_sizes:', + 'if set(remote_sizes) != set(local_sizes):', + ), + ( + 'cmp "$PUBLISH_ROOT/assets/SHA256SUMS" "$PUBLISH_ROOT/verified-draft/SHA256SUMS"', + 'true # draft checksum asset comparison bypassed', + ), + ( + 'cd "$PUBLISH_ROOT/verified-draft"\n' + ' sha256sum --check SHA256SUMS', + 'cd "$PUBLISH_ROOT/verified-draft"\n' + ' sha256sum --status SHA256SUMS || true', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate(old, new), + "draft assets verified by exact name, size, and SHA-256", + ) + def test_required_work_cannot_ignore_failures(self) -> None: mutated = self.mutate( " - name: Rust workspace clippy\n", @@ -367,13 +630,13 @@ def test_macos_package_command_cannot_be_faked_by_an_echo_string(self) -> None: " run: >-\n" " ./web/node_modules/.bin/tauri build --ci\n" " --target aarch64-apple-darwin --bundles app,dmg\n" - " --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'\n" + " --config '{\"bundle\":{\"createUpdaterArtifacts\":true,\"macOS\":{\"signingIdentity\":\"-\"}}}'\n" ) decoy = ( " run: >-\n" " echo './web/node_modules/.bin/tauri build --ci\n" " --target aarch64-apple-darwin --bundles app,dmg\n" - " --config {\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'\n" + " --config {\"bundle\":{\"createUpdaterArtifacts\":true,\"macOS\":{\"signingIdentity\":\"-\"}}}'\n" ) self.assert_rejected( self.mutate(original, decoy), "complete ad-hoc macOS ARM64 bundle gate" @@ -381,8 +644,8 @@ def test_macos_package_command_cannot_be_faked_by_an_echo_string(self) -> None: def test_windows_package_command_cannot_be_faked_by_a_string(self) -> None: mutated = self.mutate( - " & .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis\n", - " $decoy = '& .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis'\n", + " & .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis --config '{\"bundle\":{\"createUpdaterArtifacts\":true}}'\n", + " $decoy = '& .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis --config {\"bundle\":{\"createUpdaterArtifacts\":true}}'\n", ) self.assert_rejected(mutated, "complete Windows x64 installer gate") @@ -393,7 +656,7 @@ def test_publish_command_cannot_be_faked_by_echo(self) -> None: ) self.assert_rejected(mutated, "verified prerelease publication") - def test_repository_metadata_is_beta3_and_release_notes_exist(self) -> None: + def test_repository_metadata_is_beta4_and_release_notes_exist(self) -> None: self.assertEqual([], contract.validate_repository_metadata(REPOSITORY_ROOT)) @@ -405,21 +668,21 @@ def make_repository(self) -> tuple[tempfile.TemporaryDirectory[str], Path]: (root / "web").mkdir() (root / "docs" / "releases").mkdir(parents=True) (root / "Cargo.toml").write_text( - '[workspace.package]\nversion = "1.0.0-beta.3"\n', encoding="utf-8" + '[workspace.package]\nversion = "1.0.0-beta.4"\n', encoding="utf-8" ) (root / "src-tauri" / "tauri.conf.json").write_text( json.dumps( { - "version": "1.0.0-beta.3", - "bundle": {"windows": {"wix": {"version": "1.0.0.3"}}}, + "version": "1.0.0-beta.4", + "bundle": {"windows": {"wix": {"version": "1.0.0.4"}}}, } ), encoding="utf-8", ) (root / "web" / "package.json").write_text( - json.dumps({"version": "1.0.0-beta.3"}), encoding="utf-8" + json.dumps({"version": "1.0.0-beta.4"}), encoding="utf-8" ) - (root / "docs" / "releases" / "1.0.0-beta.3.md").write_text( + (root / "docs" / "releases" / "1.0.0-beta.4.md").write_text( "release notes\n", encoding="utf-8" ) return temporary, root @@ -438,7 +701,7 @@ def test_non_object_json_metadata_returns_a_contract_error(self) -> None: def test_release_notes_io_error_returns_a_contract_error(self) -> None: temporary, root = self.make_repository() self.addCleanup(temporary.cleanup) - notes = root / "docs" / "releases" / "1.0.0-beta.3.md" + notes = root / "docs" / "releases" / "1.0.0-beta.4.md" real_read_text = Path.read_text def fail_notes(path: Path, *args, **kwargs): @@ -448,7 +711,7 @@ def fail_notes(path: Path, *args, **kwargs): with mock.patch.object(Path, "read_text", autospec=True, side_effect=fail_notes): self.assertEqual( - ["Beta 3 release notes exist"], + ["Beta 4 release notes exist"], contract.validate_repository_metadata(root), ) @@ -458,14 +721,14 @@ def test_wrong_windows_installer_version_returns_a_contract_error(self) -> None: (root / "src-tauri" / "tauri.conf.json").write_text( json.dumps( { - "version": "1.0.0-beta.3", - "bundle": {"windows": {"wix": {"version": "1.0.0.2"}}}, + "version": "1.0.0-beta.4", + "bundle": {"windows": {"wix": {"version": "1.0.0.3"}}}, } ), encoding="utf-8", ) self.assertEqual( - ["Windows installer version is 1.0.0.3"], + ["Windows installer version is 1.0.0.4"], contract.validate_repository_metadata(root), ) diff --git a/scripts/test_write_updater_attestation.py b/scripts/test_write_updater_attestation.py new file mode 100644 index 00000000..986cbaed --- /dev/null +++ b/scripts/test_write_updater_attestation.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = REPOSITORY_ROOT / "scripts" / "write_updater_attestation.py" +SOURCE_SHA = "a" * 40 + + +def load_generator(): + if not MODULE_PATH.is_file(): + return None + spec = importlib.util.spec_from_file_location( + "write_updater_attestation", MODULE_PATH + ) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class UpdaterAttestationTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.artifact = self.root / "OpenTake.app.tar.gz" + self.artifact.write_bytes(b"signed updater payload") + self.output = self.root / "OpenTake.app.tar.gz.attestation.json" + + def generator(self): + module = load_generator() + self.assertIsNotNone(module, "updater attestation generator must exist") + return module + + def write(self): + return self.generator().write_attestation( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + platform="darwin-aarch64", + artifact=self.artifact, + output=self.output, + ) + + def test_writes_exact_canonical_attestation_bound_to_payload(self) -> None: + value = self.write() + expected = { + "schemaVersion": 1, + "repository": "appergb/OpenTake", + "tag": "v1.0.0-beta.3", + "version": "1.0.0-beta.3", + "sourceSha": SOURCE_SHA, + "platform": "darwin-aarch64", + "assetName": self.artifact.name, + "size": self.artifact.stat().st_size, + "sha256": hashlib.sha256(self.artifact.read_bytes()).hexdigest(), + } + self.assertEqual(expected, value) + self.assertEqual( + json.dumps(expected, sort_keys=True, separators=(",", ":")) + "\n", + self.output.read_text(encoding="utf-8"), + ) + + def test_rejects_wrong_repository_tag_version_source_or_platform(self) -> None: + generator = self.generator() + mutations = ( + {"repository": "attacker/OpenTake"}, + {"tag": "v1.0.0-beta.2"}, + {"version": "1.0.0"}, + { + "tag": "v1.0.0-beta.3+hotfix.1", + "version": "1.0.0-beta.3+hotfix.1", + }, + {"source_sha": "A" * 40}, + {"platform": "darwin-x86_64"}, + {"platform": "windows-x86_64"}, + ) + defaults = { + "repository": "appergb/OpenTake", + "tag": "v1.0.0-beta.3", + "version": "1.0.0-beta.3", + "source_sha": SOURCE_SHA, + "platform": "darwin-aarch64", + "artifact": self.artifact, + "output": self.output, + } + for mutation in mutations: + with self.subTest(mutation=mutation): + with self.assertRaisesRegex( + generator.AttestationError, + "repository|tag|version|source SHA|platform", + ): + generator.write_attestation(**(defaults | mutation)) + + def test_accepts_installer_specific_windows_platforms_and_shapes(self) -> None: + generator = self.generator() + for platform, filename in ( + ("windows-x86_64-msi", "OpenTake_1.0.0-beta.3_x64.msi"), + ("windows-x86_64-nsis", "OpenTake_1.0.0-beta.3_x64-setup.exe"), + ): + artifact = self.root / filename + artifact.write_bytes(platform.encode("ascii")) + with self.subTest(platform=platform): + value = generator.write_attestation( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + platform=platform, + artifact=artifact, + output=Path(f"{artifact}.attestation.json"), + ) + self.assertEqual(platform, value["platform"]) + + def test_rejects_wrong_output_name_and_empty_or_symlink_payload(self) -> None: + generator = self.generator() + wrong_output = self.root / "latest.json" + with self.assertRaisesRegex(generator.AttestationError, "filename"): + generator.write_attestation( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + platform="darwin-aarch64", + artifact=self.artifact, + output=wrong_output, + ) + empty = self.root / "empty.app.tar.gz" + empty.write_bytes(b"") + for artifact in (empty, self.root / "linked.app.tar.gz"): + if artifact.name.startswith("linked"): + artifact.symlink_to(self.artifact) + with self.subTest(artifact=artifact.name): + with self.assertRaisesRegex( + generator.AttestationError, "regular|empty" + ): + generator.write_attestation( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + platform="darwin-aarch64", + artifact=artifact, + output=Path(f"{artifact}.attestation.json"), + ) + + def test_rejects_artifact_names_that_require_url_percent_encoding(self) -> None: + generator = self.generator() + artifact = self.root / "Open Take.app.tar.gz" + artifact.write_bytes(b"unsafe URL filename") + with self.assertRaisesRegex(generator.AttestationError, "filename|URL"): + generator.write_attestation( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + platform="darwin-aarch64", + artifact=artifact, + output=Path(f"{artifact}.attestation.json"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_write_updater_manifest.py b/scripts/test_write_updater_manifest.py new file mode 100644 index 00000000..945f07c8 --- /dev/null +++ b/scripts/test_write_updater_manifest.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = REPOSITORY_ROOT / "scripts" / "write_updater_manifest.py" +SOURCE_SHA = "a" * 40 + + +def load_generator(): + if not MODULE_PATH.is_file(): + return None + spec = importlib.util.spec_from_file_location("write_updater_manifest", MODULE_PATH) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class UpdaterManifestTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.mac = self.root / "OpenTake.app.tar.gz" + self.mac_sig = self.root / "OpenTake.app.tar.gz.sig" + self.mac_attestation = self.root / "OpenTake.app.tar.gz.attestation.json" + self.mac_attestation_sig = self.root / "OpenTake.app.tar.gz.attestation.json.sig" + self.msi = self.root / "OpenTake_1.0.0-beta.3_x64.msi" + self.msi_sig = self.root / "OpenTake_1.0.0-beta.3_x64.msi.sig" + self.msi_attestation = self.root / ( + "OpenTake_1.0.0-beta.3_x64.msi.attestation.json" + ) + self.msi_attestation_sig = self.root / ( + "OpenTake_1.0.0-beta.3_x64.msi.attestation.json.sig" + ) + self.nsis = self.root / "OpenTake_1.0.0-beta.3_x64-setup.exe" + self.nsis_sig = self.root / "OpenTake_1.0.0-beta.3_x64-setup.exe.sig" + self.nsis_attestation = self.root / ( + "OpenTake_1.0.0-beta.3_x64-setup.exe.attestation.json" + ) + self.nsis_attestation_sig = self.root / ( + "OpenTake_1.0.0-beta.3_x64-setup.exe.attestation.json.sig" + ) + self.mac.write_bytes(b"mac updater bytes") + self.mac_sig.write_text("mac-signature\n", encoding="utf-8") + self.msi.write_bytes(b"windows MSI updater bytes") + self.msi_sig.write_text("windows-msi-signature\n", encoding="utf-8") + self.nsis.write_bytes(b"windows NSIS updater bytes") + self.nsis_sig.write_text("windows-nsis-signature\n", encoding="utf-8") + self.write_attestation( + self.mac, self.mac_attestation, "darwin-aarch64" + ) + self.mac_attestation_sig.write_text( + "mac-attestation-signature\n", encoding="utf-8" + ) + self.write_attestation( + self.msi, self.msi_attestation, "windows-x86_64-msi" + ) + self.msi_attestation_sig.write_text( + "windows-msi-attestation-signature\n", encoding="utf-8" + ) + self.write_attestation( + self.nsis, self.nsis_attestation, "windows-x86_64-nsis" + ) + self.nsis_attestation_sig.write_text( + "windows-nsis-attestation-signature\n", encoding="utf-8" + ) + self.output = self.root / "updater-v1.0.0-beta.3.json" + + def write_attestation( + self, artifact: Path, output: Path, platform: str + ) -> None: + value = { + "schemaVersion": 1, + "repository": "appergb/OpenTake", + "tag": "v1.0.0-beta.3", + "version": "1.0.0-beta.3", + "sourceSha": SOURCE_SHA, + "platform": platform, + "assetName": artifact.name, + "size": artifact.stat().st_size, + "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), + } + output.write_text( + json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + def generator(self): + module = load_generator() + self.assertIsNotNone(module, "updater manifest generator must exist") + return module + + def artifacts(self) -> dict[str, tuple[Path, Path, Path, Path]]: + return { + "darwin-aarch64": ( + self.mac, + self.mac_sig, + self.mac_attestation, + self.mac_attestation_sig, + ), + "windows-x86_64-msi": ( + self.msi, + self.msi_sig, + self.msi_attestation, + self.msi_attestation_sig, + ), + "windows-x86_64-nsis": ( + self.nsis, + self.nsis_sig, + self.nsis_attestation, + self.nsis_attestation_sig, + ), + } + + def write(self): + return self.generator().write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + output=self.output, + ) + + def test_writes_exact_tag_specific_tauri_v2_manifest(self) -> None: + self.write() + self.assertEqual( + { + "version": "1.0.0-beta.3", + "platforms": { + "darwin-aarch64": { + "attestationSignature": "mac-attestation-signature", + "attestationUrl": ( + "https://github.com/appergb/OpenTake/releases/download/" + "v1.0.0-beta.3/" + "OpenTake.app.tar.gz.attestation.json" + ), + "signature": "mac-signature", + "url": ( + "https://github.com/appergb/OpenTake/releases/download/" + "v1.0.0-beta.3/OpenTake.app.tar.gz" + ), + }, + "windows-x86_64-msi": { + "attestationSignature": "windows-msi-attestation-signature", + "attestationUrl": ( + "https://github.com/appergb/OpenTake/releases/download/" + "v1.0.0-beta.3/" + "OpenTake_1.0.0-beta.3_x64.msi.attestation.json" + ), + "signature": "windows-msi-signature", + "url": ( + "https://github.com/appergb/OpenTake/releases/download/" + "v1.0.0-beta.3/" + "OpenTake_1.0.0-beta.3_x64.msi" + ), + }, + "windows-x86_64-nsis": { + "attestationSignature": "windows-nsis-attestation-signature", + "attestationUrl": ( + "https://github.com/appergb/OpenTake/releases/download/" + "v1.0.0-beta.3/" + "OpenTake_1.0.0-beta.3_x64-setup.exe.attestation.json" + ), + "signature": "windows-nsis-signature", + "url": ( + "https://github.com/appergb/OpenTake/releases/download/" + "v1.0.0-beta.3/" + "OpenTake_1.0.0-beta.3_x64-setup.exe" + ), + }, + }, + }, + json.loads(self.output.read_text(encoding="utf-8")), + ) + + def test_rejects_any_repository_other_than_fixed_opentake_origin(self) -> None: + generator = self.generator() + with self.assertRaisesRegex(generator.ManifestError, "repository"): + generator.write_manifest( + repository="attacker/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + output=self.output, + ) + + def test_rejects_tag_version_mismatch_and_non_beta_version(self) -> None: + generator = self.generator() + for tag, version in ( + ("v1.0.0-beta.2", "1.0.0-beta.3"), + ("v1.0.0", "1.0.0"), + ("main", "1.0.0-beta.3"), + ): + with self.subTest(tag=tag, version=version): + with self.assertRaisesRegex(generator.ManifestError, "tag|prerelease"): + generator.write_manifest( + repository="appergb/OpenTake", + tag=tag, + version=version, + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + output=self.output, + ) + + def test_rejects_semver_build_metadata_before_forming_asset_urls(self) -> None: + generator = self.generator() + with self.assertRaisesRegex(generator.ManifestError, "version|metadata"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3+hotfix.1", + version="1.0.0-beta.3+hotfix.1", + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + output=self.root / "updater-v1.0.0-beta.3+hotfix.1.json", + ) + + def test_requires_exact_real_release_platforms(self) -> None: + generator = self.generator() + for artifacts in ( + {"darwin-aarch64": self.artifacts()["darwin-aarch64"]}, + { + "darwin-aarch64": self.artifacts()["darwin-aarch64"], + "windows-x86_64": self.artifacts()["windows-x86_64-nsis"], + }, + { + **self.artifacts(), + "darwin-x86_64": self.artifacts()["darwin-aarch64"], + }, + ): + with self.subTest(platforms=sorted(artifacts)): + with self.assertRaisesRegex(generator.ManifestError, "platform"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=artifacts, + output=self.output, + ) + + def test_requires_tauri_v2_updater_asset_shapes(self) -> None: + generator = self.generator() + bad_mac = self.root / "OpenTake.dmg" + bad_mac.write_bytes(b"dmg") + bad_msi = self.root / "OpenTake-msi.exe" + bad_msi.write_bytes(b"exe") + bad_nsis = self.root / "OpenTake-nsis.msi" + bad_nsis.write_bytes(b"msi") + for platform, artifact in ( + ("darwin-aarch64", bad_mac), + ("windows-x86_64-msi", bad_msi), + ("windows-x86_64-nsis", bad_nsis), + ): + artifacts = self.artifacts() + signature = self.root / f"{artifact.name}.sig" + signature.write_text("signature\n", encoding="utf-8") + current = artifacts[platform] + artifacts[platform] = (artifact, signature, current[2], current[3]) + with self.subTest(platform=platform): + with self.assertRaisesRegex(generator.ManifestError, "artifact"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=artifacts, + output=self.output, + ) + + def test_rejects_artifact_names_that_require_url_percent_encoding(self) -> None: + generator = self.generator() + artifact = self.root / "Open Take_1.0.0-beta.3_x64.msi" + signature = Path(f"{artifact}.sig") + attestation = Path(f"{artifact}.attestation.json") + attestation_signature = Path(f"{attestation}.sig") + artifact.write_bytes(b"unsafe URL filename") + signature.write_text("signature\n", encoding="utf-8") + self.write_attestation(artifact, attestation, "windows-x86_64-msi") + attestation_signature.write_text("attestation-signature\n", encoding="utf-8") + artifacts = self.artifacts() + artifacts["windows-x86_64-msi"] = ( + artifact, + signature, + attestation, + attestation_signature, + ) + with self.assertRaisesRegex(generator.ManifestError, "filename|URL"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=artifacts, + output=self.output, + ) + + def test_requires_nonempty_companion_signature_files(self) -> None: + generator = self.generator() + wrong_signature = self.root / "detached.sig" + wrong_signature.write_text("signature", encoding="utf-8") + empty_signature = self.root / f"{self.mac.name}.sig" + empty_signature.write_text("", encoding="utf-8") + for signature in (wrong_signature, empty_signature): + artifacts = self.artifacts() + artifacts["darwin-aarch64"] = ( + self.mac, + signature, + self.mac_attestation, + self.mac_attestation_sig, + ) + with self.subTest(signature=signature.name): + with self.assertRaisesRegex(generator.ManifestError, "signature"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=artifacts, + output=self.output, + ) + + def test_validator_rejects_forged_manifest_fields(self) -> None: + generator = self.generator() + manifest = self.write() + mutations = ( + ("http URL", lambda value: value["platforms"]["darwin-aarch64"].update( + url="http://github.com/appergb/OpenTake/releases/download/v1.0.0-beta.3/OpenTake.app.tar.gz" + )), + ("wrong repository", lambda value: value["platforms"]["darwin-aarch64"].update( + url="https://github.com/attacker/OpenTake/releases/download/v1.0.0-beta.3/OpenTake.app.tar.gz" + )), + ("wrong tag", lambda value: value["platforms"]["darwin-aarch64"].update( + url="https://github.com/appergb/OpenTake/releases/download/v1.0.0-beta.2/OpenTake.app.tar.gz" + )), + ("wrong asset", lambda value: value["platforms"]["darwin-aarch64"].update( + url="https://github.com/appergb/OpenTake/releases/download/v1.0.0-beta.3/other.app.tar.gz" + )), + ("wrong version", lambda value: value.update(version="1.0.0-beta.2")), + ("forged signature", lambda value: value["platforms"]["darwin-aarch64"].update( + signature="forged" + )), + ("wrong attestation URL", lambda value: value["platforms"]["darwin-aarch64"].update( + attestationUrl="https://github.com/appergb/OpenTake/releases/download/v1.0.0-beta.3/forged.attestation.json" + )), + ("forged attestation signature", lambda value: value["platforms"]["darwin-aarch64"].update( + attestationSignature="forged" + )), + ) + for name, mutate in mutations: + with self.subTest(mutation=name): + value = json.loads(json.dumps(manifest)) + mutate(value) + with self.assertRaisesRegex( + generator.ManifestError, "version|URL|signature" + ): + generator.validate_manifest( + value, + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + ) + + def test_validator_rejects_missing_or_extra_architecture(self) -> None: + generator = self.generator() + manifest = self.write() + without_windows = json.loads(json.dumps(manifest)) + del without_windows["platforms"]["windows-x86_64-msi"] + with_extra = json.loads(json.dumps(manifest)) + with_extra["platforms"]["darwin-x86_64"] = dict( + with_extra["platforms"]["darwin-aarch64"] + ) + for value in (without_windows, with_extra): + with self.assertRaisesRegex(generator.ManifestError, "platform"): + generator.validate_manifest( + value, + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + ) + + def test_rejects_attestation_not_bound_to_release_and_payload(self) -> None: + generator = self.generator() + original = json.loads(self.mac_attestation.read_text(encoding="utf-8")) + mutations = ( + {"schemaVersion": 2}, + {"schemaVersion": True}, + {"repository": "attacker/OpenTake"}, + {"tag": "v1.0.0-beta.2"}, + {"version": "1.0.0-beta.2"}, + {"sourceSha": "b" * 40}, + {"platform": "darwin-x86_64"}, + {"assetName": "old.app.tar.gz"}, + {"size": 1}, + {"sha256": "0" * 64}, + {"extra": True}, + ) + for mutation in mutations: + with self.subTest(mutation=mutation): + forged = original | mutation + self.mac_attestation.write_text( + json.dumps(forged, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(generator.ManifestError, "attestation"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=self.artifacts(), + output=self.output, + ) + self.write_attestation( + self.mac, self.mac_attestation, "darwin-aarch64" + ) + + def test_rejects_noncompanion_or_empty_attestation_signature(self) -> None: + generator = self.generator() + wrong = self.root / "detached-attestation.sig" + wrong.write_text("signature", encoding="utf-8") + empty = self.mac_attestation_sig + for signature in (wrong, empty): + with self.subTest(signature=signature.name): + if signature == empty: + empty.write_text("", encoding="utf-8") + artifacts = self.artifacts() + artifacts["darwin-aarch64"] = ( + self.mac, + self.mac_sig, + self.mac_attestation, + signature, + ) + with self.assertRaisesRegex(generator.ManifestError, "signature"): + generator.write_manifest( + repository="appergb/OpenTake", + tag="v1.0.0-beta.3", + version="1.0.0-beta.3", + source_sha=SOURCE_SHA, + artifacts=artifacts, + output=self.output, + ) + self.mac_attestation_sig.write_text( + "mac-attestation-signature\n", encoding="utf-8" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/write_updater_attestation.py b/scripts/write_updater_attestation.py new file mode 100644 index 00000000..d585d851 --- /dev/null +++ b/scripts/write_updater_attestation.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Write a deterministic signed-updater identity attestation.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile + + +EXPECTED_REPOSITORY = "appergb/OpenTake" +EXPECTED_PLATFORMS = frozenset( + {"darwin-aarch64", "windows-x86_64-msi", "windows-x86_64-nsis"} +) +ATTESTATION_KEYS = frozenset( + { + "schemaVersion", + "repository", + "tag", + "version", + "sourceSha", + "platform", + "assetName", + "size", + "sha256", + } +) +_NUMERIC = r"(?:0|[1-9][0-9]*)" +_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +_SEMVER_RE = re.compile( + rf"^{_NUMERIC}\.{_NUMERIC}\.{_NUMERIC}" + rf"(?:-{_IDENTIFIER}(?:\.{_IDENTIFIER})*)$" +) +_SOURCE_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_SAFE_ASSET_NAME_RE = re.compile(r"^[0-9A-Za-z._-]+$") + + +class AttestationError(ValueError): + """Attestation input does not satisfy the updater release contract.""" + + +def _validate_identity( + repository: str, + tag: str, + version: str, + source_sha: str, + platform: str, +) -> None: + if repository != EXPECTED_REPOSITORY: + raise AttestationError(f"unexpected attestation repository: {repository}") + if _SEMVER_RE.fullmatch(version) is None: + raise AttestationError(f"attestation version must be a SemVer prerelease: {version}") + if tag != f"v{version}": + raise AttestationError(f"attestation tag/version mismatch: {tag} != v{version}") + if _SOURCE_SHA_RE.fullmatch(source_sha) is None: + raise AttestationError(f"invalid attestation source SHA: {source_sha}") + if platform not in EXPECTED_PLATFORMS: + raise AttestationError(f"unexpected attestation platform: {platform}") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_attestation( + *, + repository: str, + tag: str, + version: str, + source_sha: str, + platform: str, + artifact: Path, +) -> dict[str, object]: + _validate_identity(repository, tag, version, source_sha, platform) + artifact = Path(artifact) + if artifact.is_symlink() or not artifact.is_file(): + raise AttestationError(f"updater artifact must be a regular file: {artifact}") + if artifact.stat().st_size <= 0: + raise AttestationError(f"updater artifact is empty: {artifact}") + if _SAFE_ASSET_NAME_RE.fullmatch(artifact.name) is None: + raise AttestationError( + f"updater artifact filename is not URL-safe: {artifact.name}" + ) + if platform == "darwin-aarch64" and not artifact.name.endswith(".app.tar.gz"): + raise AttestationError(f"unexpected macOS updater artifact: {artifact.name}") + if platform == "windows-x86_64-msi" and not artifact.name.endswith(".msi"): + raise AttestationError(f"unexpected Windows MSI updater artifact: {artifact.name}") + if platform == "windows-x86_64-nsis" and not artifact.name.endswith(".exe"): + raise AttestationError(f"unexpected Windows NSIS updater artifact: {artifact.name}") + return { + "schemaVersion": 1, + "repository": repository, + "tag": tag, + "version": version, + "sourceSha": source_sha, + "platform": platform, + "assetName": artifact.name, + "size": artifact.stat().st_size, + "sha256": _sha256(artifact), + } + + +def write_attestation( + *, + repository: str, + tag: str, + version: str, + source_sha: str, + platform: str, + artifact: Path, + output: Path, +) -> dict[str, object]: + artifact = Path(artifact) + output = Path(output) + if output != Path(f"{artifact}.attestation.json"): + raise AttestationError(f"unexpected attestation filename: {output.name}") + value = build_attestation( + repository=repository, + tag=tag, + version=version, + source_sha=source_sha, + platform=platform, + artifact=artifact, + ) + if set(value) != ATTESTATION_KEYS: + raise AttestationError("attestation field set is not exact") + output.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=output.parent, + prefix=f".{output.name}.", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(json.dumps(value, sort_keys=True, separators=(",", ":"))) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(0o644) + os.replace(temporary, output) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + written = json.loads(output.read_text(encoding="utf-8")) + if written != value or set(written) != ATTESTATION_KEYS: + raise AttestationError("written attestation failed exact reload validation") + return value + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--platform", required=True) + parser.add_argument("--artifact", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + options = parser.parse_args() + write_attestation( + repository=options.repository, + tag=options.tag, + version=options.version, + source_sha=options.source_sha, + platform=options.platform, + artifact=options.artifact, + output=options.output, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/write_updater_manifest.py b/scripts/write_updater_manifest.py new file mode 100644 index 00000000..63766a07 --- /dev/null +++ b/scripts/write_updater_manifest.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Write and verify the tag-specific Tauri v2 updater manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +from typing import Mapping +from urllib.parse import quote + + +EXPECTED_REPOSITORY = "appergb/OpenTake" +EXPECTED_PLATFORMS = frozenset( + {"darwin-aarch64", "windows-x86_64-msi", "windows-x86_64-nsis"} +) +ATTESTATION_KEYS = frozenset( + { + "schemaVersion", + "repository", + "tag", + "version", + "sourceSha", + "platform", + "assetName", + "size", + "sha256", + } +) +_NUMERIC = r"(?:0|[1-9][0-9]*)" +_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +_SEMVER_RE = re.compile( + rf"^{_NUMERIC}\.{_NUMERIC}\.{_NUMERIC}" + rf"(?:-{_IDENTIFIER}(?:\.{_IDENTIFIER})*)$" +) +_SOURCE_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_SAFE_ASSET_NAME_RE = re.compile(r"^[0-9A-Za-z._-]+$") + + +class ManifestError(ValueError): + """The updater manifest inputs do not satisfy the release trust contract.""" + + +def _validate_release_identity( + repository: str, tag: str, version: str, source_sha: str +) -> None: + if repository != EXPECTED_REPOSITORY: + raise ManifestError(f"unexpected updater repository: {repository}") + if _SEMVER_RE.fullmatch(version) is None: + raise ManifestError(f"updater version must be a SemVer prerelease: {version}") + if tag != f"v{version}": + raise ManifestError(f"updater tag/version mismatch: {tag} != v{version}") + if _SOURCE_SHA_RE.fullmatch(source_sha) is None: + raise ManifestError(f"invalid updater source SHA: {source_sha}") + + +def _read_signature(artifact: Path, signature: Path) -> str: + if signature != Path(f"{artifact}.sig"): + raise ManifestError(f"signature is not the artifact companion: {signature}") + for kind, path in (("artifact", artifact), ("signature", signature)): + if path.is_symlink() or not path.is_file(): + raise ManifestError(f"{kind} must be a regular file: {path}") + if artifact.stat().st_size <= 0: + raise ManifestError(f"updater artifact is empty: {artifact}") + if signature.stat().st_size > 16 * 1024: + raise ManifestError(f"updater signature is unexpectedly large: {signature}") + try: + value = signature.read_text(encoding="utf-8").strip() + except UnicodeError as error: + raise ManifestError(f"updater signature is not UTF-8: {signature}") from error + if not value or "\x00" in value: + raise ManifestError(f"updater signature is empty or malformed: {signature}") + return value + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _validate_attestation( + *, + attestation_path: Path, + artifact: Path, + repository: str, + tag: str, + version: str, + source_sha: str, + platform: str, +) -> None: + if attestation_path != Path(f"{artifact}.attestation.json"): + raise ManifestError( + f"attestation is not the artifact companion: {attestation_path}" + ) + if attestation_path.is_symlink() or not attestation_path.is_file(): + raise ManifestError(f"attestation must be a regular file: {attestation_path}") + try: + attestation = json.loads(attestation_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError) as error: + raise ManifestError(f"attestation is not valid JSON: {attestation_path}") from error + if not isinstance(attestation, dict) or set(attestation) != ATTESTATION_KEYS: + raise ManifestError("attestation field set is not exact") + string_fields = ( + "repository", + "tag", + "version", + "sourceSha", + "platform", + "assetName", + "sha256", + ) + if ( + type(attestation["schemaVersion"]) is not int + or type(attestation["size"]) is not int + or attestation["size"] <= 0 + or any(type(attestation[field]) is not str for field in string_fields) + ): + raise ManifestError("attestation field types are not strict") + expected = { + "schemaVersion": 1, + "repository": repository, + "tag": tag, + "version": version, + "sourceSha": source_sha, + "platform": platform, + "assetName": artifact.name, + "size": artifact.stat().st_size, + "sha256": _sha256(artifact), + } + if attestation != expected: + raise ManifestError( + f"attestation does not bind exact release and payload: {attestation_path}" + ) + + +def _validate_artifacts( + artifacts: Mapping[str, tuple[Path, Path, Path, Path]], + *, + repository: str, + tag: str, + version: str, + source_sha: str, +) -> dict[str, tuple[Path, str, Path, str]]: + if set(artifacts) != EXPECTED_PLATFORMS: + raise ManifestError( + f"updater platforms must be exactly {sorted(EXPECTED_PLATFORMS)}" + ) + validated: dict[str, tuple[Path, str, Path, str]] = {} + for platform in sorted(EXPECTED_PLATFORMS): + artifact, signature, attestation, attestation_signature = artifacts[platform] + artifact = Path(artifact) + signature = Path(signature) + attestation = Path(attestation) + attestation_signature = Path(attestation_signature) + if _SAFE_ASSET_NAME_RE.fullmatch(artifact.name) is None: + raise ManifestError( + f"updater artifact filename is not URL-safe: {artifact.name}" + ) + if platform == "darwin-aarch64" and not artifact.name.endswith( + ".app.tar.gz" + ): + raise ManifestError(f"unexpected macOS updater artifact: {artifact.name}") + if platform == "windows-x86_64-msi" and not artifact.name.endswith(".msi"): + raise ManifestError(f"unexpected Windows MSI updater artifact: {artifact.name}") + if platform == "windows-x86_64-nsis" and not artifact.name.endswith(".exe"): + raise ManifestError(f"unexpected Windows NSIS updater artifact: {artifact.name}") + package_signature = _read_signature(artifact, signature) + _validate_attestation( + attestation_path=attestation, + artifact=artifact, + repository=repository, + tag=tag, + version=version, + source_sha=source_sha, + platform=platform, + ) + attestation_signature_value = _read_signature( + attestation, attestation_signature + ) + validated[platform] = ( + artifact, + package_signature, + attestation, + attestation_signature_value, + ) + return validated + + +def _asset_url(repository: str, tag: str, artifact: Path) -> str: + return ( + f"https://github.com/{repository}/releases/download/" + f"{quote(tag, safe='')}/{quote(artifact.name, safe='')}" + ) + + +def build_manifest( + *, + repository: str, + tag: str, + version: str, + source_sha: str, + artifacts: Mapping[str, tuple[Path, Path, Path, Path]], +) -> dict[str, object]: + _validate_release_identity(repository, tag, version, source_sha) + validated = _validate_artifacts( + artifacts, + repository=repository, + tag=tag, + version=version, + source_sha=source_sha, + ) + return { + "version": version, + "platforms": { + platform: { + "attestationSignature": attestation_signature, + "attestationUrl": _asset_url(repository, tag, attestation), + "signature": package_signature, + "url": _asset_url(repository, tag, artifact), + } + for platform, ( + artifact, + package_signature, + attestation, + attestation_signature, + ) in sorted(validated.items()) + }, + } + + +def validate_manifest( + manifest: object, + *, + repository: str, + tag: str, + version: str, + source_sha: str, + artifacts: Mapping[str, tuple[Path, Path, Path, Path]], +) -> None: + expected = build_manifest( + repository=repository, + tag=tag, + version=version, + source_sha=source_sha, + artifacts=artifacts, + ) + if not isinstance(manifest, dict): + raise ManifestError("updater manifest must be a JSON object") + if manifest.get("version") != version: + raise ManifestError("updater manifest version does not match its tag") + platforms = manifest.get("platforms") + if not isinstance(platforms, dict) or set(platforms) != EXPECTED_PLATFORMS: + raise ManifestError("updater manifest platform set is not exact") + for platform in sorted(EXPECTED_PLATFORMS): + entry = platforms.get(platform) + expected_entry = expected["platforms"][platform] # type: ignore[index] + if not isinstance(entry, dict): + raise ManifestError(f"updater platform entry is malformed: {platform}") + if entry.get("url") != expected_entry["url"]: + raise ManifestError(f"updater artifact URL mismatch: {platform}") + if entry.get("signature") != expected_entry["signature"]: + raise ManifestError(f"updater signature mismatch: {platform}") + if entry.get("attestationUrl") != expected_entry["attestationUrl"]: + raise ManifestError(f"updater attestation URL mismatch: {platform}") + if ( + entry.get("attestationSignature") + != expected_entry["attestationSignature"] + ): + raise ManifestError(f"updater attestation signature mismatch: {platform}") + if set(entry) != { + "url", + "signature", + "attestationUrl", + "attestationSignature", + }: + raise ManifestError(f"updater platform entry has extra fields: {platform}") + if set(manifest) != {"version", "platforms"}: + raise ManifestError("updater manifest has unexpected top-level fields") + + +def write_manifest( + *, + repository: str, + tag: str, + version: str, + source_sha: str, + artifacts: Mapping[str, tuple[Path, Path, Path, Path]], + output: Path, +) -> dict[str, object]: + output = Path(output) + _validate_release_identity(repository, tag, version, source_sha) + if output.name != f"updater-{tag}.json": + raise ManifestError(f"unexpected updater manifest filename: {output.name}") + manifest = build_manifest( + repository=repository, + tag=tag, + version=version, + source_sha=source_sha, + artifacts=artifacts, + ) + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=output.parent, + prefix=f".{output.name}.", + delete=False, + ) as stream: + temporary = Path(stream.name) + json.dump(manifest, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + try: + temporary.chmod(0o644) + os.replace(temporary, output) + finally: + temporary.unlink(missing_ok=True) + written = json.loads(output.read_text(encoding="utf-8")) + validate_manifest( + written, + repository=repository, + tag=tag, + version=version, + source_sha=source_sha, + artifacts=artifacts, + ) + return written + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--darwin-artifact", type=Path, required=True) + parser.add_argument("--darwin-signature", type=Path, required=True) + parser.add_argument("--darwin-attestation", type=Path, required=True) + parser.add_argument("--darwin-attestation-signature", type=Path, required=True) + parser.add_argument("--windows-msi-artifact", type=Path, required=True) + parser.add_argument("--windows-msi-signature", type=Path, required=True) + parser.add_argument("--windows-msi-attestation", type=Path, required=True) + parser.add_argument("--windows-msi-attestation-signature", type=Path, required=True) + parser.add_argument("--windows-nsis-artifact", type=Path, required=True) + parser.add_argument("--windows-nsis-signature", type=Path, required=True) + parser.add_argument("--windows-nsis-attestation", type=Path, required=True) + parser.add_argument("--windows-nsis-attestation-signature", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + options = parser.parse_args() + write_manifest( + repository=options.repository, + tag=options.tag, + version=options.version, + source_sha=options.source_sha, + artifacts={ + "darwin-aarch64": ( + options.darwin_artifact, + options.darwin_signature, + options.darwin_attestation, + options.darwin_attestation_signature, + ), + "windows-x86_64-msi": ( + options.windows_msi_artifact, + options.windows_msi_signature, + options.windows_msi_attestation, + options.windows_msi_attestation_signature, + ), + "windows-x86_64-nsis": ( + options.windows_nsis_artifact, + options.windows_nsis_signature, + options.windows_nsis_attestation, + options.windows_nsis_attestation_signature, + ), + }, + output=options.output, + ) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a139ec77..7dee8519 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,8 +29,10 @@ tauri-plugin-dialog = "2" # previous global `**` asset exposure. The fs plugin must initialize first. tauri-plugin-fs = "2" tauri-plugin-persisted-scope = { version = "2", features = ["protocol-asset"] } +tauri-plugin-updater = "=2.10.1" serde = { workspace = true } serde_json = { workspace = true } +semver = "1" opentake-core = { workspace = true } opentake-project = { workspace = true } opentake-ops = { workspace = true } @@ -54,6 +56,7 @@ opentake-motion = { workspace = true, features = ["chromium"] } # the agent's `inspect_timeline` bridge encodes composited frames as JPEG. image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } base64 = "0.22" +minisign-verify = "=0.2.5" same-file = "1.0.6" cap-std = "4.0.2" cap-fs-ext = "4.0.2" @@ -62,8 +65,13 @@ uuid = { workspace = true } tempfile = "3" # Optional account scaffold: verify a token only against a user-configured # backend. Rustls keeps the desktop build independent of a system OpenSSL. -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "system-proxy"] } +# Keep the updater plugin's reqwest line explicit so its redirect policy can be +# configured without changing the app's existing 0.12 HTTP surface. +reqwest-updater = { package = "reqwest", version = "=0.13.4", default-features = false, features = ["system-proxy"] } +rustls = { version = "=0.23.40", default-features = false, features = ["ring", "std"] } futures-util = "0.3" +quick-xml = "=0.41.0" crossbeam-channel = "0.5" velato = { workspace = true } sentry = { workspace = true } @@ -104,6 +112,7 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] opentake-media = { workspace = true, features = ["test-faults"] } tauri = { version = "2", features = ["protocol-asset", "test"] } +tokio = { version = "1", features = ["test-util"] } [features] # `default` now includes `playback-engine`: the continuous Rust streaming engine diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index a689b93d..0da98026 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -416,8 +416,10 @@ fn finish_login_failure(state: &AccountState, attempt: LoginAttempt, message: St #[tauri::command] pub fn account_set_backend_url( state: State<'_, AccountState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, url: Option, ) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; set_backend_url(&keyring_store(), &state, url) } @@ -430,8 +432,10 @@ pub fn account_get_backend_url(state: State<'_, AccountState>) -> Result, + admission: State<'_, crate::updater::InstallAdmissionGate>, token: String, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let token = token.trim().to_string(); if token.is_empty() { return Err("Token is empty".to_string()); @@ -459,7 +463,11 @@ fn logout(store: &dyn KeyStore, state: &AccountState) -> Result<(), String> { } #[tauri::command] -pub fn account_logout(state: State<'_, AccountState>) -> Result<(), String> { +pub fn account_logout( + state: State<'_, AccountState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, +) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; logout(&keyring_store(), &state) } diff --git a/src-tauri/src/advanced.rs b/src-tauri/src/advanced.rs index 727d7565..72f041cc 100644 --- a/src-tauri/src/advanced.rs +++ b/src-tauri/src/advanced.rs @@ -656,18 +656,29 @@ impl CaptionTranslationProvider for NetworkCaptionTranslationProvider { pub struct AdvancedWorkflowCommandState { bridge: Arc, - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, +} + +struct ActiveAdvancedWorkflow { + token: MediaCancelToken, + _admission: crate::updater::ActivityLease, } impl AdvancedWorkflowCommandState { - pub fn new(bridge: Arc) -> Self { + pub fn new( + bridge: Arc, + admission: crate::updater::InstallAdmissionGate, + ) -> Self { Self { bridge, active: Mutex::new(None), + admission, } } fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -676,7 +687,10 @@ impl AdvancedWorkflowCommandState { return Err("advanced_workflow_busy".to_string()); } let token = MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveAdvancedWorkflow { + token: token.clone(), + _admission: admission, + }); Ok(token) } @@ -687,7 +701,7 @@ impl AdvancedWorkflowCommandState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.token.same_instance(token)) { *active = None; } @@ -698,20 +712,33 @@ impl AdvancedWorkflowCommandState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - active.as_ref().is_some_and(|token| { - token.cancel(); + active.as_ref().is_some_and(|current| { + current.token.cancel(); true }) } } -#[derive(Default)] pub struct MattingModelInstallState { - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, +} + +struct ActiveMattingModelInstall { + token: MediaCancelToken, + _admission: crate::updater::ActivityLease, } impl MattingModelInstallState { + pub fn new(admission: crate::updater::InstallAdmissionGate) -> Self { + Self { + active: Mutex::new(None), + admission, + } + } + fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -720,7 +747,10 @@ impl MattingModelInstallState { return Err("matting_model_download_busy".to_string()); } let token = MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveMattingModelInstall { + token: token.clone(), + _admission: admission, + }); Ok(token) } @@ -731,7 +761,7 @@ impl MattingModelInstallState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.token.same_instance(token)) { *active = None; } @@ -742,11 +772,15 @@ impl MattingModelInstallState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - active.as_ref().is_some_and(|token| { - token.cancel(); + active.as_ref().is_some_and(|current| { + current.token.cancel(); true }) } + + pub(crate) fn cancel_active(&self) -> bool { + self.cancel() + } } #[derive(Clone, Debug, Serialize, PartialEq)] @@ -1037,6 +1071,7 @@ pub fn advanced_apply_caption_translation_review( state: State<'_, AdvancedWorkflowCommandState>, request: ApplyCaptionTranslationReviewRequest, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&state.admission)?; if request.changes.is_empty() || request.changes.len() > 500 { return Err("select between 1 and 500 translated captions to apply".into()); } @@ -3850,6 +3885,29 @@ mod tests { use std::collections::HashSet; use std::process::Command; + #[test] + fn advanced_work_cannot_begin_after_update_install_claims_admission() { + let temp = tempfile::tempdir().unwrap(); + let admission = crate::updater::InstallAdmissionGate::default(); + let bridge = Arc::new(TauriAdvancedWorkflowBridge::new( + AppCore::new(), + temp.path().join("cache"), + temp.path().join("models"), + )); + let state = AdvancedWorkflowCommandState::new(bridge, admission.clone()); + let matting = MattingModelInstallState::new(admission.clone()); + let _install = admission.begin_install().unwrap(); + + assert_eq!( + state.begin().err().unwrap(), + "app update installation is in progress" + ); + assert_eq!( + matting.begin().err().unwrap(), + "app update installation is in progress" + ); + } + #[test] fn provider_resource_ids_are_safe_path_segments() { assert!(valid_provider_resource_id("abc-123_DEF")); diff --git a/src-tauri/src/captions.rs b/src-tauri/src/captions.rs index 2e69b39a..466d043f 100644 --- a/src-tauri/src/captions.rs +++ b/src-tauri/src/captions.rs @@ -110,12 +110,20 @@ pub struct GenerateCaptionsResult { /// to `download_transcribe_model`). Returns `caption_count == 0` (not an error) /// when nothing was captionable / no speech was found, matching upstream's empty /// return. +fn begin_caption_generation( + admission: &crate::updater::InstallAdmissionGate, +) -> Result { + admission.begin_activity() +} + #[tauri::command] pub fn generate_captions( core: State<'_, AppCore>, media: State<'_, MediaState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, request: CaptionRequestDto, ) -> Result { + let _admission = begin_caption_generation(&admission)?; let snapshot = core.runtime_snapshot(); let revision = ProjectRevision { project_epoch: snapshot.project_epoch, @@ -472,6 +480,15 @@ mod tests { assert!(!req.censor_profanity); } + #[test] + fn caption_generation_is_rejected_while_update_install_owns_admission() { + let admission = crate::updater::InstallAdmissionGate::default(); + let install = admission.begin_install().unwrap(); + assert!(begin_caption_generation(&admission).is_err()); + drop(install); + assert!(begin_caption_generation(&admission).is_ok()); + } + #[test] fn result_serializes_camelcase() { let r = GenerateCaptionsResult { diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs index 8705a32b..26649bb2 100644 --- a/src-tauri/src/chat.rs +++ b/src-tauri/src/chat.rs @@ -43,6 +43,7 @@ pub struct ChatState { sessions: Arc>>, turns: Arc>, persistence: Arc>, + admission: crate::updater::InstallAdmissionGate, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -154,11 +155,33 @@ impl ChatState { None, None, None, + crate::updater::InstallAdmissionGate::default(), + ) + } + + #[cfg(test)] + fn new_with_admission( + core: AppCore, + workflows_dir: PathBuf, + cache_root: PathBuf, + models_dir: PathBuf, + admission: crate::updater::InstallAdmissionGate, + ) -> Self { + Self::new_inner( + core, + workflows_dir, + cache_root, + models_dir, + None, + None, + None, + admission, ) } /// Build the state in `setup`: a dispatcher over the live core + workflow /// registry + the same media bridge the desktop MCP server uses. + #[allow(clippy::too_many_arguments)] // explicit dependency-injection boundary pub fn new_with_capabilities( core: AppCore, workflows_dir: PathBuf, @@ -167,6 +190,7 @@ impl ChatState { generation_bridge: Arc, motion_bridge: Arc, advanced_bridge: Arc, + admission: crate::updater::InstallAdmissionGate, ) -> Self { Self::new_inner( core, @@ -176,9 +200,11 @@ impl ChatState { Some(generation_bridge), Some(motion_bridge), Some(advanced_bridge), + admission, ) } + #[allow(clippy::too_many_arguments)] // keeps optional production bridges explicit in tests fn new_inner( core: AppCore, workflows_dir: PathBuf, @@ -187,6 +213,7 @@ impl ChatState { generation_bridge: Option>, motion_bridge: Option>, advanced_bridge: Option>, + admission: crate::updater::InstallAdmissionGate, ) -> Self { let handle: Arc = Arc::new(AppCoreHandle::new(core.clone())); let registry = Arc::new(RwLock::new(crate::mcp::build_registry(&workflows_dir))); @@ -210,6 +237,7 @@ impl ChatState { sessions: sessions.clone(), turns: turns.clone(), persistence: Arc::new(Mutex::new(())), + admission, }; let transition_turns = turns.clone(); state @@ -433,7 +461,12 @@ impl ChatState { Ok(session) } - fn reserve_turn(&self, key: SessionKey, cancel: Arc) -> Result<(), String> { + fn reserve_turn( + &self, + key: SessionKey, + cancel: Arc, + ) -> Result { + let admission = self.admission.begin_activity()?; let mut turns = self.turns.lock().map_err(|e| e.to_string())?; if turns.transition_depth > 0 { cancel.request(); @@ -442,7 +475,7 @@ impl ChatState { match turns.running.entry(key) { Entry::Vacant(entry) => { entry.insert(cancel); - Ok(()) + Ok(admission) } Entry::Occupied(_) => Err("a turn is already running on this session".into()), } @@ -650,7 +683,7 @@ pub async fn chat_send( let session_key = project.key(&session_id); let undo_scope = agent_undo_scope(&session_key); let turn_cancel = Arc::new(TurnCancel::new()); - state.reserve_turn(session_key.clone(), turn_cancel.clone())?; + let turn_admission = state.reserve_turn(session_key.clone(), turn_cancel.clone())?; let cancel = turn_cancel.requested.clone(); let mut session = match state.take_open_project_session_for_turn(&project, &session_id) { @@ -670,6 +703,7 @@ pub async fn chat_send( let sid = session_id.clone(); tauri::async_runtime::spawn(async move { + let _turn_admission = turn_admission; let emitter = AppEmitter { app: app.clone(), state: state_clone.clone(), @@ -868,6 +902,7 @@ pub fn chat_session_set_open( expected_project_epoch: u64, expected_project_path: String, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&state.admission)?; let project = state.project_context_for(expected_project_epoch, &expected_project_path)?; state.set_project_session_open(&project, &session_id, is_open) } @@ -1508,6 +1543,43 @@ mod tests { assert_eq!(state.turns.lock().unwrap().running.len(), 1); } + #[test] + fn chat_turn_admission_spans_reservation_and_rejects_turns_during_install() { + let temp = tempfile::tempdir().unwrap(); + let core = AppCore::new(); + let admission = crate::updater::InstallAdmissionGate::default(); + let state = ChatState::new_with_admission( + core, + temp.path().join("no-workflows"), + temp.path().join("chat-cache"), + temp.path().join("chat-models"), + admission.clone(), + ); + let key = SessionKey { + project_epoch: 7, + project_dir: temp.path().join("A.opentake"), + session_id: "chat-admission".into(), + }; + + let turn_admission = state + .reserve_turn(key.clone(), Arc::new(TurnCancel::new())) + .unwrap(); + assert!(admission.begin_install().is_err()); + state.release_turn(&key); + drop(turn_admission); + + let install = admission.begin_install().unwrap(); + assert!(state + .reserve_turn(key.clone(), Arc::new(TurnCancel::new())) + .is_err()); + drop(install); + let resumed = state + .reserve_turn(key.clone(), Arc::new(TurnCancel::new())) + .unwrap(); + state.release_turn(&key); + drop(resumed); + } + #[test] fn project_turn_gate_request_cancel_cancels_the_whole_turn() { let temp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index bef65a9f..6ddfeb7b 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -527,6 +527,7 @@ async fn start_login_until( state: &CodexAuthState, codex: VerifiedCodex, deadline: tokio::time::Instant, + activity: crate::updater::ActivityLease, ) -> Result<(), String> { let id = state.next_login_id.fetch_add(1, Ordering::AcqRel) + 1; let cancel = Arc::new(AtomicBool::new(false)); @@ -552,6 +553,10 @@ async fn start_login_until( let spawn = std::thread::Builder::new() .name("codex-login".to_string()) .spawn(move || { + // The command returns after startup while the browser login child + // keeps running. Let the worker own update admission until the + // contained child has actually exited and been reaped. + let _activity = activity; let _guard = LoginCompletionGuard(thread_completion); let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() @@ -603,7 +608,9 @@ pub async fn codex_auth_status( #[tauri::command] pub async fn codex_login_start( state: State<'_, CodexAuthState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, ) -> Result { + let activity = crate::updater::begin_mutating_activity(&admission)?; let deadline = tokio::time::Instant::now() + CODEX_AUTH_TIMEOUT; let (current, codex) = auth_status_until(&state, deadline).await?; if current.authenticated || current.login_in_progress { @@ -612,7 +619,7 @@ pub async fn codex_login_start( let Some(codex) = codex else { return Ok(CodexAuthStatus::unavailable()); }; - start_login_until(&state, codex.clone(), deadline).await?; + start_login_until(&state, codex.clone(), deadline, activity).await?; Ok(CodexAuthStatus { available: true, authenticated: false, @@ -635,7 +642,11 @@ pub async fn codex_login_cancel( } #[tauri::command] -pub async fn codex_logout(state: State<'_, CodexAuthState>) -> Result { +pub async fn codex_logout( + state: State<'_, CodexAuthState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let deadline = tokio::time::Instant::now() + CODEX_LOGOUT_TIMEOUT; cancel_login_until(&state, deadline).await?; let cancel = AtomicBool::new(false); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f3748daa..f32f2cba 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -185,10 +185,12 @@ pub fn generation_log(core: State<'_, AppCore>) -> opentake_project::GenerationL #[tauri::command] pub fn undo( core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, expected_project_epoch: u64, expected_timeline_version: u64, expected_project_path: Option, ) -> Result { + let _admission = begin_edit_activity(&admission)?; handle_edit_apply_at_project_revision( &core, ProjectRevision { @@ -203,10 +205,12 @@ pub fn undo( #[tauri::command] pub fn redo( core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, expected_project_epoch: u64, expected_timeline_version: u64, expected_project_path: Option, ) -> Result { + let _admission = begin_edit_activity(&admission)?; handle_edit_apply_at_project_revision( &core, ProjectRevision { @@ -228,6 +232,10 @@ pub async fn project_new( app: AppHandle, path: Option, ) -> Result { + let _update_activity = crate::updater::begin_mutating_activity( + &app.state::(), + ) + .map_err(crate::playback::session::PlaybackCommandError::busy)?; let coordinator = app.state::(); let lifecycle = coordinator .try_acquire() @@ -247,9 +255,15 @@ pub async fn project_new( let admission = coordinator .try_admit_prepare(&path) .map_err(crate::playback::session::PlaybackCommandError::busy)?; - let prepared = prepare_saved_project_off_thread(path.clone(), admission) - .await - .map_err(crate::playback::session::PlaybackCommandError::engine)?; + let prepared = prepare_saved_project_off_thread( + path.clone(), + admission, + app.state::() + .inner() + .clone(), + ) + .await + .map_err(crate::playback::session::PlaybackCommandError::engine)?; if !prepared.is_current_namespace().map_err(|error| { crate::playback::session::PlaybackCommandError::engine(error.to_string()) })? { @@ -308,6 +322,9 @@ pub async fn project_new( app: AppHandle, path: Option, ) -> Result { + let _update_activity = crate::updater::begin_mutating_activity( + &app.state::(), + )?; let coordinator = app.state::(); let lifecycle = coordinator.try_acquire()?; if let Some(path) = path { @@ -319,7 +336,14 @@ pub async fn project_new( return Err("project path has not been approved by a native file dialog".into()); } let admission = coordinator.try_admit_prepare(&path)?; - let prepared = prepare_saved_project_off_thread(path.clone(), admission).await?; + let prepared = prepare_saved_project_off_thread( + path.clone(), + admission, + app.state::() + .inner() + .clone(), + ) + .await?; if !prepared .is_current_namespace() .map_err(|error| error.to_string())? @@ -395,12 +419,18 @@ async fn prepare_project_open_off_thread( async fn prepare_saved_project_off_thread( path: std::path::PathBuf, admission: ProjectPrepareAdmission, + update_admission: crate::updater::InstallAdmissionGate, ) -> Result { + let worker_activity = crate::updater::begin_mutating_activity(&update_admission)?; run_blocking_with_timeout( "project create", PROJECT_LIFECYCLE_PREPARE_TIMEOUT, admission, move || { + // spawn_blocking cannot be cancelled when the caller's timeout + // expires. Keep update admission until this worker really stops so + // its bundle writes cannot cross the install/save barrier. + let _worker_activity = worker_activity; AppCore::new() .save_project(Some(path.clone())) .map_err(|error| error.to_string())?; @@ -416,6 +446,10 @@ pub async fn project_open( app: AppHandle, path: String, ) -> Result { + let _update_activity = crate::updater::begin_mutating_activity( + &app.state::(), + ) + .map_err(crate::playback::session::PlaybackCommandError::busy)?; let coordinator = app.state::(); let lifecycle = coordinator .try_acquire() @@ -500,6 +534,9 @@ fn commit_prepared_project_open_with_playback_and_prewarm( #[cfg(not(feature = "playback-engine"))] #[tauri::command] pub async fn project_open(app: AppHandle, path: String) -> Result { + let _update_activity = crate::updater::begin_mutating_activity( + &app.state::(), + )?; let coordinator = app.state::(); let lifecycle = coordinator.try_acquire()?; let path = std::path::PathBuf::from(path); @@ -538,10 +575,13 @@ pub async fn project_open(app: AppHandle, path: String) -> Result, + admission: State<'_, crate::updater::InstallAdmissionGate>, path: Option, expected_project_epoch: u64, expected_project_path: Option, ) -> Result { + let _activity = + crate::updater::begin_mutating_activity(&admission).map_err(validation_error)?; project_save_for_project( &core, path, @@ -590,16 +630,60 @@ fn project_save_for_project( /// as the save dialog's `defaultPath` so the user picks a location + name like /// upstream `createNewProject` (`NSSavePanel`). #[tauri::command] -pub fn get_default_project_dir(app: AppHandle) -> Result { +pub fn get_default_project_dir( + app: AppHandle, + admission: State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { let dir = app .path() .document_dir() .map_err(|e| e.to_string())? .join("OpenTake"); - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + ensure_default_project_dir(&dir, &admission)?; Ok(dir.to_string_lossy().into_owned()) } +fn ensure_default_project_dir( + dir: &std::path::Path, + admission: &crate::updater::InstallAdmissionGate, +) -> Result<(), String> { + if dir.is_dir() { + return Ok(()); + } + let _activity = crate::updater::begin_mutating_activity(admission)?; + std::fs::create_dir_all(dir).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod default_project_dir_tests { + use super::ensure_default_project_dir; + + #[test] + fn update_install_rejects_first_default_project_directory_write() { + let temp = tempfile::tempdir().expect("default directory fixture"); + let directory = temp.path().join("OpenTake"); + let admission = crate::updater::InstallAdmissionGate::default(); + let install = admission.begin_install().expect("install starts"); + + assert_eq!( + ensure_default_project_dir(&directory, &admission) + .expect_err("directory creation must fail closed"), + "app update installation is in progress" + ); + assert!(!directory.exists()); + + drop(install); + ensure_default_project_dir(&directory, &admission) + .expect("directory creation resumes after install"); + assert!(directory.is_dir()); + + let install = admission.begin_install().expect("second install starts"); + ensure_default_project_dir(&directory, &admission) + .expect("an existing default directory is a read-only cache hit"); + drop(install); + } +} + /// `export_xmeml`: write the current timeline to `path` as XMEML 4 (Final Cut /// Pro 7 XML, `.xml`). This is the Premiere / DaVinci / 剪映-importable /// interchange format — Premiere Pro does NOT read modern FCPXML natively, so @@ -607,7 +691,12 @@ pub fn get_default_project_dir(app: AppHandle) -> Result { /// the timeline / media manifest / project dir from the core, builds the XML via /// the pure `export_xmeml`, and writes the file. #[tauri::command] -pub fn export_xmeml(core: State<'_, AppCore>, path: String) -> Result<(), String> { +pub fn export_xmeml( + core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, + path: String, +) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let snapshot = core.runtime_snapshot(); // Resolve each source file's start timecode via ffprobe (upstream reads the // QuickTime `tmcd` track; here `opentake_media::read_start_timecode_frame` @@ -664,8 +753,12 @@ fn resolve_start_timecodes( /// New code (and the format picker) should call `export_xmeml`; native FCPXML is /// `export_fcpxml_modern`. #[tauri::command] -pub fn export_fcpxml(core: State<'_, AppCore>, path: String) -> Result<(), String> { - export_xmeml(core, path) +pub fn export_fcpxml( + core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, + path: String, +) -> Result<(), String> { + export_xmeml(core, admission, path) } /// `export_edl`: write the current timeline to `path` as a CMX3600 EDL (`.edl`). @@ -674,7 +767,12 @@ pub fn export_fcpxml(core: State<'_, AppCore>, path: String) -> Result<(), Strin /// Avid / 剪映 import. Effects, transforms, opacity, and multi-track layering are /// dropped — see `opentake_project::edl` for the documented limitations. #[tauri::command] -pub fn export_edl(core: State<'_, AppCore>, path: String) -> Result<(), String> { +pub fn export_edl( + core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, + path: String, +) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let snapshot = core.runtime_snapshot(); let edl = opentake_project::export_edl(&snapshot.timeline, &snapshot.media); std::fs::write(&path, edl).map_err(|e| e.to_string()) @@ -686,7 +784,12 @@ pub fn export_edl(core: State<'_, AppCore>, path: String) -> Result<(), String> /// per-clip media references; see `opentake_project::otio` for what is dropped /// (effects, transforms, keyframes). #[tauri::command] -pub fn export_otio(core: State<'_, AppCore>, path: String) -> Result<(), String> { +pub fn export_otio( + core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, + path: String, +) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let snapshot = core.runtime_snapshot(); let json = opentake_project::export_otio( &snapshot.timeline, @@ -702,7 +805,12 @@ pub fn export_otio(core: State<'_, AppCore>, path: String) -> Result<(), String> /// FCPXML — use `export_xmeml` for Premiere / DaVinci / 剪映. See /// `opentake_project::fcpxml_modern`. #[tauri::command] -pub fn export_fcpxml_modern(core: State<'_, AppCore>, path: String) -> Result<(), String> { +pub fn export_fcpxml_modern( + core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, + path: String, +) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let snapshot = core.runtime_snapshot(); let xml = opentake_project::export_fcpxml( &snapshot.timeline, @@ -745,9 +853,11 @@ pub struct SubtitleExportSummary { #[tauri::command] pub fn export_subtitles( core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, path: String, format: SubtitleFormat, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let timeline = core.get_timeline().timeline; write_subtitles(&timeline, path, format) } @@ -785,21 +895,30 @@ pub fn can_redo(core: State<'_, AppCore>) -> bool { // MARK: - The single editing entry point +fn begin_edit_activity( + admission: &crate::updater::InstallAdmissionGate, +) -> Result { + crate::updater::begin_mutating_activity(admission).map_err(validation_error) +} + /// `edit_apply`: the unified editing command. The front end constructs an /// [`EditRequest`] from a UI gesture; this maps it to an [`EditCommand`] and /// routes it through [`AppCore::apply_at_project_revision`] (which performs the /// project identity check and snapshot/commit/version transaction under one /// authoritative lock, then emits `TimelineChanged`). #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri IPC injects states and request identity separately pub fn edit_apply( core: State<'_, AppCore>, render: State<'_, crate::render::RenderState>, media: State<'_, crate::media::MediaState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, command: EditRequest, expected_project_epoch: u64, expected_timeline_version: u64, expected_project_path: Option, ) -> Result { + let _admission = begin_edit_activity(&admission)?; let mut prepared_freeze_path = None; let cmd = match command { EditRequest::FreezeFrame { @@ -2089,6 +2208,52 @@ mod project_open_async_tests { drop(released_path); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn timed_out_mutating_worker_keeps_update_admission_until_it_really_finishes() { + let coordinator = ProjectLifecycleCoordinator::default(); + let admission = coordinator + .try_admit_prepare(std::path::Path::new("slow-create.opentake")) + .expect("prepare admitted"); + let update_admission = crate::updater::InstallAdmissionGate::default(); + let worker_activity = crate::updater::begin_mutating_activity(&update_admission).unwrap(); + let (release_worker, wait_for_release) = std::sync::mpsc::channel(); + + let result: Result<(), String> = run_blocking_with_timeout( + "project create", + Duration::from_millis(10), + admission, + move || { + let _worker_activity = worker_activity; + wait_for_release + .recv() + .map_err(|error| format!("release channel closed: {error}")) + }, + ) + .await; + + assert_eq!( + result.expect_err("blocked writer must time out at the caller"), + "project create timed out after 10ms" + ); + assert!( + update_admission.begin_install().is_err(), + "spawn_blocking keeps writing after the caller timeout, so install must still wait" + ); + + release_worker.send(()).expect("release worker"); + let install = tokio::time::timeout(Duration::from_secs(1), async { + loop { + if let Ok(install) = update_admission.begin_install() { + break install; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("worker eventually releases update admission"); + drop(install); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timed_out_prepare_cannot_commit_a_late_project() { let fixture = tempfile::tempdir().expect("fixture tempdir"); @@ -2135,10 +2300,12 @@ mod project_open_async_tests { let admission = coordinator .try_admit_prepare(&bundle) .expect("prepare admitted"); + let update_admission = crate::updater::InstallAdmissionGate::default(); - let prepared = prepare_saved_project_off_thread(bundle.clone(), admission) - .await - .expect("new project bundle prepares"); + let prepared = + prepare_saved_project_off_thread(bundle.clone(), admission, update_admission) + .await + .expect("new project bundle prepares"); assert_eq!(core.project_revision(), before); assert!(bundle.join("project.json").is_file()); @@ -2161,11 +2328,13 @@ mod project_open_async_tests { let admission = coordinator .try_admit_prepare(&bundle) .expect("prepare admitted"); + let update_admission = crate::updater::InstallAdmissionGate::default(); - let error = match prepare_saved_project_off_thread(bundle, admission).await { - Err(error) => error, - Ok(_) => panic!("invalid destination must fail"), - }; + let error = + match prepare_saved_project_off_thread(bundle, admission, update_admission).await { + Err(error) => error, + Ok(_) => panic!("invalid destination must fail"), + }; assert!(!error.is_empty()); assert_eq!(core.project_revision(), before); @@ -2402,12 +2571,25 @@ mod project_prewarm_lifecycle_tests { #[cfg(test)] mod edit_request_serde_tests { - use super::{validate_freeze_frame_request, EditRequest}; + use super::{begin_edit_activity, validate_freeze_frame_request, EditRequest}; use opentake_core::{AppCore, EditCommand}; use opentake_domain::{ClipType, TransitionKind}; use opentake_ops::command::{NewTrackClipMode, PlaceMediaTarget}; use opentake_ops::ClipEntry; + #[test] + fn deferred_analysis_continuation_cannot_commit_after_install_wins_the_await_boundary() { + let admission = crate::updater::InstallAdmissionGate::default(); + let analysis = admission.begin_activity().unwrap(); + assert!(admission.begin_install().is_err()); + drop(analysis); + + let install = admission.begin_install().unwrap(); + assert!(begin_edit_activity(&admission).is_err()); + drop(install); + assert!(begin_edit_activity(&admission).is_ok()); + } + fn request_route(request: &EditRequest) -> &'static str { match request { EditRequest::CreateNestedSequence { .. } => "CreateNestedSequence", diff --git a/src-tauri/src/feedback.rs b/src-tauri/src/feedback.rs index e67c9d10..31410858 100644 --- a/src-tauri/src/feedback.rs +++ b/src-tauri/src/feedback.rs @@ -243,8 +243,10 @@ impl FeedbackState { #[tauri::command] pub async fn submit_feedback( state: State<'_, FeedbackState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, draft: FeedbackDraft, ) -> Result<(), String> { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let submission = FeedbackSubmission::from_runtime(draft)?; state.send(&submission).await } diff --git a/src-tauri/src/generation.rs b/src-tauri/src/generation.rs index accda4b4..cbf11882 100644 --- a/src-tauri/src/generation.rs +++ b/src-tauri/src/generation.rs @@ -38,11 +38,16 @@ const RESULT_REDIRECT_MAX: usize = 5; #[derive(Default)] struct GenerationRuntime { - jobs: Mutex>, + jobs: Mutex>, terminal_leases: Mutex>, completed: Mutex>, } +struct ActiveGenerationJob { + cancel: MediaCancelToken, + _admission: crate::updater::ActivityLease, +} + #[derive(Clone)] pub(crate) struct TauriGenerationBridge { core: AppCore, @@ -50,6 +55,7 @@ pub(crate) struct TauriGenerationBridge { staging_root: PathBuf, runtime: Arc, clients: Arc, + admission: crate::updater::InstallAdmissionGate, } trait GenerationClientFactory: Send + Sync { @@ -153,6 +159,7 @@ pub(crate) fn build_bridge( core: AppCore, cache_root: PathBuf, models_dir: PathBuf, + admission: crate::updater::InstallAdmissionGate, ) -> Arc { Arc::new(TauriGenerationBridge { core, @@ -160,6 +167,7 @@ pub(crate) fn build_bridge( staging_root: cache_root.join("generation-staging"), runtime: Arc::new(GenerationRuntime::default()), clients: Arc::new(ProductionGenerationClientFactory), + admission, }) } @@ -169,6 +177,23 @@ fn build_bridge_with_clients( cache_root: PathBuf, models_dir: PathBuf, clients: Arc, +) -> Arc { + build_bridge_with_clients_and_admission( + core, + cache_root, + models_dir, + clients, + crate::updater::InstallAdmissionGate::default(), + ) +} + +#[cfg(test)] +fn build_bridge_with_clients_and_admission( + core: AppCore, + cache_root: PathBuf, + models_dir: PathBuf, + clients: Arc, + admission: crate::updater::InstallAdmissionGate, ) -> Arc { Arc::new(TauriGenerationBridge { core, @@ -176,18 +201,39 @@ fn build_bridge_with_clients( staging_root: cache_root.join("generation-staging"), runtime: Arc::new(GenerationRuntime::default()), clients, + admission, }) } impl TauriGenerationBridge { + pub(crate) fn has_active(&self) -> bool { + self.runtime + .jobs + .lock() + .map(|jobs| !jobs.is_empty()) + .unwrap_or(true) + } + + pub(crate) fn cancel_all_active(&self) -> usize { + let jobs = self + .runtime + .jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for job in jobs.values() { + job.cancel.cancel(); + } + jobs.len() + } + pub(crate) fn cancel(&self, job_id: &str) -> bool { self.runtime .jobs .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .get(job_id) - .is_some_and(|token| { - token.cancel(); + .is_some_and(|job| { + job.cancel.cancel(); true }) } @@ -351,13 +397,16 @@ impl TauriGenerationBridge { if !job.has_active_output { continue; } - let already_running = self + let Ok(admission) = self.admission.begin_activity() else { + continue; + }; + if self .runtime .jobs .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains_key(&job_id); - if already_running { + .contains_key(&job_id) + { continue; } job.placeholders.sort_by_key(|(index, _)| *index); @@ -396,7 +445,13 @@ impl TauriGenerationBridge { .jobs .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(job_id.clone(), cancel.clone()); + .insert( + job_id.clone(), + ActiveGenerationJob { + cancel: cancel.clone(), + _admission: admission, + }, + ); let bridge = self.clone(); let recovery_dir = project_dir.clone(); let managed = !provider_job_id.starts_with(&format!("{}::", job.provider)); @@ -1234,6 +1289,7 @@ impl GenerationBridge for TauriGenerationBridge { cancel: &MediaCancelToken, ) -> Result { cancelled(cancel)?; + let admission = self.admission.begin_activity()?; let prepared = self.prepare(request)?; let snapshot = self.core.runtime_snapshot(); let project_dir = snapshot @@ -1253,7 +1309,13 @@ impl GenerationBridge for TauriGenerationBridge { .jobs .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(committed.job_id.clone(), background_cancel.clone()); + .insert( + committed.job_id.clone(), + ActiveGenerationJob { + cancel: background_cancel.clone(), + _admission: admission, + }, + ); let bridge = self.clone(); let job_id = committed.job_id.clone(); let placeholder_ids = committed.placeholder_asset_ids.clone(); @@ -2038,6 +2100,90 @@ mod tests { } } + #[test] + fn updater_gate_observes_and_cancels_every_active_generation() { + let (_temp, bundle, core) = saved_core(); + let mock = MockTransport::new(); + let (cache, models) = runtime_dirs(&bundle); + let bridge = build_bridge_with_clients( + core, + cache, + models, + Arc::new(FixtureClients { + client: fixture_client(&mock), + }), + ); + let first = MediaCancelToken::new(); + let second = MediaCancelToken::new(); + { + let mut jobs = bridge + .runtime + .jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + jobs.insert( + "job-1".to_string(), + ActiveGenerationJob { + cancel: first.clone(), + _admission: bridge.admission.begin_activity().unwrap(), + }, + ); + jobs.insert( + "job-2".to_string(), + ActiveGenerationJob { + cancel: second.clone(), + _admission: bridge.admission.begin_activity().unwrap(), + }, + ); + } + + assert!(bridge.has_active()); + assert_eq!(bridge.cancel_all_active(), 2); + assert!(first.is_cancelled()); + assert!(second.is_cancelled()); + + bridge + .runtime + .jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); + assert!(!bridge.has_active()); + assert_eq!(bridge.cancel_all_active(), 0); + } + + #[test] + fn generation_cannot_submit_after_update_install_claims_admission() { + let (_temp, bundle, core) = saved_core(); + let mock = MockTransport::new(); + let (cache, models) = runtime_dirs(&bundle); + let admission = crate::updater::InstallAdmissionGate::default(); + let bridge = build_bridge_with_clients_and_admission( + core, + cache, + models, + Arc::new(FixtureClients { + client: fixture_client(&mock), + }), + admission.clone(), + ); + let _install = admission.begin_install().unwrap(); + + assert_eq!( + bridge + .submit( + GenerationRequest::Image(GenerateImageArgs { + prompt: "must not submit".to_string(), + ..Default::default() + }), + &MediaCancelToken::new(), + ) + .unwrap_err(), + "app update installation is in progress" + ); + assert!(bridge.runtime.jobs.lock().unwrap().is_empty()); + } + fn runtime_dirs(bundle: &Path) -> (PathBuf, PathBuf) { let root = bundle.parent().unwrap(); (root.join("cache"), root.join("models")) diff --git a/src-tauri/src/home.rs b/src-tauri/src/home.rs index 5ea35264..60ebdce9 100644 --- a/src-tauri/src/home.rs +++ b/src-tauri/src/home.rs @@ -999,9 +999,13 @@ pub async fn home_projects_sync( app: AppHandle, entries: Vec, ) -> Result, String> { + let activity = crate::updater::begin_mutating_activity( + &app.state::(), + )?; let scope = app.asset_protocol_scope(); let registry_scope = scope.clone(); let registry_entries = tauri::async_runtime::spawn_blocking(move || { + let _activity = activity; let authorized_legacy = entries .into_iter() .filter(|entry| { @@ -1040,7 +1044,11 @@ pub async fn home_project_register( path: String, opened_at: Option, ) -> Result<(), String> { + let activity = crate::updater::begin_mutating_activity( + &app.state::(), + )?; tauri::async_runtime::spawn_blocking(move || { + let _activity = activity; let path = validated_project_path(Path::new(&path))?; let scope = app.asset_protocol_scope(); if !crate::safe_asset_protocol::scope_allows_lexical_path(&scope, &path) { @@ -1057,7 +1065,11 @@ pub async fn home_project_register( #[tauri::command] pub async fn home_project_remove(app: AppHandle, path: String) -> Result<(), String> { + let activity = crate::updater::begin_mutating_activity( + &app.state::(), + )?; tauri::async_runtime::spawn_blocking(move || { + let _activity = activity; with_registry(&app, |registry| { registry.remove(Path::new(&path)).map(|_| ()) }) @@ -1068,7 +1080,11 @@ pub async fn home_project_remove(app: AppHandle, path: String) -> Result<(), Str #[tauri::command] pub async fn home_project_trash(app: AppHandle, path: String) -> Result<(), String> { + let activity = crate::updater::begin_mutating_activity( + &app.state::(), + )?; tauri::async_runtime::spawn_blocking(move || { + let _activity = activity; let path = validated_project_path(Path::new(&path))?; if !crate::safe_asset_protocol::scope_allows_lexical_path( &app.asset_protocol_scope(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 151f1500..140da9db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,7 @@ mod secret; mod storage; pub mod telemetry; mod transcribe; +mod updater; // Streaming playback engine (#53). Feature-gated (`playback-engine`, now a DEFAULT // feature) and `pub` so the gated GPU+ffmpeg integration test can drive the render @@ -49,10 +50,7 @@ use std::sync::Arc; use opentake_core::{AppCore, CoreEvent, IdGen}; use opentake_media::library::LibraryStore; use opentake_media::MediaEngine; -use tauri::{Emitter, Manager, WindowEvent}; -// `RunEvent::Reopen` (Dock click) is a macOS-only variant. -#[cfg(target_os = "macos")] -use tauri::RunEvent; +use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use crate::media::prewarm::PrewarmScheduler; use crate::media::MediaState; @@ -126,6 +124,7 @@ pub fn run() { // Tauri requires fs to be initialized before persisted-scope. .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_persisted_scope::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { // Background-run: don't quit, hide and return to Home. @@ -133,9 +132,20 @@ pub fn run() { // Flush the open project before hiding so background-run never // loses edits (autosave is debounced; this is the final write). // No-op when no project is open (save_project returns an error we - // intentionally ignore). + // intentionally ignore). Once an update owns admission, its + // own final save is authoritative and no later close event may + // write across that barrier. if let Some(core) = window.app_handle().try_state::() { - let _ = core.save_project(None); + if let Some(admission) = window + .app_handle() + .try_state::() + { + if let Ok(_activity) = updater::begin_mutating_activity(&admission) { + let _ = core.save_project(None); + } + } else { + let _ = core.save_project(None); + } } let _ = window.hide(); let _ = window.app_handle().emit("go_home", ()); @@ -188,8 +198,13 @@ pub fn run() { .app_data_dir() .unwrap_or_else(|_| std::env::temp_dir()) .join("workflows"); - let generation_bridge = - generation::build_bridge(core.clone(), cache_root.clone(), models_dir.clone()); + let install_admission = updater::InstallAdmissionGate::default(); + let generation_bridge = generation::build_bridge( + core.clone(), + cache_root.clone(), + models_dir.clone(), + install_admission.clone(), + ); let motion_bridge = Arc::new(motion::TauriMotionBridge::new( core.clone(), cache_root.clone(), @@ -207,6 +222,7 @@ pub fn run() { generation_bridge.clone(), motion_bridge.clone(), advanced_bridge.clone(), + install_admission.clone(), ); // The fixed-port external MCP endpoint is disabled for Beta until // the product has an authenticated pairing UX. Official Codex @@ -227,7 +243,8 @@ pub fn run() { app.manage(core); app.manage(commands::ProjectLifecycleCoordinator::default()); app.manage(generation_bridge); - let motion_state = motion::MotionCommandState::new(motion_bridge); + let motion_state = + motion::MotionCommandState::new(motion_bridge, install_admission.clone()); let motion_transition_state = motion_state.clone(); app.state::() .subscribe_project_identity_transition(move |pending| { @@ -236,8 +253,13 @@ pub fn run() { } }); app.manage(motion_state); - app.manage(advanced::AdvancedWorkflowCommandState::new(advanced_bridge)); - app.manage(advanced::MattingModelInstallState::default()); + app.manage(advanced::AdvancedWorkflowCommandState::new( + advanced_bridge, + install_admission.clone(), + )); + app.manage(advanced::MattingModelInstallState::new( + install_admission.clone(), + )); let advanced_transition_handle = app.handle().clone(); app.state::() .subscribe_project_identity_transition(move |pending| { @@ -249,10 +271,15 @@ pub fn run() { }); app.manage(chat_state); app.manage(codex::CodexAuthState::default()); - app.manage(MediaState::new(engine)); - app.manage(media::StabilizationAnalysisState::default()); - app.manage(media::LoudnessAnalysisState::default()); - app.manage(media::DenoiseAnalysisState::default()); + app.manage(MediaState::new_with_admission( + engine, + install_admission.clone(), + )); + app.manage(media::StabilizationAnalysisState::new( + install_admission.clone(), + )); + app.manage(media::LoudnessAnalysisState::new(install_admission.clone())); + app.manage(media::DenoiseAnalysisState::new(install_admission.clone())); let analysis_transition_handle = app.handle().clone(); app.state::() .subscribe_project_identity_transition(move |pending| { @@ -265,8 +292,8 @@ pub fn run() { ); } }); - app.manage(media::StemSeparationState::default()); - app.manage(media::MediaProxyState::default()); + app.manage(media::StemSeparationState::new(install_admission.clone())); + app.manage(media::MediaProxyState::new(install_admission.clone())); let proxy_transition_handle = app.handle().clone(); app.state::() .subscribe_project_identity_transition(move |pending| { @@ -276,7 +303,10 @@ pub fn run() { .cancel(); } }); - app.manage(PrewarmScheduler::new(initial_project_epoch)); + app.manage(PrewarmScheduler::new_with_admission( + initial_project_epoch, + install_admission.clone(), + )); app.manage(library_state); // Lazily-acquired GPU context for timeline composite previews (#47). app.manage(render::RenderState::new()); @@ -287,6 +317,8 @@ pub fn run() { // Optional account scaffold. It starts offline and never performs // network I/O until the user configures a backend and logs in. app.manage(account::AccountState::default()); + app.manage(install_admission); + app.manage(updater::UpdateCoordinator::default()); // Streaming playback (#53): start the loopback MJPEG transport on the // Tauri async runtime (mirrors the MCP server spawn) and register the @@ -418,6 +450,10 @@ pub fn run() { library::library_import_to_project, storage::storage_usage, storage::storage_clear, + updater::check_for_update, + updater::close_update, + updater::install_update, + updater::open_update_releases, #[cfg(feature = "playback-engine")] playback::commands::playback_start, #[cfg(feature = "playback-engine")] @@ -432,6 +468,18 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|_app, _event| { + // A user-driven Quit must not interrupt bundle replacement. The + // updater's own restart has a programmatic exit code and remains + // allowed after both save barriers succeed. + if let RunEvent::ExitRequested { code, api, .. } = &_event { + if code.is_none() + && _app + .try_state::() + .is_some_and(|coordinator| coordinator.prevents_user_exit()) + { + api.prevent_exit(); + } + } // Dock-reopen with no visible window (we hide on close) shows it again. // `RunEvent::Reopen` only exists on macOS; other platforms rely on the // tray / OS to re-surface the window (a cross-platform follow-up). diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 7b1f9f05..5c0f87c8 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -197,11 +197,13 @@ pub fn library_list( #[tauri::command] pub fn library_favorite( library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, source: String, kind: String, category: Option, thumb: Option, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let _workflow = library.lock_workflow(); let store = library.store()?; let source_path = PathBuf::from(&source); @@ -225,8 +227,10 @@ pub fn library_favorite( pub fn library_unfavorite( core: State<'_, AppCore>, library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, id: String, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; remove_from_library_and_project(&core, &library, &id) } @@ -235,9 +239,11 @@ pub fn library_unfavorite( #[tauri::command] pub fn library_categorize( library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, id: String, category: Option, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let _workflow = library.lock_workflow(); let store = library.store()?; let entry = store @@ -253,9 +259,11 @@ pub fn library_categorize( #[tauri::command] pub fn library_rename( library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, from: String, to: Option, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; let _workflow = library.lock_workflow(); library .store()? @@ -270,8 +278,10 @@ pub fn library_rename( pub fn library_delete( core: State<'_, AppCore>, library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, id: String, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; remove_from_library_and_project(&core, &library, &id) } @@ -334,8 +344,10 @@ pub fn library_import_to_project( core: State<'_, AppCore>, media: State<'_, MediaState>, library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, id: String, ) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; library_import_to_project_impl(&core, &media, &library, &id) } diff --git a/src-tauri/src/lut.rs b/src-tauri/src/lut.rs index 9e936429..a1e408da 100644 --- a/src-tauri/src/lut.rs +++ b/src-tauri/src/lut.rs @@ -72,7 +72,12 @@ fn read_source(path: &Path) -> Result, String> { /// Validate an untrusted `.cube`, publish it by content hash inside the active /// bundle, and return the path-free authored reference for a later `SetLut`. #[tauri::command] -pub fn import_lut(core: State<'_, AppCore>, path: String) -> Result { +pub fn import_lut( + core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, + path: String, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; import_lut_impl(&core, &path) } diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 9e4f879d..b40de585 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -79,30 +79,40 @@ pub mod prewarm; /// state. pub struct MediaState { engine: MediaEngine, + admission: crate::updater::InstallAdmissionGate, } /// Single-flight cooperative cancellation for an Inspector stabilization run. #[derive(Default)] pub struct StabilizationAnalysisState { - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, } /// Single-flight cooperative cancellation for an Inspector loudness run. #[derive(Default)] pub struct LoudnessAnalysisState { - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, } /// Single-flight cooperative cancellation for an Inspector denoise validation. #[derive(Default)] pub struct DenoiseAnalysisState { - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, +} + +struct ActiveDeferredAnalysis { + cancel: opentake_media::MediaCancelToken, + _admission: crate::updater::ActivityLease, } /// Single-flight cooperative cancellation for a two-stem separation job. #[derive(Default)] pub struct StemSeparationState { - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, } /// Single-flight proxy transcode plus the app-level playback preference. The @@ -110,12 +120,27 @@ pub struct StemSeparationState { /// source selection only and never changes export resolution. #[derive(Default)] pub struct MediaProxyState { - active: Mutex>, + active: Mutex>, + admission: crate::updater::InstallAdmissionGate, enabled: AtomicBool, } +struct ActiveProjectMutation { + cancel: opentake_media::MediaCancelToken, + _admission: crate::updater::ActivityLease, +} + impl MediaProxyState { + pub(crate) fn new(admission: crate::updater::InstallAdmissionGate) -> Self { + Self { + active: Mutex::new(None), + admission, + enabled: AtomicBool::new(false), + } + } + fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -124,7 +149,10 @@ impl MediaProxyState { return Err("media_proxy_busy".to_string()); } let token = opentake_media::MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveProjectMutation { + cancel: token.clone(), + _admission: admission, + }); Ok(token) } @@ -135,7 +163,7 @@ impl MediaProxyState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.cancel.same_instance(token)) { *active = None; } @@ -146,8 +174,8 @@ impl MediaProxyState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - active.as_ref().is_some_and(|token| { - token.cancel(); + active.as_ref().is_some_and(|operation| { + operation.cancel.cancel(); true }) } @@ -162,7 +190,15 @@ impl MediaProxyState { } impl StemSeparationState { + pub(crate) fn new(admission: crate::updater::InstallAdmissionGate) -> Self { + Self { + active: Mutex::new(None), + admission, + } + } + fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -171,7 +207,10 @@ impl StemSeparationState { return Err("stem_separation_busy".to_string()); } let token = opentake_media::MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveProjectMutation { + cancel: token.clone(), + _admission: admission, + }); Ok(token) } @@ -182,7 +221,7 @@ impl StemSeparationState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.cancel.same_instance(token)) { *active = None; } @@ -193,15 +232,29 @@ impl StemSeparationState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - active.as_ref().is_some_and(|token| { - token.cancel(); + active.as_ref().is_some_and(|operation| { + operation.cancel.cancel(); true }) } } +fn begin_direct_media_project_write( + admission: &crate::updater::InstallAdmissionGate, +) -> Result { + crate::updater::begin_mutating_activity(admission) +} + impl DenoiseAnalysisState { + pub(crate) fn new(admission: crate::updater::InstallAdmissionGate) -> Self { + Self { + active: Mutex::new(None), + admission, + } + } + fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -210,7 +263,10 @@ impl DenoiseAnalysisState { return Err("denoise_analysis_busy".to_string()); } let token = opentake_media::MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveDeferredAnalysis { + cancel: token.clone(), + _admission: admission, + }); Ok(token) } @@ -221,7 +277,7 @@ impl DenoiseAnalysisState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.cancel.same_instance(token)) { *active = None; } @@ -232,15 +288,23 @@ impl DenoiseAnalysisState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - active.as_ref().is_some_and(|token| { - token.cancel(); + active.as_ref().is_some_and(|analysis| { + analysis.cancel.cancel(); true }) } } impl LoudnessAnalysisState { + pub(crate) fn new(admission: crate::updater::InstallAdmissionGate) -> Self { + Self { + active: Mutex::new(None), + admission, + } + } + fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -249,7 +313,10 @@ impl LoudnessAnalysisState { return Err("loudness_analysis_busy".to_string()); } let token = opentake_media::MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveDeferredAnalysis { + cancel: token.clone(), + _admission: admission, + }); Ok(token) } @@ -260,7 +327,7 @@ impl LoudnessAnalysisState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.cancel.same_instance(token)) { *active = None; } @@ -271,15 +338,23 @@ impl LoudnessAnalysisState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - active.as_ref().is_some_and(|token| { - token.cancel(); + active.as_ref().is_some_and(|analysis| { + analysis.cancel.cancel(); true }) } } impl StabilizationAnalysisState { + pub(crate) fn new(admission: crate::updater::InstallAdmissionGate) -> Self { + Self { + active: Mutex::new(None), + admission, + } + } + fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -288,7 +363,10 @@ impl StabilizationAnalysisState { return Err("a stabilization analysis is already running".to_string()); } let token = opentake_media::MediaCancelToken::new(); - *active = Some(token.clone()); + *active = Some(ActiveDeferredAnalysis { + cancel: token.clone(), + _admission: admission, + }); Ok(token) } @@ -299,7 +377,7 @@ impl StabilizationAnalysisState { .unwrap_or_else(|poisoned| poisoned.into_inner()); if active .as_ref() - .is_some_and(|current| current.same_instance(token)) + .is_some_and(|current| current.cancel.same_instance(token)) { *active = None; } @@ -310,8 +388,8 @@ impl StabilizationAnalysisState { .active .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(token) = active.as_ref() { - token.cancel(); + if let Some(analysis) = active.as_ref() { + analysis.cancel.cancel(); true } else { false @@ -338,13 +416,24 @@ pub(crate) fn cancel_project_bound_analyses( impl MediaState { /// Wrap an engine for managed state. pub fn new(engine: MediaEngine) -> Self { - MediaState { engine } + Self::new_with_admission(engine, crate::updater::InstallAdmissionGate::default()) + } + + pub(crate) fn new_with_admission( + engine: MediaEngine, + admission: crate::updater::InstallAdmissionGate, + ) -> Self { + MediaState { engine, admission } } /// The wrapped engine. pub fn engine(&self) -> &MediaEngine { &self.engine } + + fn begin_cache_write(&self) -> Result { + crate::updater::begin_mutating_activity(&self.admission) + } } /// One media item for the panel. camelCase to match the existing DTO surface @@ -769,6 +858,20 @@ fn poster_target_time(time_secs: Option) -> f64 { .unwrap_or(0.0) } +fn read_cached_poster( + poster_path: &Path, + target: f64, +) -> Option> { + if !poster_path.exists() { + return None; + } + Some( + image::image_dimensions(poster_path) + .map(|(width, height)| (poster_path.to_path_buf(), width, height, target)) + .map_err(|error| format!("thumbnail dimensions: {error}")), + ) +} + /// Decode (or read from cache) a single poster frame for `path` at `target`, /// scaled to fit `max_size`, written to `poster_path`. Shared by the small grid /// poster ([`video_poster`]) and the hi-res preview poster @@ -780,10 +883,8 @@ fn decode_poster_to( target: f64, max_size: (u32, u32), ) -> Result<(PathBuf, u32, u32, f64), String> { - if poster_path.exists() { - let (width, height) = image::image_dimensions(&poster_path) - .map_err(|e| format!("thumbnail dimensions: {e}"))?; - return Ok((poster_path, width, height, target)); + if let Some(cached) = read_cached_poster(&poster_path, target) { + return cached; } let req = FrameRequest { @@ -1010,6 +1111,97 @@ fn generate_thumbnail_for_entry( } } +fn cached_thumbnail_for_entry( + engine: &MediaEngine, + entry: &MediaManifestEntry, + path: &Path, + time_secs: Option, + max_frames: Option, + include_sprite: bool, +) -> Option> { + if !path.is_file() { + return Some(Err(format!("source file not found: {}", path.display()))); + } + let key = match cache_key_for(path) { + Ok(key) => key, + Err(error) => return Some(Err(error)), + }; + match entry.kind { + ClipType::Video => { + let target = poster_target_time(time_secs); + let poster_path = timed_poster_path_for(engine.cache_root(), &key, target); + let (poster_path, poster_w, poster_h, poster_time) = + match read_cached_poster(&poster_path, target)? { + Ok(cached) => cached, + Err(error) => return Some(Err(error)), + }; + let sprite_meta = if include_sprite { + let limit = sprite_frame_limit(max_frames); + match read_cached_sprite_meta(engine.cache_root(), &key) { + Some(mut meta) => { + meta.times.truncate(limit); + Some(meta) + } + None if video_thumbnail_times(entry.duration) + .into_iter() + .take(limit) + .next() + .is_none() => + { + None + } + None => return None, + } + } else { + None + }; + let sprite_path = sprite_path_for(engine.cache_root(), &key); + Some(Ok(ThumbnailDto { + media_ref: entry.id.clone(), + kind: entry.kind, + thumbnail_path: Some(poster_path.to_string_lossy().into_owned()), + sprite_path: if include_sprite && sprite_path.is_file() { + Some(sprite_path.to_string_lossy().into_owned()) + } else { + None + }, + tile_width: sprite_meta + .as_ref() + .map(|meta| meta.tile_width) + .or(Some(poster_w)), + tile_height: sprite_meta + .as_ref() + .map(|meta| meta.tile_height) + .or(Some(poster_h)), + columns: sprite_meta.as_ref().map(|meta| meta.columns).or(Some(1)), + times: sprite_meta + .map(|meta| meta.times) + .unwrap_or_else(|| vec![poster_time]), + })) + } + ClipType::Image => { + let poster_path = poster_path_for(engine.cache_root(), &key); + if !poster_path.exists() { + return None; + } + let (tile_width, tile_height) = image::image_dimensions(&poster_path) + .map(|(width, height)| (Some(width), Some(height))) + .unwrap_or((None, None)); + Some(Ok(ThumbnailDto { + media_ref: entry.id.clone(), + kind: entry.kind, + thumbnail_path: Some(poster_path.to_string_lossy().into_owned()), + sprite_path: None, + tile_width, + tile_height, + columns: Some(1), + times: vec![0.0], + })) + } + _ => Some(Ok(empty_thumbnail_dto(entry))), + } +} + /// Probe `path` via the engine, mapping ffprobe facts to [`ProbedMedia`]. Probe /// failures (no ffprobe, unreadable file) degrade to defaults so a single bad /// file never sinks a batch import. @@ -1380,9 +1572,11 @@ pub fn import_folder( core: State<'_, AppCore>, media: State<'_, MediaState>, prewarm: State<'_, prewarm::PrewarmScheduler>, + admission: State<'_, crate::updater::InstallAdmissionGate>, path: String, recursive: Option, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; import_folder_impl(&core, media.engine(), &prewarm, path, recursive) } @@ -2459,8 +2653,10 @@ pub fn import_media( core: State<'_, AppCore>, media: State<'_, MediaState>, prewarm: State<'_, prewarm::PrewarmScheduler>, + admission: State<'_, crate::updater::InstallAdmissionGate>, paths: Vec, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; import_media_impl(&core, media.engine(), &prewarm, paths) } @@ -2533,28 +2729,39 @@ fn import_media_impl_with_options( /// `get_media`: the current media catalog for the panel. Infallible. #[tauri::command] -pub fn get_media( - app: AppHandle, +pub fn get_media( + app: AppHandle, core: State<'_, AppCore>, media: State<'_, MediaState>, ) -> MediaListDto { let mut catalog = MediaListDto::from_core(&core, Some(media.engine().cache_root())); - grant_catalog_proxy_asset_scope(&app, &mut catalog); + if catalog.items.iter().any(|item| item.proxy_path.is_some()) { + if let Ok(_activity) = media.begin_cache_write() { + grant_catalog_proxy_asset_scope(&app, &mut catalog); + } else { + for item in &mut catalog.items { + item.proxy_path = None; + } + } + } catalog } /// Persist one project asset in the content-addressed global library and mirror /// that identity in the current project manifest. #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri injects project/media/library/update state pub fn toggle_favorite( core: State<'_, AppCore>, media: State<'_, MediaState>, library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, asset_id: String, favorite: bool, expected_project_epoch: u64, expected_project_path: String, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; let _workflow = library.lock_workflow(); toggle_favorite_impl_for_project( &core, @@ -2782,10 +2989,12 @@ pub fn sync_project_favorites( core: State<'_, AppCore>, media: State<'_, MediaState>, library: State<'_, LibraryState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, legacy_asset_ids: Vec, expected_project_epoch: u64, expected_project_path: String, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; let _workflow = library.lock_workflow(); sync_project_favorites_impl_for_project( &core, @@ -3352,9 +3561,13 @@ fn validate_extract_output(out_path: &str) -> Result { pub fn extract_audio( core: State<'_, AppCore>, media: State<'_, MediaState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, media_id: String, out_path: String, ) -> Result { + // The output is user-selected rather than project state, but it is a long + // durable write that must not be cut off by the updater's process exit. + let _activity = crate::updater::begin_mutating_activity(&admission)?; // Path boundary check first (review #4): fail fast on a bad output path // before touching the manifest or spawning ffmpeg. let output = validate_extract_output(&out_path)?; @@ -3395,9 +3608,11 @@ pub fn relink_media( app: AppHandle, core: State<'_, AppCore>, media: State<'_, MediaState>, + admission: State<'_, crate::updater::InstallAdmissionGate>, media_ref: String, new_path: String, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; let new = PathBuf::from(&new_path); if !new.is_file() { return Err(format!("file not found: {new_path}")); @@ -3472,13 +3687,25 @@ pub fn generate_thumbnail( .find(|e| e.id == media_ref) .ok_or_else(|| format!("media not found: {media_ref}"))?; let path = source_path_for_entry(entry, snapshot.project_dir.as_deref())?; + let include_sprite = include_sprite.unwrap_or(false); + if let Some(cached) = cached_thumbnail_for_entry( + media.engine(), + entry, + &path, + time_secs, + max_frames, + include_sprite, + ) { + return cached; + } + let _activity = media.begin_cache_write()?; generate_thumbnail_for_entry( media.engine(), entry, &path, time_secs, max_frames, - include_sprite.unwrap_or(false), + include_sprite, ) .map_err(|e| { eprintln!( @@ -3691,6 +3918,13 @@ pub fn preview_poster( return Err(format!("source file not found: {}", path.display())); } let key = cache_key_for(&path)?; + let target = poster_target_time(time_secs); + let cached_path = preview_poster_path_for(media.engine().cache_root(), &key, target); + if let Some(cached) = read_cached_poster(&cached_path, target) { + return cached + .map(|(poster_path, _, _, _)| Some(poster_path.to_string_lossy().into_owned())); + } + let _activity = media.begin_cache_write()?; let (poster_path, _, _, _) = video_preview_poster(media.engine(), &path, &key, time_secs) .map_err(|e| { eprintln!( @@ -3727,7 +3961,18 @@ pub fn get_waveform( None => return Err("project not saved; cannot resolve media path".into()), }, }; - media.engine().waveform(&path, entry.duration).map_err(|e| { + let result = if let Some(key) = visual_file_identity_key(&path) { + if let Some(cached) = + opentake_media::waveform::store::load_waveform(media.engine().cache_root(), &key) + { + return Ok(cached); + } + let _activity = media.begin_cache_write()?; + media.engine().waveform(&path, entry.duration) + } else { + opentake_media::waveform::waveform(&path, entry.duration) + }; + result.map_err(|e| { // Log server-side too (the frontend swallows the error into "no // waveform"); without this a decode failure is invisible. eprintln!( @@ -4311,10 +4556,12 @@ pub struct ImportStemsToTracksDto { #[tauri::command] pub fn import_stems_to_tracks( core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, vocals_asset_id: String, accompaniment_asset_id: String, start_frame: i32, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; import_stems_to_tracks_core(&core, vocals_asset_id, accompaniment_asset_id, start_frame) } @@ -4760,8 +5007,10 @@ fn remove_media_proxy_impl( pub fn remove_media_proxy( app: AppHandle, core: State<'_, AppCore>, + admission: State<'_, crate::updater::InstallAdmissionGate>, asset_id: String, ) -> Result { + let _activity = begin_direct_media_project_write(&admission)?; remove_media_proxy_impl(&core, &asset_id, |path| { let _ = std::fs::remove_file(path); revoke_proxy_asset_file(&app, path); @@ -7700,6 +7949,164 @@ mod tests { assert!(!handle.asset_protocol_scope().is_allowed(&proxy)); } + #[test] + fn update_install_rejects_synchronous_cache_writers() { + let temp = tempfile::tempdir().unwrap(); + let (core, _bundle, _source, asset_id) = saved_core_with_media(temp.path()); + let admission = crate::updater::InstallAdmissionGate::default(); + let app = tauri::test::mock_app(); + app.manage(core); + app.manage(MediaState::new_with_admission( + engine_for(temp.path()), + admission.clone(), + )); + let install = admission.begin_install().expect("install starts"); + let expected = "app update installation is in progress"; + + assert_eq!( + generate_thumbnail( + app.state::(), + app.state::(), + asset_id.clone(), + None, + None, + None, + ) + .expect_err("thumbnail cache writer must fail closed"), + expected + ); + assert_eq!( + preview_poster( + app.state::(), + app.state::(), + asset_id.clone(), + None, + ) + .expect_err("preview-poster cache writer must fail closed"), + expected + ); + assert_eq!( + get_waveform(app.state::(), app.state::(), asset_id,) + .expect_err("waveform cache writer must fail closed"), + expected + ); + drop(install); + } + + #[test] + fn update_install_allows_cache_hits_and_nonwriting_thumbnail_requests() { + let temp = tempfile::tempdir().unwrap(); + let (core, _bundle, source, video_id) = saved_core_with_media(temp.path()); + let audio_source = temp.path().join("audio.wav"); + fs::write(&audio_source, b"audio-placeholder").unwrap(); + let audio_id = core + .import_media_file(&audio_source, "audio", &ProbedMedia::default()) + .expect("import audio fixture") + .id; + core.save_project(None).expect("persist audio fixture"); + + let engine = engine_for(temp.path()); + let key = cache_key_for(&source).expect("video cache key"); + let thumbnail_path = timed_poster_path_for(engine.cache_root(), &key, 0.0); + let preview_path = preview_poster_path_for(engine.cache_root(), &key, 0.0); + write_png(&thumbnail_path, &RgbaFrame::black(2, 2)).expect("seed thumbnail cache"); + write_png(&preview_path, &RgbaFrame::black(4, 4)).expect("seed preview cache"); + let cached_waveform = vec![0.25, 0.75]; + opentake_media::waveform::store::save_waveform(engine.cache_root(), &key, &cached_waveform) + .expect("seed waveform cache"); + + let admission = crate::updater::InstallAdmissionGate::default(); + let app = tauri::test::mock_app(); + app.manage(core); + app.manage(MediaState::new_with_admission(engine, admission.clone())); + let install = admission.begin_install().expect("install starts"); + + let thumbnail = generate_thumbnail( + app.state::(), + app.state::(), + video_id.clone(), + None, + None, + Some(false), + ) + .expect("cached thumbnail is read-only"); + assert_eq!( + thumbnail.thumbnail_path.as_deref(), + Some(thumbnail_path.to_string_lossy().as_ref()) + ); + assert_eq!( + preview_poster( + app.state::(), + app.state::(), + video_id.clone(), + None, + ) + .expect("cached preview is read-only") + .as_deref(), + Some(preview_path.to_string_lossy().as_ref()) + ); + assert_eq!( + get_waveform(app.state::(), app.state::(), video_id,) + .expect("cached waveform is read-only"), + cached_waveform + ); + let audio_thumbnail = generate_thumbnail( + app.state::(), + app.state::(), + audio_id, + None, + None, + Some(false), + ) + .expect("audio has no thumbnail cache write"); + assert_eq!(audio_thumbnail.kind, ClipType::Audio); + assert_eq!(audio_thumbnail.thumbnail_path, None); + drop(install); + } + + #[test] + fn get_media_does_not_persist_proxy_scope_during_update_install() { + let temp = tempfile::tempdir().unwrap(); + let (core, _bundle, _source, asset_id) = saved_core_with_media(temp.path()); + let snapshot = core.runtime_snapshot(); + let project_dir = snapshot.project_dir.clone().unwrap(); + let proxy = project_dir.join("media/proxies/proxy.mp4"); + fs::create_dir_all(proxy.parent().unwrap()).unwrap(); + fs::write(&proxy, b"proxy").unwrap(); + core.set_media_proxy_for_project( + snapshot.project_epoch, + &project_dir, + &asset_id, + Some(MediaProxy { + relative_path: "media/proxies/proxy.mp4".into(), + source_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .into(), + width: 1280, + height: 720, + }), + ) + .unwrap(); + let admission = crate::updater::InstallAdmissionGate::default(); + let app = tauri::test::mock_app(); + app.manage(core); + app.manage(MediaState::new_with_admission( + engine_for(temp.path()), + admission.clone(), + )); + let install = admission.begin_install().expect("install starts"); + + let catalog = get_media( + app.handle().clone(), + app.state::(), + app.state::(), + ); + + assert_eq!(catalog.items.len(), 1); + assert_eq!(catalog.items[0].proxy_path, None); + assert!(!app.handle().asset_protocol_scope().is_allowed(&proxy)); + drop(install); + } + #[cfg(unix)] #[test] fn proxy_asset_scope_rejects_symlink() { @@ -7967,6 +8374,28 @@ mod tests { state.finish(&second); } + #[test] + fn deferred_inspector_analyses_share_update_install_admission() { + let admission = crate::updater::InstallAdmissionGate::default(); + let stabilization = StabilizationAnalysisState::new(admission.clone()); + let loudness = LoudnessAnalysisState::new(admission.clone()); + let denoise = DenoiseAnalysisState::new(admission.clone()); + + let stabilization_token = stabilization.begin().unwrap(); + let loudness_token = loudness.begin().unwrap(); + let denoise_token = denoise.begin().unwrap(); + assert!(admission.begin_install().is_err()); + + stabilization.finish(&stabilization_token); + loudness.finish(&loudness_token); + denoise.finish(&denoise_token); + let install = admission.begin_install().unwrap(); + assert!(stabilization.begin().is_err()); + assert!(loudness.begin().is_err()); + assert!(denoise.begin().is_err()); + drop(install); + } + #[test] fn project_identity_transition_cancels_every_inspector_analysis() { let stabilization = StabilizationAnalysisState::default(); @@ -8002,4 +8431,67 @@ mod tests { let second = state.begin().expect("slot is reusable"); state.finish(&second); } + + #[test] + fn stem_separation_holds_update_admission_until_finish() { + let admission = crate::updater::InstallAdmissionGate::default(); + let state = StemSeparationState::new(admission.clone()); + + let token = state.begin().expect("stem separation starts"); + assert!( + admission.begin_install().is_err(), + "install must wait through the final persisted stem import" + ); + state.finish(&token); + + let install = admission + .begin_install() + .expect("finished job releases gate"); + assert!( + state.begin().is_err(), + "an installer that wins admission rejects new stem work" + ); + drop(install); + } + + #[test] + fn media_proxy_holds_update_admission_until_finish() { + let admission = crate::updater::InstallAdmissionGate::default(); + let state = MediaProxyState::new(admission.clone()); + + let token = state.begin().expect("proxy transcode starts"); + assert!( + admission.begin_install().is_err(), + "install must wait through the final proxy manifest commit" + ); + state.finish(&token); + + let install = admission + .begin_install() + .expect("finished job releases gate"); + assert!( + state.begin().is_err(), + "an installer that wins admission rejects new proxy work" + ); + drop(install); + } + + #[test] + fn direct_media_project_writer_is_mutually_exclusive_with_update_install() { + let admission = crate::updater::InstallAdmissionGate::default(); + + let write = begin_direct_media_project_write(&admission).expect("writer starts"); + assert!( + admission.begin_install().is_err(), + "a direct writer that starts first must block install" + ); + drop(write); + + let install = admission.begin_install().expect("install starts"); + assert!( + begin_direct_media_project_write(&admission).is_err(), + "install that starts first must reject a stale writer IPC" + ); + drop(install); + } } diff --git a/src-tauri/src/media/prewarm.rs b/src-tauri/src/media/prewarm.rs index 74ca7737..767a842b 100644 --- a/src-tauri/src/media/prewarm.rs +++ b/src-tauri/src/media/prewarm.rs @@ -79,6 +79,7 @@ struct SchedulerInner { sender: SyncSender, low_sender: SyncSender, state: Mutex, + admission: crate::updater::InstallAdmissionGate, } type PrewarmWork = Box; @@ -88,6 +89,7 @@ struct PrewarmJob { token: MediaCancelToken, reservation: ReservationKey, low_priority: bool, + admission: crate::updater::ActivityLease, work: PrewarmWork, } @@ -111,11 +113,22 @@ struct ReservationGuard { impl PrewarmScheduler { pub fn new(active_epoch: u64) -> Self { + Self::new_with_admission( + active_epoch, + crate::updater::InstallAdmissionGate::default(), + ) + } + + pub(crate) fn new_with_admission( + active_epoch: u64, + admission: crate::updater::InstallAdmissionGate, + ) -> Self { let (sender, receiver) = mpsc::sync_channel(PREWARM_QUEUE_CAPACITY); let (low_sender, low_receiver) = mpsc::sync_channel(LOW_PRIORITY_QUEUE_CAPACITY); let inner = Arc::new(SchedulerInner { sender, low_sender, + admission, state: Mutex::new(SchedulerState { active_epoch, transitioning: false, @@ -226,7 +239,7 @@ impl PrewarmScheduler { cache_key: cache_key.into(), }; let low_priority = kind == PrewarmKind::TimelineSprite; - let token = { + let (token, admission) = { let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner()); if state.transitioning || state.active_epoch != epoch { return PrewarmResult::StaleProject; @@ -246,23 +259,35 @@ impl PrewarmScheduler { ); return PrewarmResult::Cancelled; } - if !state.in_flight.insert(reservation.clone()) { + if state.in_flight.contains(&reservation) { return PrewarmResult::Duplicate; } - if low_priority { + let Ok(admission) = crate::updater::begin_mutating_activity(&self.inner.admission) + else { + if low_priority { + state + .timeline_sprite_statuses + .insert(reservation.cache_key.clone(), TimelineSpriteStatus::Busy); + } + return PrewarmResult::Busy; + }; + state.in_flight.insert(reservation.clone()); + let token = if low_priority { state .timeline_sprite_statuses .insert(reservation.cache_key.clone(), TimelineSpriteStatus::Queued); state.low_cancel.clone() } else { state.cancel.clone() - } + }; + (token, admission) }; let job = PrewarmJob { epoch, token, reservation: reservation.clone(), low_priority, + admission, work: Box::new(work), }; let send = if low_priority { @@ -514,6 +539,7 @@ fn prewarm_worker(inner: Weak, receiver: Arc, receiver: Arc, - active: Arc>>, + active: Arc>>, + admission: crate::updater::InstallAdmissionGate, +} + +struct ActiveMotionCommand { + cancel: opentake_media::MediaCancelToken, + _admission: crate::updater::ActivityLease, } impl MotionCommandState { - pub fn new(bridge: Arc) -> Self { + pub(crate) fn new( + bridge: Arc, + admission: crate::updater::InstallAdmissionGate, + ) -> Self { Self { bridge, active: Arc::new(Mutex::new(None)), + admission, } } fn begin(&self) -> Result { + let admission = self.admission.begin_activity()?; let mut active = self .active .lock() @@ -70,7 +81,10 @@ impl MotionCommandState { return Err("another motion render is already running".into()); } let cancel = opentake_media::MediaCancelToken::new(); - *active = Some(cancel.clone()); + *active = Some(ActiveMotionCommand { + cancel: cancel.clone(), + _admission: admission, + }); Ok(cancel) } @@ -84,7 +98,7 @@ impl MotionCommandState { self.active .lock() .ok() - .and_then(|active| active.clone()) + .and_then(|active| active.as_ref().map(|command| command.cancel.clone())) .map(|cancel| { cancel.cancel(); true @@ -92,8 +106,15 @@ impl MotionCommandState { .unwrap_or(false) } - pub fn cancel_active(&self) { - let _ = self.cancel(); + pub fn has_active(&self) -> bool { + self.active + .lock() + .map(|active| active.is_some()) + .unwrap_or(true) + } + + pub fn cancel_active(&self) -> bool { + self.cancel() } } @@ -935,4 +956,36 @@ mod tests { ); validate_motion_result(&serde_json::to_vec(&expected).unwrap(), &expected).unwrap(); } + + #[test] + fn updater_gate_observes_and_cancels_an_active_motion_render() { + let temp = tempfile::tempdir().unwrap(); + let bridge = Arc::new(TauriMotionBridge::new(AppCore::new(), temp.path())); + let admission = crate::updater::InstallAdmissionGate::default(); + let state = MotionCommandState::new(bridge, admission.clone()); + assert!(!state.has_active()); + + let token = state.begin().unwrap(); + assert!(state.has_active()); + assert!(state.cancel_active()); + assert!(token.is_cancelled()); + + state.finish(); + assert!(!state.has_active()); + assert!(!state.cancel_active()); + } + + #[test] + fn motion_cannot_begin_after_update_install_claims_admission() { + let temp = tempfile::tempdir().unwrap(); + let bridge = Arc::new(TauriMotionBridge::new(AppCore::new(), temp.path())); + let admission = crate::updater::InstallAdmissionGate::default(); + let state = MotionCommandState::new(bridge, admission.clone()); + let _install = admission.begin_install().unwrap(); + + assert_eq!( + state.begin().err().unwrap(), + "app update installation is in progress" + ); + } } diff --git a/src-tauri/src/playback/audio.rs b/src-tauri/src/playback/audio.rs index c21753eb..f6b742df 100644 --- a/src-tauri/src/playback/audio.rs +++ b/src-tauri/src/playback/audio.rs @@ -51,7 +51,9 @@ const STREAM_WINDOW_CAPACITY: usize = 4; const STREAM_SEND_POLL: Duration = Duration::from_millis(5); const CALLBACK_START_TIMEOUT: Duration = Duration::from_secs(1); const CALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(5); +const AUDIO_CLOCK_STALL_TIMEOUT: Duration = Duration::from_millis(150); const CALLBACKS_REQUIRED_FOR_LIVENESS: u64 = 2; +pub(super) const AUDIO_PREPARE_BUSY: &str = "audio_prepare_busy"; type AudioRateReply = SyncSender>; @@ -72,18 +74,37 @@ struct AudioPrepareJob { result: tokio::sync::oneshot::Sender>, } -struct AudioPrepareOccupancyGuard(Arc); +struct AudioPrepareOccupancy { + occupied: AtomicBool, + idle: tokio::sync::Notify, +} + +impl AudioPrepareOccupancy { + fn new() -> Self { + Self { + occupied: AtomicBool::new(false), + idle: tokio::sync::Notify::new(), + } + } + + fn release(&self) { + self.occupied.store(false, Ordering::Release); + self.idle.notify_waiters(); + } +} + +struct AudioPrepareOccupancyGuard(Arc); impl Drop for AudioPrepareOccupancyGuard { fn drop(&mut self) { - self.0.store(false, Ordering::Release); + self.0.release(); } } /// One persistent blocking worker with exactly one admitted audio-prepare job. pub struct AudioPrepareWorker { sender: SyncSender>, - occupied: Arc, + occupancy: Arc, } /// Owning admission for the single audio-prepare worker slot. Dropping an @@ -92,7 +113,7 @@ pub struct AudioPrepareWorker { #[must_use] pub struct AudioPreparePermit { sender: SyncSender>, - occupied: Arc, + occupancy: Arc, reserved: bool, } @@ -111,7 +132,7 @@ impl AudioPreparePermit { self.reserved = false; Ok(receiver) } - Err(TrySendError::Full(_)) => Err("audio_prepare_busy".to_string()), + Err(TrySendError::Full(_)) => Err(AUDIO_PREPARE_BUSY.to_string()), Err(TrySendError::Disconnected(_)) => Err("audio_prepare_worker_stopped".to_string()), } } @@ -120,7 +141,7 @@ impl AudioPreparePermit { impl Drop for AudioPreparePermit { fn drop(&mut self) { if self.reserved { - self.occupied.store(false, Ordering::Release); + self.occupancy.release(); } } } @@ -128,29 +149,30 @@ impl Drop for AudioPreparePermit { impl AudioPrepareWorker { pub fn new() -> Self { let (sender, receiver) = mpsc::sync_channel::>(1); - let occupied = Arc::new(AtomicBool::new(false)); - let worker_occupied = Arc::clone(&occupied); + let occupancy = Arc::new(AudioPrepareOccupancy::new()); + let worker_occupancy = Arc::clone(&occupancy); let _ = thread::Builder::new() .name("opentake-audio-prepare".to_string()) .spawn(move || { while let Ok(job) = receiver.recv() { - let occupancy = AudioPrepareOccupancyGuard(Arc::clone(&worker_occupied)); + let occupancy = AudioPrepareOccupancyGuard(Arc::clone(&worker_occupancy)); let value = catch_unwind(AssertUnwindSafe(job.build)) .map_err(|_| "audio_prepare_job_panicked".to_string()); drop(occupancy); let _ = job.result.send(value); } }); - Self { sender, occupied } + Self { sender, occupancy } } pub fn try_reserve(&self) -> Result, String> { - self.occupied + self.occupancy + .occupied .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .map_err(|_| "audio_prepare_busy".to_string())?; + .map_err(|_| AUDIO_PREPARE_BUSY.to_string())?; Ok(AudioPreparePermit { sender: self.sender.clone(), - occupied: Arc::clone(&self.occupied), + occupancy: Arc::clone(&self.occupancy), reserved: true, }) } @@ -163,7 +185,21 @@ impl AudioPrepareWorker { } pub fn is_occupied(&self) -> bool { - self.occupied.load(Ordering::Acquire) + self.occupancy.occupied.load(Ordering::Acquire) + } + + /// Wait for the admitted closure to return without polling a worker thread. + pub async fn wait_until_idle(&self, timeout: Duration) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let idle = self.occupancy.idle.notified(); + if !self.is_occupied() { + return true; + } + if tokio::time::timeout_at(deadline, idle).await.is_err() { + return !self.is_occupied(); + } + } } } @@ -304,14 +340,83 @@ pub struct AudioClock { /// Project fps (for `seek`, which has no fps argument). fps: i32, stream: Option>, + progress: Mutex, +} + +struct AudioClockProgress { + observed_pos: u64, + observed_at: Instant, + fallback: Option<(Instant, i32)>, + last_frame: i32, +} + +impl AudioClock { + fn new( + pos: Arc, + rate: u32, + fps: i32, + stream: Option>, + ) -> Self { + let observed_pos = pos.load(Ordering::Acquire); + let initial_frame = audio_position_frame(observed_pos, rate, fps); + Self { + pos, + rate, + fps, + stream, + progress: Mutex::new(AudioClockProgress { + observed_pos, + observed_at: Instant::now(), + fallback: None, + last_frame: initial_frame, + }), + } + } +} + +fn audio_position_frame(pos: u64, rate: u32, fps: i32) -> i32 { + let fps = fps.max(1); + ((pos as f64 / rate.max(1) as f64) * fps as f64) as i32 } impl PlaybackClock for AudioClock { fn frame(&self, fps: i32) -> i32 { let fps = if fps > 0 { fps } else { self.fps.max(1) }; - let pos = self.pos.load(Ordering::Relaxed); - // Truncate (secondsToFrame = Int(secs*fps)). - ((pos as f64 / self.rate.max(1) as f64) * fps as f64) as i32 + let pos = self.pos.load(Ordering::Acquire); + let audio_frame = audio_position_frame(pos, self.rate, fps); + let now = Instant::now(); + let mut progress = self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if pos != progress.observed_pos { + progress.observed_pos = pos; + progress.observed_at = now; + } else if progress.fallback.is_none() + && now.saturating_duration_since(progress.observed_at) >= AUDIO_CLOCK_STALL_TIMEOUT + { + // The callback was proven live at startup/resume, but devices can be + // interrupted later. Continue from the last monotonic frame on wall + // time instead of rendering the same timeline frame forever. + progress.fallback = Some((progress.observed_at, progress.last_frame.max(audio_frame))); + } + + let candidate = if let Some((origin, base_frame)) = progress.fallback { + let elapsed_frames = + (now.saturating_duration_since(origin).as_secs_f64() * fps as f64) as i32; + let wall_frame = base_frame.saturating_add(elapsed_frames.max(0)); + if audio_frame >= wall_frame { + progress.fallback = None; + audio_frame + } else { + wall_frame + } + } else { + audio_frame + }; + progress.last_frame = progress.last_frame.max(candidate); + progress.last_frame } fn seek(&self, frame: i32) { @@ -323,6 +428,17 @@ impl PlaybackClock for AudioClock { let pos = ((frame.max(0) as f64 / fps as f64) * self.rate as f64).round() as u64; // Release pairs with the callback's AcqRel fetch_add so it observes the seek. self.pos.store(pos, Ordering::Release); + let mut progress = self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *progress = AudioClockProgress { + observed_pos: pos, + observed_at: Instant::now(), + fallback: None, + last_frame: frame.max(0), + }; + drop(progress); if let Some(stream) = &self.stream { stream.request_seek(pos); } @@ -1448,12 +1564,12 @@ fn build_clock_with_state( }; let pos = Arc::new(AtomicU64::new(start_audio_frame)); let paused = Arc::new(AtomicBool::new(start_paused)); - let clock = AudioClock { - pos: Arc::clone(&pos), + let clock = AudioClock::new( + Arc::clone(&pos), rate, fps, - stream: Some(Arc::clone(&prepared.control)), - }; + Some(Arc::clone(&prepared.control)), + ); match AudioPlayback::start_stream( prepared.consumer, prepared.control, @@ -1483,12 +1599,7 @@ where let buffer = Arc::new(mixed); let pos = Arc::new(AtomicU64::new(0)); let paused = Arc::new(AtomicBool::new(start_paused)); - let clock = AudioClock { - pos: pos.clone(), - rate, - fps, - stream: None, - }; + let clock = AudioClock::new(pos.clone(), rate, fps, None); clock.seek(start_frame); // begin playback at the current playhead match start(buffer, pos, paused) { @@ -1932,20 +2043,49 @@ mod tests { #[test] fn audio_clock_frame_and_seek_round_trip() { - let clock = AudioClock { - pos: Arc::new(AtomicU64::new(0)), - rate: 48_000, - fps: 30, - stream: None, - }; + let clock = AudioClock::new(Arc::new(AtomicU64::new(0)), 48_000, 30, None); // seek(30) → 30 frames = 1s = 48000 output frames → frame()==30. clock.seek(30); assert_eq!(clock.pos.load(Ordering::Relaxed), 48_000); assert_eq!(clock.frame(30), 30); // Half a second of frames → frame 15. - clock.pos.store(24_000, Ordering::Relaxed); - assert_eq!(clock.frame(30), 15); + let half_second = AudioClock::new(Arc::new(AtomicU64::new(24_000)), 48_000, 30, None); + assert_eq!(half_second.frame(30), 15); + } + + #[test] + fn audio_clock_falls_back_to_wall_time_when_callbacks_stall_mid_playback() { + let clock = AudioClock::new(Arc::new(AtomicU64::new(0)), 48_000, 100, None); + + assert_eq!(clock.frame(100), 0); + std::thread::sleep(AUDIO_CLOCK_STALL_TIMEOUT + Duration::from_millis(20)); + + assert!( + clock.frame(100) >= 1, + "a dead audio callback must not freeze the timeline forever" + ); + } + + #[test] + fn recovered_audio_callbacks_and_explicit_seeks_never_rewind_accidentally() { + let pos = Arc::new(AtomicU64::new(0)); + let clock = AudioClock::new(Arc::clone(&pos), 48_000, 100, None); + let _ = clock.frame(100); + std::thread::sleep(AUDIO_CLOCK_STALL_TIMEOUT + Duration::from_millis(20)); + let fallback_frame = clock.frame(100); + assert!(fallback_frame >= 1); + + // A recovering device may initially report a position behind the wall + // fallback. It must catch up without pulling the timeline backwards. + pos.store(4_800, Ordering::Release); + assert!(clock.frame(100) >= fallback_frame); + pos.store(48_000, Ordering::Release); + assert!(clock.frame(100) >= 100); + + // A user/transport seek is authoritative and intentionally may move back. + clock.seek(7); + assert_eq!(clock.frame(100), 7); } #[test] @@ -2025,12 +2165,7 @@ mod tests { #[test] fn audio_clock_truncates_partial_frames() { - let clock = AudioClock { - pos: Arc::new(AtomicU64::new(0)), - rate: 48_000, - fps: 30, - stream: None, - }; + let clock = AudioClock::new(Arc::new(AtomicU64::new(0)), 48_000, 30, None); // 1599 frames @ 48k, 30fps = 0.999 video frame → truncates to 0. clock.pos.store(1_599, Ordering::Relaxed); assert_eq!(clock.frame(30), 0); @@ -2044,12 +2179,7 @@ mod tests { // 44100 Hz @ 24 fps: rate/fps = 1837.5 (not integer). seek (round) + // frame (truncate) must still land back on the same frame — a regression // guard for the truncate-only seek that reported frame-1 here. - let clock = AudioClock { - pos: Arc::new(AtomicU64::new(0)), - rate: 44_100, - fps: 24, - stream: None, - }; + let clock = AudioClock::new(Arc::new(AtomicU64::new(0)), 44_100, 24, None); for f in [1, 7, 23, 100, 511] { clock.seek(f); assert_eq!(clock.frame(24), f, "seek({f}) must round-trip"); diff --git a/src-tauri/src/playback/commands.rs b/src-tauri/src/playback/commands.rs index 3f141b62..ff10f35a 100644 --- a/src-tauri/src/playback/commands.rs +++ b/src-tauri/src/playback/commands.rs @@ -12,6 +12,7 @@ //! existing `