wai-quantum-desktop: installers on a tag for macOS, Linux and Windows, signed updates, the cask and the Scoop manifest #549

Merged
dcharlot merged 6 commits from wai/quantum-desktop-release into main 2026-10-30 12:04:28 -04:00
28 changed files with 1943 additions and 41 deletions

View file

@ -0,0 +1,417 @@
name: wai-quantum desktop release
# On a tag wai-quantum-desktop-v<version> (the version wai/wai-quantum-desktop/Cargo.toml
# and its tauri.conf.json state), builds Quantum Studio for the desktop on each runner and
# attaches its installers to the forge release of that tag, each with its SHA-256:
# macOS aarch64-apple-darwin and x86_64-apple-darwin: a disk image (.dmg), with the
# GPU state vector;
# Linux x86_64- and aarch64-unknown-linux-gnu: an AppImage, a Debian package and an
# RPM package;
# Windows x86_64-pc-windows-msvc: an installer (-setup.exe) and a portable archive
# (.zip) for package managers.
# Each build runs what it made before it is attached: the app's `check` (a Bell pair run in
# its worker, sealed and verified) from the bundle where the runner can run it, and its
# `update-status` (the app, the AppImage and the Debian package's app know their installer;
# the portable archive's copy knows none, so it never updates itself), the macOS app's code
# signature, the disk image's checksums, and the Debian package unpacked.
#
# Signing. Each kind is skipped, and the log says so, when its secrets are absent:
# - Updates: with the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY (and, if the key has one,
# WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD), every installer an update installs is
# signed (the macOS app as a .app.tar.gz, the AppImage, the packages, the Windows
# installer), and the built app's `verify-update` checks each signature with the public
# key in tauri.conf.json, for this version, before anything is attached. Without the
# secret the release has installers and no update: copies already installed are not
# offered it, and update themselves no further until a release is signed.
# - macOS: with APPLE_CERTIFICATE (a Developer ID Application certificate, .p12 in
# base64), APPLE_CERTIFICATE_PASSWORD and APPLE_SIGNING_IDENTITY, the app is signed with
# that identity; with APPLE_API_ISSUER, APPLE_API_KEY and APPLE_API_KEY_P8 (or APPLE_ID,
# APPLE_PASSWORD and APPLE_TEAM_ID) it is also notarised and stapled. Without them the
# app is signed ad hoc and not notarised, and the cask says how to open it.
#
# A last job writes the Homebrew cask and the Scoop manifest from the assets just
# attached, and, when the builds were signed for updates, the update manifest
# (latest.json), and attaches them to this version's release. The app asks the website
# for the update manifest (plugins.updater.endpoints), and the website takes it from
# this release when it is next deployed. Nothing is pushed to the tap or the bucket:
# their repositories take the cask and the manifest by hand.
#
# Uploads go to the forge directly over the private network (FORGE_API), never through
# the public tunnel. A runner that is down leaves its platform's assets missing: the
# release lists what each build attached, and the manifests leave that platform out.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ['wai-quantum-desktop-v*']
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
FORGE_API: http://100.64.224.0:3400/api/v1
FORGE_REPO: Transaction-Science/open-standards
PUBLIC: https://git.transaction.science/Transaction-Science/open-standards
# The command that bundles, pinned (cargo install --locked).
TAURI_CLI: 2.12.3
jobs:
version:
name: the tag is the app's version
runs-on: linux
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- uses: actions/checkout@v5
- id: v
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
v="${tag#wai-quantum-desktop-v}"
crate="$(sed -n 's/^version = "\(.*\)"$/\1/p' wai/wai-quantum-desktop/Cargo.toml | head -1)"
conf="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' wai/wai-quantum-desktop/tauri.conf.json)"
[ "$v" = "$crate" ] || { echo "::error::the tag says $v, wai-quantum-desktop/Cargo.toml says $crate"; exit 1; }
[ "$v" = "$conf" ] || { echo "::error::the tag says $v, wai-quantum-desktop/tauri.conf.json says $conf"; exit 1; }
echo "version=$v" >> "$GITHUB_OUTPUT"
macos:
name: macOS · ${{ matrix.triple }}
needs: version
runs-on: macos
timeout-minutes: 120
strategy:
fail-fast: false
max-parallel: 1
matrix:
triple: [aarch64-apple-darwin, x86_64-apple-darwin]
env:
SECRET_UPDATER_KEY: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY }}
SECRET_UPDATER_KEY_PASSWORD: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD }}
SECRET_APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
SECRET_APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
SECRET_APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
SECRET_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
SECRET_APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
SECRET_APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
SECRET_APPLE_ID: ${{ secrets.APPLE_ID }}
SECRET_APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
SECRET_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo and node on the macOS host runner.
- name: Put cargo and node on PATH
shell: bash
run: |
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
# The app embeds the Studio's build, so it is built first.
- name: Build the Studio
shell: bash
working-directory: wai/quantum-studio
run: |
set -euo pipefail
npm ci --no-audit --no-fund
node build.mjs
# In a folder of its own, by version: the host's own cargo bin may hold another
# version for other work, which this leaves alone.
- name: The bundler, and the target
shell: bash
run: |
set -euo pipefail
rustup target add "${{ matrix.triple }}"
root="$HOME/.cache/wai-ci/tauri-cli-$TAURI_CLI"
[ -x "$root/bin/cargo-tauri" ] || nice -n 10 cargo install --locked tauri-cli --version "$TAURI_CLI" --root "$root" --target-dir "$RUNNER_TEMP/tauri-cli-build"
rm -rf "$RUNNER_TEMP/tauri-cli-build"
echo "$root/bin" >> "$GITHUB_PATH"
"$root/bin/cargo-tauri" tauri --version
- name: Build, sign where the secrets allow, and check
shell: bash
working-directory: wai/wai-quantum-desktop
env:
CI: 'true'
run: |
set -euo pipefail
t="${{ matrix.triple }}"; v="${{ needs.version.outputs.version }}"
cli="$(cargo tauri --version)"; [[ "$cli" == *" $TAURI_CLI" ]] || { echo "::error::cargo tauri is $cli, not $TAURI_CLI"; exit 1; }
source release/signing.sh
if [ -n "${SECRET_APPLE_SIGNING_IDENTITY:-}" ]; then
export APPLE_SIGNING_IDENTITY="$SECRET_APPLE_SIGNING_IDENTITY"
if [ -n "${SECRET_APPLE_CERTIFICATE:-}" ]; then
export APPLE_CERTIFICATE="$SECRET_APPLE_CERTIFICATE" APPLE_CERTIFICATE_PASSWORD="${SECRET_APPLE_CERTIFICATE_PASSWORD:-}"
fi
echo "macOS signing: with the identity APPLE_SIGNING_IDENTITY names"
if [ -n "${SECRET_APPLE_API_KEY_P8:-}" ]; then
(umask 077; printf '%s\n' "$SECRET_APPLE_API_KEY_P8" > "$RUNNER_TEMP/notary-key.p8")
export APPLE_API_ISSUER="$SECRET_APPLE_API_ISSUER" APPLE_API_KEY="$SECRET_APPLE_API_KEY" APPLE_API_KEY_PATH="$RUNNER_TEMP/notary-key.p8"
echo "notarisation: with the API key"
elif [ -n "${SECRET_APPLE_ID:-}" ]; then
export APPLE_ID="$SECRET_APPLE_ID" APPLE_PASSWORD="$SECRET_APPLE_PASSWORD" APPLE_TEAM_ID="$SECRET_APPLE_TEAM_ID"
echo "notarisation: with the Apple ID"
else
echo "::warning::notarisation skipped: neither APPLE_API_KEY_P8 nor APPLE_ID is set; the app is signed and not notarised"
fi
else
echo "macOS signing skipped: the secret APPLE_SIGNING_IDENTITY is not set; the app is signed ad hoc and not notarised"
fi
nice -n 10 cargo tauri build --ci --target "$t" --bundles app,dmg --features metal --config "$UPDATER_CONFIG" -- --locked
rm -f "$RUNNER_TEMP/notary-key.p8"
b="target/$t/release/bundle"
app="$b/macos/Quantum Studio.app"
exe="$app/Contents/MacOS/$(plutil -extract CFBundleExecutable raw -o - "$app/Contents/Info.plist")"
codesign --verify --deep --strict --verbose=2 "$app"
for d in "$b"/dmg/*.dmg; do hdiutil verify "$d"; done
if [ "$t" = "$(rustc -vV | sed -n 's/^host: //p')" ]; then
"$exe" check
# The app knows it was installed as an app (so, with a key, it updates itself).
s="$("$exe" update-status)"; echo "update-status: $s"
[[ "$s" == *'"installed_by":"app"'* ]] || { echo "::error::the bundled app does not know it is the installed app"; exit 1; }
if [ "$UPDATES_SIGNED" = 1 ]; then
for f in "$b"/macos/*.app.tar.gz; do "$exe" verify-update "$f" "$f.sig" "$v"; done
fi
else
echo "note: the $t app is not run here (another architecture)"
fi
bash release/collect.sh "$b" "$v" "$t" "$RUNNER_TEMP/dist"
- name: Attach to the release
shell: bash
working-directory: wai/wai-quantum-desktop
env:
FORGE_TOKEN: ${{ github.token }}
run: bash release/publish.sh "$GITHUB_REF_NAME" "Quantum Studio ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/*
linux:
name: Linux · ${{ matrix.triple }}
needs: version
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- runner: linux
triple: x86_64-unknown-linux-gnu
- runner: linux-arm64
triple: aarch64-unknown-linux-gnu
env:
SECRET_UPDATER_KEY: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY }}
SECRET_UPDATER_KEY_PASSWORD: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0 and the web view's libraries
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev librsvg2-dev file xdg-utils
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build the Studio
shell: bash
working-directory: wai/quantum-studio
run: |
set -euo pipefail
npm ci --no-audit --no-fund
node build.mjs
- name: The bundler
shell: bash
run: |
set -euo pipefail
cargo install --locked tauri-cli --version "$TAURI_CLI"
cargo tauri --version
- name: Build, sign for updates if the key is set, and check
shell: bash
working-directory: wai/wai-quantum-desktop
run: |
set -euo pipefail
t="${{ matrix.triple }}"; v="${{ needs.version.outputs.version }}"
cli="$(cargo tauri --version)"; [[ "$cli" == *" $TAURI_CLI" ]] || { echo "::error::cargo tauri is $cli, not $TAURI_CLI"; exit 1; }
source release/signing.sh
cargo tauri build --ci --target "$t" --bundles appimage,deb,rpm --config "$UPDATER_CONFIG" -- --locked
b="target/$t/release/bundle"
"target/$t/release/wai-quantum-desktop" check
# The AppImage runs (unpacked first: a container has no FUSE), and knows it is one.
for a in "$b"/appimage/*.AppImage; do
APPIMAGE_EXTRACT_AND_RUN=1 "$a" check
s="$(APPIMAGE_EXTRACT_AND_RUN=1 "$a" update-status)"; echo "update-status: $s"
[[ "$s" == *'"installed_by":"appimage"'* ]] || { echo "::error::the AppImage does not know it is one"; exit 1; }
done
# The Debian package holds the app, which runs and knows the package installed it.
for d in "$b"/deb/*.deb; do
dpkg-deb --info "$d" | grep -E '^ (Package|Version|Depends):'
rm -rf "$RUNNER_TEMP/deb" && dpkg-deb -x "$d" "$RUNNER_TEMP/deb"
"$RUNNER_TEMP/deb/usr/bin/wai-quantum-desktop" check
s="$("$RUNNER_TEMP/deb/usr/bin/wai-quantum-desktop" update-status)"; echo "update-status: $s"
[[ "$s" == *'"installed_by":"deb"'* ]] || { echo "::error::the Debian package's app does not know the package installed it"; exit 1; }
done
if [ "$UPDATES_SIGNED" = 1 ]; then
for f in "$b"/appimage/*.AppImage "$b"/deb/*.deb "$b"/rpm/*.rpm; do
"target/$t/release/wai-quantum-desktop" verify-update "$f" "$f.sig" "$v"
done
fi
bash release/collect.sh "$b" "$v" "$t" "$RUNNER_TEMP/dist"
- name: Attach to the release
shell: bash
working-directory: wai/wai-quantum-desktop
env:
FORGE_TOKEN: ${{ github.token }}
run: bash release/publish.sh "$GITHUB_REF_NAME" "Quantum Studio ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/*
windows:
name: Windows · x86_64-pc-windows-msvc
needs: version
runs-on: windows
timeout-minutes: 120
env:
SECRET_UPDATER_KEY: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY }}
SECRET_UPDATER_KEY_PASSWORD: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v5
- name: Build the Studio
shell: pwsh
working-directory: wai/quantum-studio
run: |
$ErrorActionPreference = 'Stop'
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
node --version
npm ci --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
node build.mjs
if ($LASTEXITCODE -ne 0) { throw "the Studio's build failed" }
- name: Build, sign for updates if the key is set, check and package
shell: pwsh
working-directory: wai/wai-quantum-desktop
run: |
$ErrorActionPreference = 'Stop'
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
$t = 'x86_64-pc-windows-msvc'
$v = "${{ needs.version.outputs.version }}"
$name = "wai-quantum-desktop-$v-$t"
$dist = Join-Path $env:RUNNER_TEMP 'dist'
$stage = Join-Path $env:RUNNER_TEMP $name
Remove-Item $stage, $dist -Recurse -Force -ErrorAction Ignore
New-Item -ItemType Directory -Force -Path $stage, $dist | Out-Null
# A run of the built app, waited for (it is a windowed program), its output kept.
function Run-App($exe, [string[]] $appArgs) {
$out = Join-Path $env:RUNNER_TEMP 'app.out'; $err = Join-Path $env:RUNNER_TEMP 'app.err'
$quoted = ($appArgs | ForEach-Object { '"' + $_ + '"' }) -join ' '
$p = Start-Process -FilePath $exe -ArgumentList $quoted -Wait -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError $err
$o = (Get-Content $out -Raw) + ''
Write-Host $o (Get-Content $err -Raw)
if ($p.ExitCode -ne 0) { throw "$exe $($appArgs -join ' ') exited $($p.ExitCode)" }
return $o
}
# The portable copy first, as cargo builds it: the installer's copy is marked as
# installed by it (and so updates itself), the archive's is not (a package manager
# updates it).
cargo build --locked --release --target $t
if ($LASTEXITCODE -ne 0) { throw "the build failed" }
$exe = "target\$t\release\wai-quantum-desktop.exe"
Copy-Item $exe, README.md, ..\LICENSE $stage
Run-App (Join-Path $stage 'wai-quantum-desktop.exe') @('check') | Out-Null
# The portable copy knows no installer made it, so it never updates itself.
$s = Run-App (Join-Path $stage 'wai-quantum-desktop.exe') @('update-status')
if ($s -notmatch '"installed_by":null' -or $s -notmatch '"updates_itself":false') { throw "the portable copy would update itself: $s" }
$updaterConfig = '{}'
if ($env:SECRET_UPDATER_KEY) {
$pub = (Get-Content tauri.conf.json -Raw | ConvertFrom-Json).plugins.updater.pubkey
if (-not $pub) { throw "the update key is set, but tauri.conf.json has no plugins.updater.pubkey: put the public key there first" }
$env:TAURI_SIGNING_PRIVATE_KEY = $env:SECRET_UPDATER_KEY
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "$env:SECRET_UPDATER_KEY_PASSWORD"
$updaterConfig = '{"bundle":{"createUpdaterArtifacts":true}}'
Write-Output "update signing: on, with the key the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY holds"
} else {
Write-Output "update signing skipped: the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY is not set; this release has installers and no update, and installed copies are not offered it"
}
# The bundler in a folder of its own, by version, ahead of any other on PATH.
$root = Join-Path $env:USERPROFILE ".cache\wai-ci\tauri-cli-$env:TAURI_CLI"
if (-not (Test-Path (Join-Path $root 'bin\cargo-tauri.exe'))) {
cargo install --locked tauri-cli --version $env:TAURI_CLI --root $root --target-dir (Join-Path $env:RUNNER_TEMP 'tauri-cli-build')
if ($LASTEXITCODE -ne 0) { throw "the bundler did not install" }
Remove-Item (Join-Path $env:RUNNER_TEMP 'tauri-cli-build') -Recurse -Force -ErrorAction Ignore
}
$env:PATH = (Join-Path $root 'bin') + [IO.Path]::PathSeparator + $env:PATH
$cli = (cargo tauri --version) -join ''
if ($cli -notmatch " $([regex]::Escape($env:TAURI_CLI))$") { throw "cargo tauri is $cli, not $env:TAURI_CLI" }
# '--' quoted, so PowerShell hands it to cargo as it is.
cargo tauri build --ci --target $t --bundles nsis --config $updaterConfig '--' --locked
if ($LASTEXITCODE -ne 0) { throw "the bundle failed" }
$setup = @(Get-ChildItem "target\$t\release\bundle\nsis\*-setup.exe")
if ($setup.Count -ne 1) { throw "the bundle holds $($setup.Count) installers, not one" }
$installer = Join-Path $dist "$name-setup.exe"
Copy-Item $setup[0].FullName $installer
if ($updaterConfig -ne '{}') {
Copy-Item "$($setup[0].FullName).sig" "$installer.sig"
Run-App (Join-Path $stage 'wai-quantum-desktop.exe') @('verify-update', $installer, "$installer.sig", $v) | Out-Null
}
Compress-Archive -Path $stage -DestinationPath (Join-Path $dist "$name.zip") -Force
foreach ($f in "$name-setup.exe", "$name.zip") {
$h = (Get-FileHash (Join-Path $dist $f) -Algorithm SHA256).Hash.ToLower()
Set-Content -NoNewline -Path (Join-Path $dist "$f.sha256") -Value "$h $f`n"
Write-Output "packaged $f ($h)"
}
- name: Attach to the release
shell: pwsh
env:
FORGE_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$H = @{ Authorization = "token $env:FORGE_TOKEN" }
$base = "$env:FORGE_API/repos/$env:FORGE_REPO/releases"
$tag = $env:GITHUB_REF_NAME
$rel = $null
for ($i = 0; $i -lt 6 -and -not $rel; $i++) {
try { $rel = Invoke-RestMethod -Headers $H -Uri "$base/tags/$tag" } catch {
try { Invoke-RestMethod -Headers $H -Method Post -Uri $base -ContentType 'application/json' -Body (@{ tag_name = $tag; name = "Quantum Studio ${{ needs.version.outputs.version }}" } | ConvertTo-Json) | Out-Null } catch {}
Start-Sleep -Seconds 3
}
}
if (-not $rel) { throw "no release for $tag" }
foreach ($f in Get-ChildItem (Join-Path $env:RUNNER_TEMP 'dist')) {
foreach ($a in (Invoke-RestMethod -Headers $H -Uri "$base/$($rel.id)/assets" | Where-Object { $_.name -eq $f.Name })) {
Invoke-RestMethod -Headers $H -Method Delete -Uri "$base/$($rel.id)/assets/$($a.id)" | Out-Null
}
curl.exe -fsS -H "Authorization: token $env:FORGE_TOKEN" -X POST "$base/$($rel.id)/assets?name=$($f.Name)" -F "attachment=@$($f.FullName);type=application/octet-stream" -o NUL -w "uploaded $($f.Name): http %{http_code}`n"
if ($LASTEXITCODE -ne 0) { throw "the upload of $($f.Name) failed" }
}
# The package managers' entries and the update manifest, for the assets just attached:
# a Homebrew cask (macOS), a Scoop manifest (Windows) and latest.json. A platform whose
# build did not attach its assets is left out, and the log says so.
manifests:
name: Homebrew cask, Scoop manifest and update manifest
needs: [version, macos, linux, windows]
if: always() && needs.version.result == 'success'
runs-on: linux
steps:
- uses: actions/checkout@v5
- name: Write and attach them
shell: bash
working-directory: wai/wai-quantum-desktop
env:
FORGE_TOKEN: ${{ github.token }}
MACOS_NOTARISED: ${{ secrets.APPLE_SIGNING_IDENTITY != '' && (secrets.APPLE_API_KEY_P8 != '' || secrets.APPLE_ID != '') }}
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
out="$RUNNER_TEMP/manifests"; mkdir -p "$out"
python3 release/manifests.py "$v" "$GITHUB_REF_NAME" "$FORGE_API" "$FORGE_REPO" "$PUBLIC" "$out"
files=()
for f in wai-quantum-desktop.rb wai-quantum-desktop.json latest.json; do
if [ -f "$out/$f" ]; then files+=("$out/$f"); fi
done
bash release/publish.sh "$GITHUB_REF_NAME" "Quantum Studio $v" "${files[@]}"
if [ -f "$out/latest.json" ]; then
echo "latest.json is on this release; installed copies find $v once the website serves it (scripts/sync-desktop-update.sh $v in its repository)"
fi

View file

@ -22,9 +22,16 @@ name: wai-quantum studio and desktop
# - the course in the browser, in English and in Chinese (studio.test.mjs): a lesson
# checked by the engine, what went wrong, a hint, a pass kept in the browser, and the
# language followed from the browser and switched;
# - the desktop app's tests and clippy with warnings as errors, and `check`: a job
# run in the app's worker process, sealed in the app with a key that never leaves
# it, and verified, with no window.
# - test/update.test.mjs, without a browser: the desktop app's update offer names
# both versions, installs nothing until asked, and reports a refusal;
# - the desktop app on macOS, Linux and Windows: its tests and clippy with warnings
# as errors, and `check`: a job run in the app's worker process, sealed in the app
# with a key that never leaves it, and verified, with no window. The tests hold an
# update to the build's key and the version it was signed for, and say which
# copies update themselves; on Linux, the release's manifests (the Homebrew cask,
# the Scoop manifest, the update manifest) are written from releases made up here
# (release/manifests_test.py). The installers themselves are built by
# wai-quantum-desktop-release.yml, on a tag.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -44,6 +51,7 @@ on:
- 'wai/wai-quantum-views/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-quantum-studio.yml'
- '.github/workflows/wai-quantum-desktop-release.yml'
pull_request:
paths:
- 'wai/quantum-studio/**'
@ -56,6 +64,7 @@ on:
- 'wai/wai-quantum-views/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-quantum-studio.yml'
- '.github/workflows/wai-quantum-desktop-release.yml'
jobs:
relevant:
@ -121,6 +130,7 @@ jobs:
npx playwright install --with-deps chromium
node test/hardware.test.mjs
node test/telemetry.test.mjs
node test/update.test.mjs
node build-views.mjs --check
node test/locales.test.mjs
node build.mjs --pkg "$RUNNER_TEMP/pkg"
@ -159,6 +169,121 @@ jobs:
grep -q '^test tests::device_targets_are_listed_shown_and_placed_for_the_page \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the device targets test did not run and pass"; exit 1; }
grep -q '^test tests::an_answer_is_read_run_and_sealed_for_the_page \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the answer views test did not run and pass"; exit 1; }
grep -q '^test tests::the_course_is_read_and_checked_in_the_app \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the course test did not run and pass"; exit 1; }
for t in an_update_is_held_to_this_build_s_key_and_the_version_it_was_signed_for only_a_copy_installed_by_its_own_installer_updates_itself the_updater_asks_the_release_s_manifest_and_requires_a_signed_version; do
grep -q "^test tests::$t \.\.\. ok\$" "$RUNNER_TEMP/test.log" || { echo "::error::the update test $t did not run and pass"; exit 1; }
done
nice -n 10 cargo clippy --locked --release --all-targets -- -D warnings
nice -n 10 cargo build --locked --release
./target/release/wai-quantum-desktop check
s="$(./target/release/wai-quantum-desktop update-status)"; echo "update-status: $s"
[[ "$s" == *'"installed_by":null'* && "$s" == *'"updates_itself":false'* ]] || { echo "::error::a copy cargo built would update itself"; exit 1; }
desktop-linux:
name: desktop app · linux
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0 and the web view's libraries
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal --component clippy
rustup default 1.98.0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev librsvg2-dev
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build the Studio
shell: bash
working-directory: wai/quantum-studio
run: |
set -euo pipefail
npm ci --no-audit --no-fund
node build.mjs
- name: Tests, clippy, and a job through the worker with no window
shell: bash
working-directory: wai/wai-quantum-desktop
run: |
set -euo pipefail
cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; }
for t in a_run_on_a_provider_is_estimated_refused_until_confirmed_and_sealed_with_the_app_s_key device_targets_are_listed_shown_and_placed_for_the_page an_answer_is_read_run_and_sealed_for_the_page the_course_is_read_and_checked_in_the_app an_update_is_held_to_this_build_s_key_and_the_version_it_was_signed_for only_a_copy_installed_by_its_own_installer_updates_itself the_updater_asks_the_release_s_manifest_and_requires_a_signed_version; do
grep -q "^test tests::$t \.\.\. ok\$" "$RUNNER_TEMP/test.log" || { echo "::error::the test $t did not run and pass"; exit 1; }
done
cargo clippy --locked --release --all-targets -- -D warnings
cargo build --locked --release
./target/release/wai-quantum-desktop check
s="$(./target/release/wai-quantum-desktop update-status)"; echo "update-status: $s"
[[ "$s" == *'"installed_by":null'* && "$s" == *'"updates_itself":false'* ]] || { echo "::error::a copy cargo built would update itself"; exit 1; }
- name: The release's manifests, from releases made up here
shell: bash
working-directory: wai/wai-quantum-desktop
run: |
set -euo pipefail
bash -n release/collect.sh
bash -n release/publish.sh
bash -n release/signing.sh
python3 -I release/manifests_test.py
desktop-windows:
name: desktop app · windows
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: windows
timeout-minutes: 120
steps:
- uses: actions/checkout@v5
- name: Build the Studio
shell: pwsh
working-directory: wai/quantum-studio
run: |
$ErrorActionPreference = 'Stop'
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
node --version
npm ci --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
node build.mjs
if ($LASTEXITCODE -ne 0) { throw "the Studio's build failed" }
- name: Tests, clippy, and a job through the worker with no window
shell: pwsh
working-directory: wai/wai-quantum-desktop
run: |
$ErrorActionPreference = 'Stop'
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
$log = Join-Path $env:RUNNER_TEMP 'test.log'
# cargo writes its progress to stderr; the exit status is what counts.
$ErrorActionPreference = 'Continue'
cargo test --locked --release *> $log
$rc = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
Get-Content $log
if ($rc -ne 0) { throw "the tests failed ($rc)" }
$text = Get-Content $log -Raw
if ($text -notmatch '(?m)^test result: ok\. [1-9][0-9]* passed; 0 failed') { throw "no test ran" }
foreach ($t in @('a_run_on_a_provider_is_estimated_refused_until_confirmed_and_sealed_with_the_app_s_key', 'device_targets_are_listed_shown_and_placed_for_the_page', 'an_answer_is_read_run_and_sealed_for_the_page', 'the_course_is_read_and_checked_in_the_app', 'an_update_is_held_to_this_build_s_key_and_the_version_it_was_signed_for', 'only_a_copy_installed_by_its_own_installer_updates_itself', 'the_updater_asks_the_release_s_manifest_and_requires_a_signed_version')) {
if ($text -notmatch ('(?m)^test tests::' + [regex]::Escape($t) + ' \.\.\. ok\r?$')) { throw "the test $t did not run and pass" }
}
# '--' quoted, so PowerShell hands it to cargo as it is.
cargo clippy --locked --release --all-targets '--' -D warnings
if ($LASTEXITCODE -ne 0) { throw "clippy failed" }
cargo build --locked --release
if ($LASTEXITCODE -ne 0) { throw "the build failed" }
# The app is a windowed program: waited for, its output kept.
$out = Join-Path $env:RUNNER_TEMP 'check.out'; $err = Join-Path $env:RUNNER_TEMP 'check.err'
$p = Start-Process -FilePath 'target\release\wai-quantum-desktop.exe' -ArgumentList 'check' -Wait -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError $err
Get-Content $out, $err
if ($p.ExitCode -ne 0) { throw "check exited $($p.ExitCode)" }
if (-not ((Get-Content $out -Raw) -match '^check: ')) { throw "check said nothing" }
# A copy cargo built knows no installer made it, so it never updates itself.
$p = Start-Process -FilePath 'target\release\wai-quantum-desktop.exe' -ArgumentList 'update-status' -Wait -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError $err
$s = Get-Content $out -Raw
Write-Output "update-status: $s"
if ($p.ExitCode -ne 0 -or $s -notmatch '"installed_by":null' -or $s -notmatch '"updates_itself":false') { throw "a copy cargo built would update itself: $s" }

View file

@ -178,6 +178,45 @@ wire format may change and such changes are recorded here.
implementation, `channel_lineup_verify.py`, from the text alone, agrees on every vector, and a
CI step runs both.
### wai-quantum: Quantum Studio desktop installers, package-manager entries and signed updates
- **Installers** (`.github/workflows/wai-quantum-desktop-release.yml`, on a tag `wai-quantum-desktop-v<version>`):
the desktop app (`wai/wai-quantum-desktop`, still 0.1.0) built on each runner and attached to the forge release
with each asset's SHA-256, uploaded over the private network: a disk image for macOS on Apple silicon and Intel
(with the GPU state vector); an AppImage, a Debian and an RPM package for Linux on x86_64 and arm64; an installer
and a portable archive for Windows on x86_64. Each build runs what it made first: the bundled app's `check` and
`update-status`, the macOS app's code signature and the disk image's checksums, the AppImage, and the Debian
package unpacked. The tag must equal the version in both `Cargo.toml` and `tauri.conf.json`. The app gains
`icons/icon.ico` and `icons/icon.icns`: a Windows build needs the `.ico` for its resource file, and the app had
not been built on Windows before.
- **Package managers**: `release/manifests.py` writes a Homebrew cask (`wai-quantum-desktop`) and a Scoop manifest
from the assets attached and their SHA-256 files, from templates beside it, and attaches them; nothing is pushed
to the tap or the bucket. The cask says the app updates itself only when the release signed its updates, and
says how to open an app signed ad hoc until one is notarised. `release/manifests_test.py` holds the script to
releases made up here (every platform, none signed, one platform, none at all).
- **Updates**: a copy installed by its own installer (the macOS app, the AppImage, the packages, the Windows
installer) asks the update manifest the website serves (`/quantum-studio-desktop/latest.json`) for a newer
version as it opens, and
the Studio offers it in a bar above the frame (`src/update.js`, its words in English and Chinese). It installs
only when asked, and only an update whose signature verifies with the public key in `tauri.conf.json` and
names the version announced (`requireSignedVersion`); the app then restarts into it. A copy built from source or
unpacked by a package manager does not update itself, and its update check answers why. `wai-quantum-desktop
verify-update FILE SIG VERSION` makes the same check, and the release runs it on every update it signs before
attaching anything; `wai-quantum-desktop update-status` says which installer a copy came from, and the release
and CI ask it of each copy (the app, the AppImage and the Debian package know their installer; a copy cargo
built and the portable archive's know none, so they never update themselves). The release's last job writes
`latest.json` from the update signatures and attaches it to the version's own release, which the website
serves from when it is next deployed.
- **Signing is the owner's to switch on**: each step is skipped, and the log says so, while its forge secrets are
absent. `WAI_QUANTUM_DESKTOP_UPDATER_KEY` (and its password) signs updates; until it is set, a release has
installers and no update. The `APPLE_*` secrets sign the macOS app with a Developer ID and notarise it; until then
it is signed ad hoc. The README gives the one command that makes the update key and every secret's name.
- **CI** (`wai-quantum-studio.yml`): the desktop app's tests, clippy and `check` now run on Linux (in the
act-22.04 image, with the web view's libraries) and on Windows as well as on macOS, each leg requiring the update
tests by name, and the Linux leg runs `release/manifests_test.py`; `test/update.test.mjs` (no browser) holds the
update bar's logic. Dependencies added to the app: `tauri-plugin-updater` 2.14.0, with `minisign-verify` 0.3.0 and
`base64` 0.22.1 used directly, both already in its tree.
### wai-quantum for JavaScript: the engine as an npm package
- **`@transaction-science/wai-quantum` 0.1.0** (`wai/wai-quantum-js/`): the job service's engine for WebAssembly

View file

@ -8,6 +8,8 @@ An editor for OpenQASM programs, with three places to run them:
In the desktop app, **your hardware** is a fourth: one of your own providers (`wai-quantum providers add`), its estimate asked for first and a dialog shown before a run that is not free, the credential held by the app and never by the page. In a browser tab the switch only says that such runs go through the desktop app or the command line (`wai-quantum run --provider NAME`), since a browser tab must not hold a provider's credential.
In the desktop app, a copy installed by its own installer looks for a newer version as it opens, and a bar above the Studio offers it. Nothing installs until you choose **Install and restart**, and the app installs an update only when its signature verifies with the key the app carries; which copies update themselves is in [the desktop app's README](../wai-quantum-desktop/README.md#installers-and-updates). A browser tab has no such bar.
Every place returns the same job: the request, the method and why it was chosen, the result, and its receipt. Every run asks for its verdict, and the result shows the level the engine checked to as a badge, with its statement, sealed by the receipt; a result from a host that gives none, and a refused run, have theirs worked out on this device, and say they are not sealed. The Studio shows them the same way and checks every receipt on the machine it runs on. The same request with the same seed gives the same counts in each place.
The editor:

View file

@ -12,6 +12,8 @@
在桌面应用中,**你的硬件**是第四个地方:你自己的某个提供方(`wai-quantum providers add`);运行前先向它询问估算,不免费的运行之前会显示一个对话框;凭据由应用持有,从不由页面持有。在浏览器标签页中,这个切换开关只会说明此类运行须经由桌面应用或命令行进行(`wai-quantum run --provider NAME`),因为浏览器标签页不得持有提供方的凭据。
在桌面应用中,由其自身安装程序安装的副本会在打开时查找新版本,Studio 上方的一栏会提供它。在你选择 **安装并重启** 之前不会安装任何东西;只有当更新的签名能用本应用携带的密钥验证通过时,应用才会安装它。哪些副本会自我更新,见[桌面应用的 README](../wai-quantum-desktop/README.md#installers-and-updates)。浏览器标签页没有这一栏。
每个地方返回的都是同样的作业:请求、所用的方法及选择它的原因、结果,以及结果的回执。每次运行都会请求其判定,结果以徽章显示引擎检查所达到的级别,并附上其陈述,由回执封存;若结果来自不提供判定的主机,或运行被拒绝,其判定就在本设备上得出,并注明未经封存。Studio 以同样的方式显示它们,并在其运行所在的机器上检查每一份回执。同一个请求配上同一个种子,在每个地方都给出相同的计数。
编辑器:

View file

@ -0,0 +1,13 @@
{
"_meta": {
"locale": "en",
"area": "update",
"translation": "source"
},
"update.label": "Update",
"update.available": "Quantum Studio {version} is available (this is {current}). It installs only if its signature verifies with the key this app carries.",
"update.install": "Install and restart",
"update.later": "Not now",
"update.installing": "Downloading the update and checking its signature…",
"update.none_waiting": "No update is waiting."
}

View file

@ -25,6 +25,7 @@ import enShortcutsView from './en/shortcuts_view.json' with { type: 'json' };
import enStateViews from './en/state_views.json' with { type: 'json' };
import enStudy from './en/study.json' with { type: 'json' };
import enTaste from './en/taste.json' with { type: 'json' };
import enUpdate from './en/update.json' with { type: 'json' };
import enVerdictView from './en/verdict_view.json' with { type: 'json' };
import enVersions from './en/versions.json' with { type: 'json' };
import enVisualEdit from './en/visual_edit.json' with { type: 'json' };
@ -53,15 +54,16 @@ import zhShortcutsView from './zh-CN/shortcuts_view.json' with { type: 'json' };
import zhStateViews from './zh-CN/state_views.json' with { type: 'json' };
import zhStudy from './zh-CN/study.json' with { type: 'json' };
import zhTaste from './zh-CN/taste.json' with { type: 'json' };
import zhUpdate from './zh-CN/update.json' with { type: 'json' };
import zhVerdictView from './zh-CN/verdict_view.json' with { type: 'json' };
import zhVersions from './zh-CN/versions.json' with { type: 'json' };
import zhVisualEdit from './zh-CN/visual_edit.json' with { type: 'json' };
import zhWorkbench from './zh-CN/workbench.json' with { type: 'json' };
// The parts of the interface, in the order their words are gathered.
export const AREAS = ['assistant', 'circuit_svg', 'compile_view', 'converge', 'course', 'critique_view', 'debugger', 'device', 'dom', 'estimate_view', 'examples', 'flags', 'frame', 'gallery', 'generators', 'hardware', 'novelty', 'predict', 'rewrite_view', 'rules', 'shortcuts_view', 'state_views', 'study', 'taste', 'verdict_view', 'versions', 'visual_edit', 'workbench'];
export const AREAS = ['assistant', 'circuit_svg', 'compile_view', 'converge', 'course', 'critique_view', 'debugger', 'device', 'dom', 'estimate_view', 'examples', 'flags', 'frame', 'gallery', 'generators', 'hardware', 'novelty', 'predict', 'rewrite_view', 'rules', 'shortcuts_view', 'state_views', 'study', 'taste', 'update', 'verdict_view', 'versions', 'visual_edit', 'workbench'];
export default {
en: { ...enAssistant, ...enCircuitSvg, ...enCompileView, ...enConverge, ...enCourse, ...enCritiqueView, ...enDebugger, ...enDevice, ...enDom, ...enEstimateView, ...enExamples, ...enFlags, ...enFrame, ...enGallery, ...enGenerators, ...enHardware, ...enNovelty, ...enPredict, ...enRewriteView, ...enRules, ...enShortcutsView, ...enStateViews, ...enStudy, ...enTaste, ...enVerdictView, ...enVersions, ...enVisualEdit, ...enWorkbench },
'zh-CN': { ...zhAssistant, ...zhCircuitSvg, ...zhCompileView, ...zhConverge, ...zhCourse, ...zhCritiqueView, ...zhDebugger, ...zhDevice, ...zhDom, ...zhEstimateView, ...zhExamples, ...zhFlags, ...zhFrame, ...zhGallery, ...zhGenerators, ...zhHardware, ...zhNovelty, ...zhPredict, ...zhRewriteView, ...zhRules, ...zhShortcutsView, ...zhStateViews, ...zhStudy, ...zhTaste, ...zhVerdictView, ...zhVersions, ...zhVisualEdit, ...zhWorkbench },
en: { ...enAssistant, ...enCircuitSvg, ...enCompileView, ...enConverge, ...enCourse, ...enCritiqueView, ...enDebugger, ...enDevice, ...enDom, ...enEstimateView, ...enExamples, ...enFlags, ...enFrame, ...enGallery, ...enGenerators, ...enHardware, ...enNovelty, ...enPredict, ...enRewriteView, ...enRules, ...enShortcutsView, ...enStateViews, ...enStudy, ...enTaste, ...enUpdate, ...enVerdictView, ...enVersions, ...enVisualEdit, ...enWorkbench },
'zh-CN': { ...zhAssistant, ...zhCircuitSvg, ...zhCompileView, ...zhConverge, ...zhCourse, ...zhCritiqueView, ...zhDebugger, ...zhDevice, ...zhDom, ...zhEstimateView, ...zhExamples, ...zhFlags, ...zhFrame, ...zhGallery, ...zhGenerators, ...zhHardware, ...zhNovelty, ...zhPredict, ...zhRewriteView, ...zhRules, ...zhShortcutsView, ...zhStateViews, ...zhStudy, ...zhTaste, ...zhUpdate, ...zhVerdictView, ...zhVersions, ...zhVisualEdit, ...zhWorkbench },
};

View file

@ -0,0 +1,13 @@
{
"_meta": {
"locale": "zh-CN",
"area": "update",
"translation": "machine-assisted: to be reviewed by a fluent reader before it is advertised"
},
"update.label": "更新",
"update.available": "Quantum Studio {version} 已可用(当前为 {current})。只有其签名能用本应用携带的密钥验证通过时,才会安装。",
"update.install": "安装并重启",
"update.later": "暂不",
"update.installing": "正在下载更新并核对其签名…",
"update.none_waiting": "没有等待安装的更新。"
}

View file

@ -56,6 +56,7 @@ import { devicePane, deviceBlock } from './device.js';
import { METHOD_NAMES, ACCURACY, compact, accuracyExtras, countsBlock, expectationsBlock, amplitudesBlock, energyText } from './result-parts.js';
import { answerRenderer, answerRecord } from './answer-view.js';
import { telemetryStore, telemetryPanel } from './telemetry.js';
import { Updates, updateBar } from './update.js';
const NOISE = ['depolarizing_1q', 'depolarizing_2q', 'amplitude_damping', 'dephasing', 'readout'].map((k) => [k, tr(`noise.${k}`)]);
const MITIGATE = [['none', 'none'], ['zne', 'zero-noise extrapolation'], ['pea', 'error amplification (learned noise)'], ['pec', 'error cancellation (learned noise)'], ['cdr', 'Clifford data regression'], ['vd', 'virtual distillation'], ['readout', 'readout inversion']];
@ -461,6 +462,12 @@ function mount(root) {
const qec = qecPane({ engine: local });
root.replaceChildren(header, course.node, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, debug.node, gallery.node, workbench.node, found.node, resultPane, qec.node, versions.node, aiPane, answerPane, historyPane, studyUi.node)), footer);
// On the desktop, a newer version found as the app opens, offered above the frame.
if (desktop) {
const update = updateBar({ updates: new Updates(window.__TAURI__.core.invoke), say: (m, cls) => setStatus(m, cls) });
root.prepend(update.node);
setTimeout(() => update.check(), 3000);
}
// ---- the editor ----
const style = HighlightStyle.define([

View file

@ -28,6 +28,9 @@
.qs h1, .qs h2 { margin: 0; text-wrap: balance; }
.qs-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--qs-radius); background: var(--bg-surface); }
.qs-brand { display: flex; align-items: baseline; gap: 8px; margin-right: auto; }
.qs-update { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--qs-radius); background: var(--bg-surface); color: var(--info-text); }
.qs-update[hidden] { display: none; }
.qs-update-text { flex: 1 1 240px; overflow-wrap: anywhere; }
.qs-brand .qs-title { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: 0.01em; }
.qs-mark { font-family: var(--font-mono, ui-monospace, monospace); color: var(--accent-text); font-size: 14px; }
.qs-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--qs-gap); flex: 1; min-height: 0; }

View file

@ -0,0 +1,64 @@
// Updates, in the desktop app only. A copy installed by its own installer asks
// the release's update manifest for a newer version once it opens, and the bar
// here says what it found. Nothing installs until the reader says so, and the
// app installs an update only when its signature verifies with the key the app
// carries, for the version announced; it then restarts into it. A copy that
// does not update itself (built from source, unpacked by a package manager, or
// built with no update key) says nothing here. The browser has none of this.
import { el } from './dom.js';
import { t as tr } from './i18n.js';
// The app's update commands, read as JSON. `check` answers
// {state: 'off', reason}, {state: 'current', version},
// {state: 'available', version, current, notes} or {error}.
export class Updates {
constructor(invoke) { this.invoke = invoke; this.waiting = null; }
async check() {
let v;
try { v = JSON.parse(await this.invoke('update_check', {})); } catch (e) { v = { error: e?.message || String(e) }; }
this.waiting = v?.state === 'available' ? v : null;
return v;
}
// The update told of: on success the app restarts into it, so this
// returns only with a refusal, which it throws.
async install() {
if (!this.waiting) throw new Error(tr('update.none_waiting'));
const v = JSON.parse(await this.invoke('update_install', {}));
if (v?.error) throw new Error(v.error);
return v;
}
}
// What the reader is told of an answer: words when a newer version is
// waiting, else null (the bar stays hidden).
export function updateNotice(answer) {
if (answer?.state !== 'available') return null;
return tr('update.available', { version: answer.version, current: answer.current });
}
// The bar, hidden until a newer version is found. `say` reports a refusal.
export function updateBar({ updates, say }) {
const words = el('span', { class: 'qs-update-text', id: 'qs-update-text' });
const install = el('button', { class: 'qs-btn qs-primary qs-small', type: 'button', id: 'qs-update-install', text: tr('update.install') });
const later = el('button', { class: 'qs-btn qs-small', type: 'button', id: 'qs-update-later', text: tr('update.later') });
const node = el('div', { class: 'qs-update', id: 'qs-update', role: 'region', 'aria-label': tr('update.label'), hidden: true }, words, install, later);
later.addEventListener('click', () => { node.hidden = true; });
install.addEventListener('click', async () => {
install.disabled = later.disabled = true;
words.textContent = tr('update.installing');
try { await updates.install(); } catch (e) {
node.hidden = true;
say(e.message, 'bad');
}
});
// An answer that is not an update (none, off, or a check that could not
// reach the manifest, as when offline) leaves the bar hidden.
async function check() {
const notice = updateNotice(await updates.check());
if (!notice) return;
words.textContent = notice;
install.disabled = later.disabled = false;
node.hidden = false;
}
return { node, check };
}

View file

@ -0,0 +1,66 @@
// The desktop app's update offer, without a browser: a newer version is told
// of in words naming both versions, nothing else is; nothing installs until
// asked, and only the update told of; a refusal (a signature that does not
// verify, a download that fails) is reported, and a check that cannot reach
// the manifest stays quiet.
//
// node test/update.test.mjs
import { Updates, updateNotice } from '../src/update.js';
let fails = 0;
const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails += 1; };
// The app's commands, faked: `check` is what update_check answers, `install` what update_install does.
function fake(answer, install = () => JSON.stringify({ error: 'the update was not installed: the signature does not verify' })) {
const calls = [];
const invoke = async (cmd, args) => {
calls.push([cmd, args]);
if (cmd === 'update_check') { if (answer instanceof Error) throw answer; return JSON.stringify(answer); }
if (cmd === 'update_install') return install();
return JSON.stringify({ error: `no command ${cmd}` });
};
return { calls, invoke };
}
const available = { state: 'available', version: '0.2.0', current: '0.1.0', notes: 'n' };
{
const words = updateNotice(available);
check(typeof words === 'string' && words.includes('0.2.0') && words.includes('0.1.0') && /signature/.test(words), `a newer version is told of, with both versions and the signature check (${words})`);
for (const a of [{ state: 'current', version: '0.1.0' }, { state: 'off', reason: 'this build carries no update key' }, { error: 'offline' }, null, undefined]) {
check(updateNotice(a) === null, `nothing is said for ${JSON.stringify(a)}`);
}
}
{
const f = fake(available);
const u = new Updates(f.invoke);
const v = await u.check();
check(v.state === 'available' && f.calls.length === 1 && f.calls[0][0] === 'update_check', 'a check asks the app once and installs nothing');
let err = '';
try { await u.install(); } catch (e) { err = e.message; }
check(f.calls[1]?.[0] === 'update_install' && /signature does not verify/.test(err), `an install asked for runs the app's install, and its refusal is reported (${err})`);
}
{
const f = fake({ state: 'off', reason: 'this copy was built from source' });
const u = new Updates(f.invoke);
await u.check();
let err = '';
try { await u.install(); } catch (e) { err = e.message; }
check(err === 'No update is waiting.' && !f.calls.some((c) => c[0] === 'update_install'), 'with no update told of, an install asks the app nothing');
}
{
const f = fake(available, () => JSON.stringify({ restarting: true }));
const u = new Updates(f.invoke);
await u.check();
let threw = false;
try { await u.install(); } catch (e) { threw = true; }
check(!threw && f.calls.filter((c) => c[0] === 'update_install').length === 1, 'an update that installs is installed once');
}
{
const f = fake(new Error('the app has no such command'));
const u = new Updates(f.invoke);
const v = await u.check();
check(v.error === 'the app has no such command' && updateNotice(v) === null && u.waiting === null, 'a check that fails is an answer that says nothing to the reader');
}
console.log(fails ? `${fails} check(s) failed` : 'every check passed');
process.exit(fails ? 1 : 0);

View file

@ -97,6 +97,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -510,6 +519,16 @@ dependencies = [
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -533,7 +552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@ -546,7 +565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@ -622,7 +641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@ -753,6 +772,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@ -979,7 +1009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1013,6 +1043,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
@ -1538,6 +1578,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
@ -1557,9 +1612,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -1849,6 +1906,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.21",
"walkdir",
"windows-link",
]
[[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.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@ -2043,6 +2130,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -2292,6 +2385,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@ -2318,6 +2412,18 @@ dependencies = [
"objc2-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.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@ -2387,12 +2493,32 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[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.21",
]
[[package]]
name = "pango"
version = "0.18.3"
@ -2786,15 +2912,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",
@ -2845,7 +2976,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2863,6 +2994,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@ -2872,6 +3015,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
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",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
@ -2898,6 +3068,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@ -2955,6 +3134,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.38.0"
@ -3177,6 +3379,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[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.4"
@ -3374,6 +3592,27 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"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"
@ -3395,7 +3634,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [
"bitflags 2.13.2",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@ -3405,7 +3644,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@ -3438,6 +3677,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@ -3461,7 +3711,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@ -3559,6 +3809,56 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.12.2"
@ -3569,7 +3869,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@ -3592,7 +3892,7 @@ checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@ -3659,6 +3959,19 @@ dependencies = [
"toml 1.1.8+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@ -3778,6 +4091,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@ -4156,9 +4479,12 @@ dependencies = [
name = "wai-quantum-desktop"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"minisign-verify",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"ureq",
"wai-quantum",
"wai-quantum-cli",
@ -4353,6 +4679,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
@ -4418,7 +4753,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4526,6 +4861,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@ -4768,7 +5114,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@ -4815,6 +5161,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@ -4898,6 +5254,18 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.2",
"memchr",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"

View file

@ -21,6 +21,13 @@ wai-quantum-cli = { version = "0.2.2", path = "../wai-quantum-cli", default-feat
# The person's own providers: their registry, their credentials in the
# operating system's keychain, and runs on them sealed with the app's key.
wai-quantum-qpu = { version = "0.3.0", path = "../wai-quantum-qpu" }
# Updates: a newer release found in its update manifest, and installed only
# when its signature verifies with the public key in tauri.conf.json.
tauri-plugin-updater = "2.14.0"
# The same check for `verify-update`, which a release runs on every update it
# signs; both are already in the updater's tree, at these versions.
minisign-verify = "0.3.0"
base64 = "0.22.1"
[features]
default = ["custom-protocol"]

View file

@ -9,6 +9,7 @@
- **The job service is one switch away,** as in the browser.
- **Your own hardware:** the providers you listed with `wai-quantum providers add` are under **Your hardware**. A run asks the provider for its estimate first and shows a dialog before one that is not free; the app reads the credential from the keychain and seals the counts with your key, and the page never sees the credential.
- **Memory:** a job may take `$WAI_QUANTUM_MEMORY_GB` (16 by default). The plan says when a job needs more.
- **Updates:** a copy installed by its own installer looks for a newer version as it opens and offers it above the Studio. Nothing installs until you say so, and an update installs only when its signature verifies with the key the app carries, for the version announced; the app then restarts into it. [Installers and updates](#installers-and-updates) says which copies update themselves.
## What is stable
@ -25,4 +26,59 @@ cargo build --locked --release # add --features metal for the GPU st
./target/release/wai-quantum-desktop check # a job through the worker, sealed and verified, with no window
```
It draws with the operating system's own web view, so the app carries no browser of its own. Installers for macOS, Windows and Linux come from `cargo tauri build`.
It draws with the operating system's own web view, so the app carries no browser of its own.
## Installers and updates
A tag `wai-quantum-desktop-v<version>` (the version `Cargo.toml` and `tauri.conf.json` both state) runs [the release workflow](../../.github/workflows/wai-quantum-desktop-release.yml), which builds the installers on each platform and attaches them to [the forge release](https://git.transaction.science/Transaction-Science/open-standards/releases) of that tag, each with its SHA-256:
| Platform | Assets (`wai-quantum-desktop-<version>-<target>…`) | Updates itself |
|---|---|---|
| macOS, Apple silicon and Intel (with the GPU state vector) | `.dmg` | yes, from Applications |
| Linux, x86_64 and arm64 | `.AppImage`, `.deb`, `.rpm` | yes (the packages through the system's password prompt) |
| Windows, x86_64 | `-setup.exe` | yes |
| Windows, x86_64 | `.zip`, a portable copy, for package managers | no: the package manager updates it |
The same run writes a Homebrew cask, a Scoop manifest and the update manifest (`latest.json`) from the assets it attached ([`release/manifests.py`](release/manifests.py), from the templates beside it), and attaches them to the release. The tap and the bucket take the cask and the manifest from there:
```sh norun=install
brew tap transaction-science/tap https://git.transaction.science/Transaction-Science/homebrew-tap
brew install --cask wai-quantum-desktop
```
```powershell norun=install
scoop bucket add transaction-science https://git.transaction.science/Transaction-Science/scoop-bucket
scoop install wai-quantum-desktop
```
Each build runs what it made before it is attached: the bundled app's `check`, and its `update-status`, which names the installer a copy came from (none, for a copy cargo built or the portable archive's), the macOS app's code signature and the disk image's checksums, the AppImage, and the Debian package unpacked. The bundler is pinned (`cargo install --locked tauri-cli --version 2.12.3`), and the builds are, with `UPDATER_CONFIG` `{}` unless the release signs updates:
```sh norun=ci:wai-quantum-desktop-release.yml
cargo tauri build --ci --target "$t" --bundles app,dmg --features metal --config "$UPDATER_CONFIG" -- --locked # macOS
cargo tauri build --ci --target "$t" --bundles appimage,deb,rpm --config "$UPDATER_CONFIG" -- --locked # Linux
cargo tauri build --ci --target $t --bundles nsis --config $updaterConfig '--' --locked # Windows (PowerShell)
```
**Which copies update themselves.** A copy looks for updates only when it was built with the release's public key and came from its own installer: the macOS app (moved out of its disk image), the AppImage, the Debian or RPM package, or the Windows installer. A copy built from source, or unpacked from the portable archive by a package manager, says nothing of updates and is updated the way it came. The app asks one address, `https://wai.transaction.science/quantum-studio-desktop/latest.json`, which the website serves. Each signed release attaches its own `latest.json`, and the website takes it from that release when it is next deployed (`scripts/sync-desktop-update.sh <version>` in the website's repository, which refuses a manifest for an older version than the one it serves, or one whose updates are not on that version's release). The check is a plain download of that file, and sends nothing else. An update is refused unless its signature verifies with the key in `tauri.conf.json` (`plugins.updater.pubkey`) and names the version the manifest announces (`requireSignedVersion`), so a manifest cannot pair a newer number with an older build. Before attaching anything, the release runs the built app's `verify-update FILE SIG VERSION` on every update it signed, which makes the same check with the same key.
**What the owner sets up.** Until then a release is unsigned and installers are all it makes: the macOS app is signed ad hoc and not notarised (the cask says how to open it the first time), and no copy updates itself.
1. The update key, made once on a machine you trust (it asks for a password; the private key goes to the file and is never printed), then the public key into the app:
```sh norun=account
cargo tauri signer generate -w ~/.tauri/wai-quantum-desktop.key
```
Put the one line of `~/.tauri/wai-quantum-desktop.key.pub` in `tauri.conf.json` as `plugins.updater.pubkey`, and commit that. The private key never goes in the repository.
2. The forge secrets (the repository's Settings, then Actions, then Secrets). The release reads each one only when it is set, and the log says which steps it skipped:
| Secret | What it holds | Without it |
|---|---|---|
| `WAI_QUANTUM_DESKTOP_UPDATER_KEY` | the text of `~/.tauri/wai-quantum-desktop.key` | no update is signed, and no `latest.json` is written |
| `WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD` | its password | (needed if the key has one) |
| `APPLE_SIGNING_IDENTITY` | the Developer ID Application identity, e.g. `Developer ID Application: NAME (TEAMID)` | the app is signed ad hoc |
| `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD` | that certificate as a `.p12` file in base64, and its password (not needed when the macOS runner's keychain already holds the identity) | |
| `APPLE_API_ISSUER`, `APPLE_API_KEY`, `APPLE_API_KEY_P8` | an App Store Connect API key for notarisation: its issuer, its key ID and the `.p8` file's text | not notarised |
| `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID` | instead of the API key: an Apple ID, an app-specific password and the team | |
A key in the secret with no public key in `tauri.conf.json` stops the release, since no copy could check what it is offered.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Gather what `cargo tauri build` made for one target into release assets:
#
# bash release/collect.sh <bundle-dir> <version> <triple> <out-dir>
#
# <bundle-dir> is target/<triple>/release/bundle. Each asset is named
# wai-quantum-desktop-<version>-<triple>.<kind>: on macOS the disk image (.dmg)
# and the app as an update installs it (.app.tar.gz); on Linux the AppImage,
# the Debian package (.deb) and the RPM package (.rpm). Beside each one goes its
# SHA-256 (<asset>.sha256, as `shasum -a 256 -c` reads it) and, when the build
# was signed for updates, its update signature (<asset>.sig). A kind the bundle
# should hold and does not, or holds twice, fails the collection. Windows
# collects in PowerShell, in the release workflow.
set -euo pipefail
if [ $# -ne 4 ]; then
echo "usage: bash release/collect.sh <bundle-dir> <version> <triple> <out-dir>" >&2
exit 2
fi
B="$1"; VERSION="$2"; TRIPLE="$3"; OUT="$4"
NAME="wai-quantum-desktop-$VERSION-$TRIPLE"
mkdir -p "$OUT"
sha() { ( cd "$OUT" && { command -v sha256sum >/dev/null && sha256sum "$1" || shasum -a 256 "$1"; } > "$1.sha256" ); }
# One file in $1 matching the glob $2, copied as $NAME.$3, with its .sig when there is one.
one() {
local dir="$1" glob="$2" kind="$3" found=()
shopt -s nullglob
found=("$dir"/$glob)
shopt -u nullglob
[ "${#found[@]}" -eq 1 ] || { echo "::error::$dir holds ${#found[@]} files matching $glob, not one: ${found[*]:-none}" >&2; exit 1; }
cp "${found[0]}" "$OUT/$NAME.$kind"
sha "$NAME.$kind"
if [ -f "${found[0]}.sig" ]; then
cp "${found[0]}.sig" "$OUT/$NAME.$kind.sig"
fi
echo "collected $OUT/$NAME.$kind ($(cut -d' ' -f1 "$OUT/$NAME.$kind.sha256"))$([ -f "$OUT/$NAME.$kind.sig" ] && echo ', signed for updates')"
}
case "$TRIPLE" in
*-apple-darwin)
one "$B/dmg" '*.dmg' dmg
# The app itself, as an update installs it: made only when the build is signed for updates.
if compgen -G "$B/macos/*.app.tar.gz" > /dev/null; then one "$B/macos" '*.app.tar.gz' app.tar.gz; fi ;;
*-linux-gnu)
one "$B/appimage" '*.AppImage' AppImage
one "$B/deb" '*.deb' deb
one "$B/rpm" '*.rpm' rpm ;;
*) echo "::error::release/collect.sh does not collect $TRIPLE" >&2; exit 2 ;;
esac

View file

@ -0,0 +1,175 @@
"""The package managers' entries and the update manifest for a Quantum Studio
desktop release, from the assets attached to it:
python3 release/manifests.py <version> <tag> <forge-api> <owner/repo> <public-url> <out-dir>
Writes into <out-dir>:
wai-quantum-desktop.rb a Homebrew cask (macOS), from the disk images and their SHA-256 files
(wai-quantum-desktop.rb.in);
wai-quantum-desktop.json a Scoop manifest (Windows), from the portable archive and its SHA-256
(wai-quantum-desktop.json.in);
latest.json the update manifest, from the update signatures: written only when
the builds were signed for updates. The website serves it to the app
(its scripts/sync-desktop-update.sh takes it from this release).
A platform whose assets are not attached is left out, and stderr says so; with nothing at
all, it fails. The cask says `auto_updates true` only when the macOS builds were signed for
updates, since only then does the app update itself.
The environment: FORGE_TOKEN (optional) for the API; ASSETS_DIR reads the assets from a
local folder instead of the release (the test does); MACOS_NOTARISED=true leaves out the
cask's caveat for an app signed ad hoc.
"""
import json
import os
import pathlib
import string
import sys
import urllib.request
version, tag, api, repo, public, out = sys.argv[1:7]
here = pathlib.Path(__file__).resolve().parent
token = os.environ.get("FORGE_TOKEN", "")
# A user agent of our own: the public address refuses the library's default one.
headers = {"User-Agent": "wai-quantum-release"}
if token:
headers["Authorization"] = f"token {token}"
# The forge's own address, as the API's: assets are read there, over the private network,
# not through the public address the entries point users to.
web = api.rsplit("/api/v1", 1)[0]
local = os.environ.get("ASSETS_DIR")
notarised = os.environ.get("MACOS_NOTARISED", "") == "true"
def get(url):
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as r:
return r.read()
if local:
names = set(os.listdir(local))
published = None
def read(name):
return pathlib.Path(local, name).read_text()
else:
release = json.loads(get(f"{api}/repos/{repo}/releases/tags/{tag}"))
names = {a["name"] for a in release.get("assets", [])}
published = release.get("published_at") or release.get("created_at")
def read(name):
return get(f"{web}/{repo}/releases/download/{tag}/{name}").decode()
def asset(triple, suffix):
return f"wai-quantum-desktop-{version}-{triple}{suffix}"
def url(name):
return f"{public}/releases/download/{tag}/{name}"
def digest(name):
"""The SHA-256 its .sha256 file states, or None when either is missing."""
if name not in names or name + ".sha256" not in names:
print(f"note: {name} is not attached; left out", file=sys.stderr)
return None
return read(name + ".sha256").split()[0]
def signature(name):
"""Its update signature (the .sig file's text, base64), or None."""
if name not in names:
return None
if name + ".sig" not in names:
print(f"note: {name} has no update signature; no update for it", file=sys.stderr)
return None
return read(name + ".sig").strip()
out_dir = pathlib.Path(out)
out_dir.mkdir(parents=True, exist_ok=True)
wrote = []
# The update manifest: each platform's update, under the names the updater looks for
# ({os}-{arch}-{installer}, then {os}-{arch}).
updates = [
(["darwin-aarch64"], "aarch64-apple-darwin", ".app.tar.gz"),
(["darwin-x86_64"], "x86_64-apple-darwin", ".app.tar.gz"),
(["linux-x86_64", "linux-x86_64-appimage"], "x86_64-unknown-linux-gnu", ".AppImage"),
(["linux-x86_64-deb"], "x86_64-unknown-linux-gnu", ".deb"),
(["linux-x86_64-rpm"], "x86_64-unknown-linux-gnu", ".rpm"),
(["linux-aarch64", "linux-aarch64-appimage"], "aarch64-unknown-linux-gnu", ".AppImage"),
(["linux-aarch64-deb"], "aarch64-unknown-linux-gnu", ".deb"),
(["linux-aarch64-rpm"], "aarch64-unknown-linux-gnu", ".rpm"),
(["windows-x86_64", "windows-x86_64-nsis"], "x86_64-pc-windows-msvc", "-setup.exe"),
]
platforms = {}
for keys, triple, suffix in updates:
name = asset(triple, suffix)
sig = signature(name)
if sig:
for k in keys:
platforms[k] = {"signature": sig, "url": url(name)}
if platforms:
manifest = {"version": version, "notes": f"Quantum Studio {version}: {public}/releases/tag/{tag}"}
if published:
manifest["pub_date"] = published
manifest["platforms"] = dict(sorted(platforms.items()))
(out_dir / "latest.json").write_text(json.dumps(manifest, indent=2) + "\n")
wrote.append(f"the update manifest ({', '.join(sorted(platforms))})")
else:
print("note: no update is signed (the release had no update key), so there is no update manifest", file=sys.stderr)
# The Homebrew cask, for the disk images.
blocks = []
for cpu, triple in (("arm", "aarch64-apple-darwin"), ("intel", "x86_64-apple-darwin")):
name = asset(triple, ".dmg")
sha = digest(name)
if sha:
blocks.append(
f' on_{cpu} do\n sha256 "{sha}"\n url "{url(name)}",\n'
f' verified: "{public.split("://", 1)[1]}/"\n end\n'
)
if blocks:
self_updating = any(k.startswith("darwin-") for k in platforms)
caveats = "" if notarised else (
'\n caveats <<~EOS\n'
' Quantum Studio is signed ad hoc and not yet notarised. If macOS blocks its first launch, run:\n'
' xattr -dr com.apple.quarantine "/Applications/Quantum Studio.app"\n'
' EOS\n'
)
cask = string.Template((here / "wai-quantum-desktop.rb.in").read_text()).substitute(
version=version,
arch_blocks="".join(blocks),
auto_updates="\n auto_updates true\n" if self_updating else "",
caveats=caveats,
)
(out_dir / "wai-quantum-desktop.rb").write_text(cask)
wrote.append("the Homebrew cask" + (" (the app updates itself)" if self_updating else ""))
# The Scoop manifest, for the portable archive.
win = asset("x86_64-pc-windows-msvc", "")
win_sha = digest(win + ".zip")
if win_sha:
fill = {"version": version, "url": url(win + ".zip"), "hash": win_sha, "extract_dir": win}
def filled(v):
if isinstance(v, str):
return string.Template(v).substitute(fill)
if isinstance(v, list):
return [filled(x) for x in v]
if isinstance(v, dict):
return {k: filled(x) for k, x in v.items()}
return v
scoop = filled(json.loads((here / "wai-quantum-desktop.json.in").read_text()))
(out_dir / "wai-quantum-desktop.json").write_text(json.dumps(scoop, indent=2) + "\n")
wrote.append("the Scoop manifest")
if not wrote:
sys.exit("no installer is attached to the release")
print(f"wrote {', '.join(wrote)} for Quantum Studio {version}")

View file

@ -0,0 +1,130 @@
"""release/manifests.py against releases made up here, without a network:
python3 release/manifests_test.py
- every platform: the cask names each disk image with its SHA-256 and says the app
updates itself; the Scoop manifest names the archive, its SHA-256 and its folder; the
update manifest carries each update's own signature under the names the updater asks
for, and an update with no signature is left out;
- no update key: no update manifest, and a cask that does not say the app updates itself;
- a notarised build: no caveat;
- nothing attached: a failure.
Exit 0 iff every check passes.
"""
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
here = pathlib.Path(__file__).resolve().parent
fails = 0
def check(ok, what):
global fails
print(f"{'PASS' if ok else 'FAIL'} {what}")
if not ok:
fails += 1
V = "0.3.0"
TAG = f"wai-quantum-desktop-v{V}"
PUBLIC = "https://forge.example/Org/repo"
def release(files):
"""A folder of assets: each name with its bytes, its .sha256 and, when asked, its .sig."""
d = pathlib.Path(tempfile.mkdtemp(prefix="wqd-assets-"))
for name, signed in files:
(d / name).write_text(f"bytes of {name}")
(d / f"{name}.sha256").write_text(f"{('%064x' % abs(hash(name)))[:64]} {name}\n")
if signed:
(d / f"{name}.sig").write_text(f"c2lnIG9m{name}\n")
return d
def run(assets, env=None):
out = pathlib.Path(tempfile.mkdtemp(prefix="wqd-out-"))
e = {k: v for k, v in os.environ.items() if k not in ("FORGE_TOKEN", "MACOS_NOTARISED")}
e.update({"ASSETS_DIR": str(assets)}, **(env or {}))
r = subprocess.run([sys.executable, "-I", str(here / "manifests.py"), V, TAG, "http://forge.invalid/api/v1", "Org/repo", PUBLIC, str(out)],
env=e, capture_output=True, text=True)
return r, out
def sha(assets, name):
return (assets / f"{name}.sha256").read_text().split()[0]
P = f"wai-quantum-desktop-{V}-"
every = [
(P + "aarch64-apple-darwin.dmg", False), (P + "aarch64-apple-darwin.app.tar.gz", True),
(P + "x86_64-apple-darwin.dmg", False), (P + "x86_64-apple-darwin.app.tar.gz", False),
(P + "x86_64-unknown-linux-gnu.AppImage", True), (P + "x86_64-unknown-linux-gnu.deb", True), (P + "x86_64-unknown-linux-gnu.rpm", True),
(P + "x86_64-pc-windows-msvc-setup.exe", True), (P + "x86_64-pc-windows-msvc.zip", False),
]
# Every platform.
a = release(every)
r, out = run(a)
check(r.returncode == 0, f"every platform: it runs ({r.stdout.strip()} {r.stderr.strip()})")
cask = (out / "wai-quantum-desktop.rb").read_text()
for cpu, triple in (("arm", "aarch64-apple-darwin"), ("intel", "x86_64-apple-darwin")):
name = P + triple + ".dmg"
check(f'on_{cpu} do\n sha256 "{sha(a, name)}"\n url "{PUBLIC}/releases/download/{TAG}/{name}",' in cask, f"the cask names the {triple} disk image with its SHA-256")
check(f'version "{V}"' in cask and 'app "Quantum Studio.app"' in cask and "auto_updates true" in cask and "xattr -dr com.apple.quarantine" in cask,
"the cask: its version, the app, that it updates itself, and the caveat for an app signed ad hoc")
check('verified: "forge.example/Org/repo/"' in cask, "the cask says where its downloads are verified from")
check("\n\n\n" not in cask and cask.endswith(" EOS\nend\n"), "the cask has no run of blank lines, and ends with its caveat")
if shutil.which("ruby"):
p = out / "wai-quantum-desktop.rb"
rc = subprocess.run(["ruby", "-c", str(p)], capture_output=True, text=True)
check(rc.returncode == 0, f"the cask is Ruby ({rc.stdout.strip()}{rc.stderr.strip()})")
else:
print("note: no ruby here to parse the cask")
scoop = json.loads((out / "wai-quantum-desktop.json").read_text())
win = P + "x86_64-pc-windows-msvc"
check(scoop["version"] == V and scoop["architecture"]["64bit"] == {"url": f"{PUBLIC}/releases/download/{TAG}/{win}.zip", "hash": sha(a, win + ".zip"), "extract_dir": win}
and scoop["shortcuts"] == [["wai-quantum-desktop.exe", "Quantum Studio"]], f"the Scoop manifest names the archive, its SHA-256 and its folder ({scoop['architecture']})")
latest = json.loads((out / "latest.json").read_text())
want = {"darwin-aarch64": P + "aarch64-apple-darwin.app.tar.gz",
"linux-x86_64": P + "x86_64-unknown-linux-gnu.AppImage", "linux-x86_64-appimage": P + "x86_64-unknown-linux-gnu.AppImage",
"linux-x86_64-deb": P + "x86_64-unknown-linux-gnu.deb", "linux-x86_64-rpm": P + "x86_64-unknown-linux-gnu.rpm",
"windows-x86_64": P + "x86_64-pc-windows-msvc-setup.exe", "windows-x86_64-nsis": P + "x86_64-pc-windows-msvc-setup.exe"}
check(sorted(latest["platforms"]) == sorted(want), f"the update manifest has an update for each signed platform and no other ({sorted(latest['platforms'])})")
check(all(latest["platforms"][k] == {"signature": (a / f"{n}.sig").read_text().strip(), "url": f"{PUBLIC}/releases/download/{TAG}/{n}"} for k, n in want.items()),
"each update carries its own signature and its public address")
check(latest["version"] == V and "darwin-x86_64" not in latest["platforms"] and "has no update signature" in r.stderr,
"an update with no signature is left out, and the log says so")
# No update key: nothing signed.
b = release([(n, False) for n, _ in every])
r, out = run(b)
cask = (out / "wai-quantum-desktop.rb").read_text()
check(r.returncode == 0 and not (out / "latest.json").exists() and "auto_updates" not in cask and "\n\n\n" not in cask
and "no update is signed" in r.stderr, "with no update key: no update manifest, and the cask does not say the app updates itself")
# A notarised build: no caveat.
r, out = run(a, {"MACOS_NOTARISED": "true"})
cask = (out / "wai-quantum-desktop.rb").read_text()
check("caveats" not in cask and cask.endswith(" ]\nend\n"), "a notarised build's cask has no caveat")
# Only Windows attached: the Scoop manifest alone, and the missing ones named.
c = release([(P + "x86_64-pc-windows-msvc.zip", False)])
r, out = run(c)
check(r.returncode == 0 and (out / "wai-quantum-desktop.json").exists() and not (out / "wai-quantum-desktop.rb").exists() and "aarch64-apple-darwin.dmg is not attached" in r.stderr,
"a platform that is not attached is left out, and the log names it")
# Nothing attached.
d = release([])
r, out = run(d)
check(r.returncode != 0 and "no installer is attached" in r.stderr, "with nothing attached, it fails")
for x in (a, b, c, d):
shutil.rmtree(x, ignore_errors=True)
print(f"{fails} check(s) failed" if fails else "every check passed")
sys.exit(1 if fails else 0)

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Attach files to the forge release for a tag, creating the release if there is
# none, and replacing an asset of the same name:
#
# FORGE_API=http://host:3400/api/v1 FORGE_REPO=owner/repo FORGE_TOKEN=... \
# bash release/publish.sh <tag> <title> <file>...
#
# Uploads go to FORGE_API directly (over the private network), never through the
# public tunnel, whose request size cap a large asset would hit. The command line's
# release/publish.sh, with a text of its own for a new release.
set -euo pipefail
if [ $# -lt 3 ]; then
echo "usage: bash release/publish.sh <tag> <title> <file>..." >&2
exit 2
fi
TAG="$1"; TITLE="$2"; shift 2
: "${FORGE_API:?}" "${FORGE_REPO:?}" "${FORGE_TOKEN:?}"
AUTH="Authorization: token $FORGE_TOKEN"
BASE="$FORGE_API/repos/$FORGE_REPO/releases"
release_id() { curl -fsS -H "$AUTH" "$BASE/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id", ""))' 2>/dev/null || true; }
ID="$(release_id)"
if [ -z "$ID" ]; then
BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "Quantum Studio for the desktop: macOS (a disk image), Linux (an AppImage, a Debian and an RPM package) and Windows (an installer and a portable archive). Each .sha256 file checks its asset (`shasum -a 256 -c`); each .sig file is the update signature the app checks before it installs an update."}))' "$TAG" "$TITLE")"
curl -fsS -H "$AUTH" -H 'content-type: application/json' -X POST "$BASE" -d "$BODY" > /dev/null 2>&1 || true
# Builds run side by side; one of them created it if this did not.
for _ in 1 2 3 4 5 6; do ID="$(release_id)"; [ -n "$ID" ] && break; sleep 3; done
fi
[ -n "$ID" ] || { echo "::error::no release for $TAG"; exit 1; }
for f in "$@"; do
name="$(basename "$f")"
for old in $(curl -fsS -H "$AUTH" "$BASE/$ID/assets" | NAME="$name" python3 -c 'import json,os,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin) if a["name"] == os.environ["NAME"]))'); do
curl -fsS -H "$AUTH" -X DELETE "$BASE/$ID/assets/$old" > /dev/null
done
curl -fsS -H "$AUTH" -X POST "$BASE/$ID/assets?name=$name" -F "attachment=@$f;type=application/octet-stream" -o /dev/null -w "uploaded $name: http %{http_code}\n"
done

View file

@ -0,0 +1,26 @@
# Update signing for a release build, sourced from wai/wai-quantum-desktop by the
# release workflow's macOS and Linux builds (the Windows build does the same in
# PowerShell). With the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY in SECRET_UPDATER_KEY
# (and its password, if it has one, in SECRET_UPDATER_KEY_PASSWORD), the bundler signs
# every installer an update installs; without it, nothing is signed for updates and
# the log says so. Sets UPDATER_CONFIG, the configuration the bundler merges with
# --config (`{}`, or the one that turns update signing on), and UPDATES_SIGNED, 1 or 0. The key goes to the bundler in its environment only, never
# into a file or the log.
pub="$(python3 -c 'import json; print(json.load(open("tauri.conf.json"))["plugins"]["updater"]["pubkey"])')"
if [ -n "${SECRET_UPDATER_KEY:-}" ]; then
if [ -z "$pub" ]; then
echo "::error::the update key is set, but tauri.conf.json has no plugins.updater.pubkey: put its public key there first, or the app could not check what it is offered"
exit 1
fi
export TAURI_SIGNING_PRIVATE_KEY="$SECRET_UPDATER_KEY" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${SECRET_UPDATER_KEY_PASSWORD:-}"
UPDATER_CONFIG='{"bundle":{"createUpdaterArtifacts":true}}'
UPDATES_SIGNED=1
echo "update signing: on, with the key the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY holds"
else
UPDATER_CONFIG='{}'
UPDATES_SIGNED=0
echo "update signing skipped: the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY is not set; this release has installers and no update, and installed copies are not offered it"
if [ -n "$pub" ]; then
echo "::warning::tauri.conf.json carries an update key, but this release has no secret to sign with: copies that update themselves will not see this version"
fi
fi

View file

@ -0,0 +1,15 @@
{
"version": "${version}",
"description": "Quantum Studio: OpenQASM programs run on this computer or the WAI quantum job service, every result signed",
"homepage": "https://wai.transaction.science/quantum-studio/",
"license": "Apache-2.0",
"architecture": {
"64bit": {
"url": "${url}",
"hash": "${hash}",
"extract_dir": "${extract_dir}"
}
},
"shortcuts": [["wai-quantum-desktop.exe", "Quantum Studio"]],
"notes": "Scoop updates this copy (scoop update wai-quantum-desktop); it does not update itself."
}

View file

@ -0,0 +1,16 @@
cask "wai-quantum-desktop" do
version "${version}"
${arch_blocks}
name "Quantum Studio"
desc "OpenQASM programs run on this computer or the WAI quantum job service, signed"
homepage "https://wai.transaction.science/quantum-studio/"
${auto_updates}
app "Quantum Studio.app"
zap trash: [
"~/Library/Application Support/science.transaction.quantum-studio",
"~/Library/Caches/science.transaction.quantum-studio",
"~/Library/WebKit/science.transaction.quantum-studio",
]
${caveats}end

View file

@ -24,11 +24,17 @@
//! run on one is estimated first, and one that is not free runs only after
//! the interface's dialog has asked; the credential stays in the
//! keychain, read here, and never reaches the page.
//! - Updates: a copy installed by its own installer asks the release's update
//! manifest for a newer version, and installs one only when the person
//! says so and its signature verifies with the public key this build
//! carries, for the version announced.
//!
//! ```text
//! wai-quantum-desktop the app
//! wai-quantum-desktop worker one job: {"request", "job_bytes"} on stdin, {"plan", "result"} out
//! wai-quantum-desktop check a Bell pair through the worker, sealed and checked; no window
//! wai-quantum-desktop the app
//! wai-quantum-desktop worker one job: {"request", "job_bytes"} on stdin, {"plan", "result"} out
//! wai-quantum-desktop check a Bell pair through the worker, sealed and checked; no window
//! wai-quantum-desktop verify-update FILE SIG VERSION FILE signed by SIG (as the update manifest carries it) for VERSION, checked with this build's key
//! wai-quantum-desktop update-status the installer this copy came from, and whether it updates itself, as JSON
//! ```
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
@ -37,6 +43,9 @@ use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use tauri::utils::config::BundleType;
use tauri_plugin_updater::UpdaterExt;
use wai_quantum::ed25519_dalek::SigningKey;
use wai_quantum::engine::discover::{self, Archive, Budget, Evaluator, SearchOptions, Spec, search};
use wai_quantum::engine::{answer, catalog, compile, converge, course, critique, draw, energy, equiv, estimate, job, locks, novelty, repair, rewrite, shortcuts, study, taste, verdict, versions};
@ -744,6 +753,142 @@ fn save_program(name: String, text: String) -> String {
}
}
/// The app's configuration as it was built: where the updater asks for a
/// newer version, and the public key an update must be signed with.
const CONFIG: &str = include_str!("../tauri.conf.json");
/// The public key updates must be signed with (base64 of the key file's
/// text), from the built-in configuration: empty until the release key is
/// made, and then this build updates nothing.
fn update_key() -> String {
serde_json::from_str::<Value>(CONFIG).ok().and_then(|c| c["plugins"]["updater"]["pubkey"].as_str().map(str::to_string)).unwrap_or_default()
}
/// The installer this copy came from, by the mark the installer's build left
/// in the binary (on macOS, by the app bundle it runs in): the macOS app
/// (outside its disk image), the AppImage, the Debian or RPM package, or the
/// Windows installer. `Err` says why this copy is not one an installer made:
/// it was built from source, or unpacked by a package manager from the
/// portable archive, and is updated the way it came.
fn installed_by(bundle: Option<BundleType>, exe: &Path) -> Result<&'static str, String> {
match bundle {
Some(BundleType::App) if !exe.parent().is_some_and(|d| d.ends_with("Contents/MacOS")) => Err("this copy was built from source, not installed as an app, so it does not update itself".into()),
Some(BundleType::App) if exe.starts_with("/Volumes") => Err("this copy runs from its disk image, which cannot be changed; move Quantum Studio to Applications and open it there".into()),
Some(BundleType::App) => Ok("app"),
Some(BundleType::AppImage) => Ok("appimage"),
Some(BundleType::Deb) => Ok("deb"),
Some(BundleType::Rpm) => Ok("rpm"),
Some(BundleType::Nsis) => Ok("nsis"),
Some(BundleType::Msi) => Ok("msi"),
_ => Err("this copy was not installed by its own installer (it was built from source, or unpacked by a package manager), so it is updated the way it came".into()),
}
}
/// Whether this copy updates itself: `Ok` names the installer it came from,
/// `Err` says why it does not. It does only when this build carries the
/// release's public key and the copy came from its own installer.
fn updates_itself(key: &str, bundle: Option<BundleType>, exe: &Path) -> Result<&'static str, String> {
if key.trim().is_empty() {
return Err("this build carries no update key, so it does not update itself; newer versions are on the release page".into());
}
installed_by(bundle, exe)
}
/// `update-status`: the installer this copy came from (or null) and whether
/// it updates itself, and why not, as JSON. A release asks it of each
/// installer's copy of the app.
fn update_status() -> Result<String, String> {
let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?;
let bundle = tauri::utils::platform::bundle_type();
let it = updates_itself(&update_key(), bundle.clone(), &exe);
Ok(json!({ "installed_by": installed_by(bundle, &exe).ok(), "updates_itself": it.is_ok(), "why_not": it.err() }).to_string())
}
/// Text that the update manifest carries as base64, decoded.
fn base64_text(b64: &str, what: &str) -> Result<String, String> {
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| format!("{what} is not base64: {e}"))?;
String::from_utf8(bytes).map_err(|_| format!("{what} is not text"))
}
/// `bytes` checked as the updater checks an update before installing it:
/// `signature` (base64 of the signature file, as the update manifest
/// carries it) verifies with `key` (base64 of the public key file), and the
/// version it was signed for, which the signature covers, is `version`. A
/// release runs this on every update it signs, with the key this build
/// carries, before anything is published.
fn verify_update(key: &str, bytes: &[u8], signature: &str, version: &str) -> Result<(), String> {
if key.trim().is_empty() {
return Err("this build carries no update key".into());
}
let key = minisign_verify::PublicKey::decode(&base64_text(key, "the update key")?).map_err(|e| format!("the update key does not read: {e}"))?;
let sig = minisign_verify::Signature::decode(&base64_text(signature, "the signature")?).map_err(|e| format!("the signature does not read: {e}"))?;
key.verify(bytes, &sig, true).map_err(|e| format!("the signature does not verify with this build's key: {e}"))?;
let signed = sig.trusted_comment().split('\t').find_map(|f| f.strip_prefix("version:")).ok_or("the signature does not name the version it was signed for")?;
if signed.trim_start_matches('v') != version.trim_start_matches('v') {
return Err(format!("the signature is for version {signed}, not {version}"));
}
Ok(())
}
/// The update the person was told of, kept for its install.
struct PendingUpdate(Mutex<Option<tauri_plugin_updater::Update>>);
/// Is there a newer version? `{"state": "off", "reason"}` when this copy
/// does not update itself, `{"state": "current", "version"}`, or
/// `{"state": "available", "version", "current", "notes"}`. It asks the
/// release's update manifest and sends nothing but that request.
#[tauri::command]
async fn update_check(app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>) -> Result<String, String> {
let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?;
if let Err(reason) = updates_itself(&update_key(), tauri::utils::platform::bundle_type(), &exe) {
return Ok(json!({ "state": "off", "reason": reason }).to_string());
}
let current = app.package_info().version.to_string();
let found = match app.updater() {
Ok(u) => u.check().await,
Err(e) => Err(e),
};
Ok(match found {
Ok(Some(u)) => {
let v = json!({ "state": "available", "version": u.version, "current": current, "notes": u.body });
*pending.0.lock().unwrap() = Some(u);
v
}
Ok(None) => json!({ "state": "current", "version": current }),
// A newer release with no update for this platform yet.
Err(tauri_plugin_updater::Error::TargetNotFound(_) | tauri_plugin_updater::Error::TargetsNotFound(_)) => json!({ "state": "current", "version": current, "note": "the newest release has no update for this platform" }),
Err(e) => json!({ "error": format!("the update check did not answer: {e}") }),
}
.to_string())
}
/// The update told of, downloaded, its signature checked against this
/// build's key and the version it was signed for against the one announced,
/// installed, and the app restarted into it. A failure leaves this version
/// as it was, and says why.
#[tauri::command]
async fn update_install(app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>) -> Result<String, String> {
let Some(u) = pending.0.lock().unwrap().take() else {
return Ok(error_json("no update is waiting: check for one first"));
};
match u.download_and_install(|_, _| {}, || {}).await {
Ok(()) => app.restart(),
Err(e) => Ok(error_json(format!("the update was not installed: {e}"))),
}
}
/// `verify-update FILE SIG VERSION`: FILE checked against SIG (a `.sig` file
/// as the release writes it) for VERSION, with this build's key.
fn verify_update_files(args: &[String]) -> Result<String, String> {
let [file, sig, version] = args else {
return Err("usage: wai-quantum-desktop verify-update FILE SIG VERSION".into());
};
let bytes = std::fs::read(file).map_err(|e| format!("{file}: {e}"))?;
let signature = std::fs::read_to_string(sig).map_err(|e| format!("{sig}: {e}"))?;
verify_update(&update_key(), &bytes, &signature, version)?;
Ok(format!("verify-update: {file} is signed with this build's update key, for version {version}"))
}
/// A Bell pair through the worker, sealed and checked, with no window: what
/// a build is held to where there is no screen.
fn check() -> Result<String, String> {
@ -772,6 +917,26 @@ fn main() {
}
return;
}
Some("update-status") => {
match update_status() {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("update-status failed: {e}");
std::process::exit(1);
}
}
return;
}
Some("verify-update") => {
match verify_update_files(&std::env::args().skip(2).collect::<Vec<_>>()) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("verify-update failed: {e}");
std::process::exit(1);
}
}
return;
}
_ => {}
}
let key = match wai_quantum_cli::keys::load(None, false) {
@ -782,8 +947,10 @@ fn main() {
}
};
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(Studio { key, job_bytes: job_bytes(), running: Arc::new(Mutex::new(None)), cancelled: Arc::new(Mutex::new(false)) })
.invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, compile, verdict, repair, course, course_lesson, course_check, course_hint, estimate, estimate_verify, qec, draw, layout, debug, visual_edit, picture, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, discover_novelty, instantiate, history, locks, novelty, rewrite, taste, converge, study, providers_list, provider_estimate, run_on_provider, targets, target, target_place, answer, telemetry])
.manage(PendingUpdate(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, compile, verdict, repair, course, course_lesson, course_check, course_hint, estimate, estimate_verify, qec, draw, layout, debug, visual_edit, picture, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, discover_novelty, instantiate, history, locks, novelty, rewrite, taste, converge, study, providers_list, provider_estimate, run_on_provider, targets, target, target_place, answer, telemetry, update_check, update_install])
.run(tauri::generate_context!())
.expect("the window opens");
}
@ -940,6 +1107,96 @@ mod tests {
assert!(bad["error"].as_str().unwrap().ends_with("[J009]"), "{bad}");
}
// Published test vectors (the updater's own): a throwaway public key and
// its signature of the payload `test`, made for version 2.0.0; and a
// second key with its signature of `test` that names no version.
const TEST_KEY: &str = "untrusted comment: minisign public key: A49EF4746FA6E6D7\nRWTX5qZvdPSepDGWg2bz8n6ArQnBMwgQK1geVosorcbLzcunD+KJ+cYe";
const TEST_SIG: &str = "untrusted comment: signature from tauri secret key\nRUTX5qZvdPSepPF2zsjvP/L/TQtBLRnWRvaq+4omK1UEugtQp/USrGiI1v762sDy3id3aor7bRC9ETyeot6lMHHrWoZ5hjUM1Qc=\ntrusted comment: timestamp:1791649051\tfile:payload\tversion:2.0.0\nDDq2CLOEl5cQmC2eJRHlnZPVViOZsu1CjpPKQoei4A/XBfkDqGO0B+QMrcrHgEQ5qV3mne75tdFWhJpqUXh9AA==";
const OTHER_KEY: &str = "untrusted comment: minisign public key E7620F1842B4E81F\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
const UNVERSIONED_SIG: &str = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1633700835\tfile:test\tprehashed\nwLMDjy9FLAuxZ3q4NlEvkgtyhrr0gtTu6KC4KBJdITbbOeAi1zBIYo0v4iTgt8jJpIidRJnp94ABQkJAgAooBQ==";
fn b64(s: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(s)
}
/// An update installs only when its signature verifies with this build's
/// key and names the version announced: every other case is refused,
/// and says why.
#[test]
fn an_update_is_held_to_this_build_s_key_and_the_version_it_was_signed_for() {
let (key, sig) = (b64(TEST_KEY), b64(TEST_SIG));
assert_eq!(verify_update(&key, b"test", &sig, "2.0.0"), Ok(()));
assert_eq!(verify_update(&key, b"test", &sig, "v2.0.0"), Ok(()));
let no = |r: Result<(), String>, why: &str| assert!(r.as_ref().is_err_and(|e| e.contains(why)), "{r:?} should say {why:?}");
no(verify_update(&key, b"tesT", &sig, "2.0.0"), "does not verify");
no(verify_update(&key, b"test", &sig, "2.0.1"), "is for version 2.0.0, not 2.0.1");
no(verify_update(&b64(OTHER_KEY), b"test", &sig, "2.0.0"), "does not verify");
no(verify_update(&b64(OTHER_KEY), b"test", &b64(UNVERSIONED_SIG), "2.0.0"), "does not name the version");
no(verify_update("", b"test", &sig, "2.0.0"), "carries no update key");
no(verify_update("not base64!", b"test", &sig, "2.0.0"), "not base64");
no(verify_update(&b64("not a key"), b"test", &sig, "2.0.0"), "does not read");
no(verify_update(&key, b"test", &b64("not a signature"), "2.0.0"), "does not read");
// The release's command, on files.
let dir = std::env::temp_dir().join(format!("wai-qdesk-update-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("payload"), "test").unwrap();
std::fs::write(dir.join("payload.sig"), &sig).unwrap();
let args = |v: &str| vec![dir.join("payload").display().to_string(), dir.join("payload.sig").display().to_string(), v.to_string()];
let built_in = update_key();
let r = verify_update_files(&args("2.0.0"));
if built_in.is_empty() {
assert!(r.is_err_and(|e| e.contains("carries no update key")));
} else {
assert!(r.is_err_and(|e| e.contains("does not verify")), "the release key is not the test key");
}
assert!(verify_update_files(&args("2.0.0")[..2]).is_err_and(|e| e.starts_with("usage:")));
let _ = std::fs::remove_dir_all(&dir);
}
/// Only a copy from its own installer, built with the release's key,
/// updates itself; any other copy says why not.
#[test]
fn only_a_copy_installed_by_its_own_installer_updates_itself() {
let app = Path::new("/Applications/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop");
let key = b64(TEST_KEY);
assert_eq!(updates_itself(&key, Some(BundleType::App), app), Ok("app"));
assert_eq!(updates_itself(&key, Some(BundleType::AppImage), Path::new("/home/a/Quantum Studio.AppImage")), Ok("appimage"));
assert_eq!(updates_itself(&key, Some(BundleType::Deb), Path::new("/usr/bin/wai-quantum-desktop")), Ok("deb"));
assert_eq!(updates_itself(&key, Some(BundleType::Rpm), Path::new("/usr/bin/wai-quantum-desktop")), Ok("rpm"));
assert_eq!(updates_itself(&key, Some(BundleType::Nsis), Path::new("C:/Users/a/AppData/Local/Quantum Studio/wai-quantum-desktop.exe")), Ok("nsis"));
let off = |r: Result<&str, String>, why: &str| assert!(r.as_ref().is_err_and(|e| e.contains(why)), "{r:?} should say {why:?}");
off(updates_itself("", Some(BundleType::App), app), "carries no update key");
off(updates_itself(&key, Some(BundleType::App), Path::new("/src/target/release/wai-quantum-desktop")), "built from source");
off(updates_itself(&key, Some(BundleType::App), Path::new("/Volumes/Quantum Studio/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop")), "disk image");
off(updates_itself(&key, None, Path::new("C:/Users/a/scoop/apps/wai-quantum-desktop/current/wai-quantum-desktop.exe")), "updated the way it came");
// How a copy was installed does not depend on the key.
assert_eq!(installed_by(Some(BundleType::AppImage), Path::new("/a")), Ok("appimage"));
assert!(installed_by(None, Path::new("/a")).is_err());
// This test's own binary was built by cargo: no installer made it.
let s: Value = serde_json::from_str(&update_status().unwrap()).unwrap();
assert_eq!((s["installed_by"].as_str(), s["updates_itself"].as_bool()), (None, Some(false)), "{s}");
assert!(s["why_not"].is_string(), "{s}");
}
/// The updater asks one address, the update manifest the website serves,
/// over https, and installs only an update signed for the version it
/// announces; the installers sign updates only when the release asks
/// (it holds the key), and the key built in is empty or a public key.
#[test]
fn the_updater_asks_the_release_s_manifest_and_requires_a_signed_version() {
let c: Value = serde_json::from_str(CONFIG).unwrap();
let u = &c["plugins"]["updater"];
assert_eq!(u["endpoints"], json!(["https://wai.transaction.science/quantum-studio-desktop/latest.json"]), "{u}");
assert_eq!(u["requireSignedVersion"], true, "{u}");
assert!(u.get("dangerousInsecureTransportProtocol").is_none() && u.get("allowDowngrades").is_none(), "{u}");
assert_eq!(c["bundle"]["createUpdaterArtifacts"], false);
assert_eq!(c["version"], env!("CARGO_PKG_VERSION"), "tauri.conf.json and Cargo.toml state one version");
let key = update_key();
if !key.is_empty() {
minisign_verify::PublicKey::decode(&base64_text(&key, "the update key").unwrap()).expect("the built-in update key is a public key");
}
}
#[test]
fn a_model_server_is_reached_by_http_only() {
assert_eq!(model_url("http://127.0.0.1:8080/v1/", "/models").unwrap(), "http://127.0.0.1:8080/v1/models");

View file

@ -25,9 +25,25 @@
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.png"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.png", "icons/icon.icns", "icons/icon.ico"],
"category": "DeveloperTool",
"shortDescription": "OpenQASM programs run on this computer or the quantum job service, every result named and signed",
"copyright": "Transaction Science"
"copyright": "Transaction Science",
"publisher": "Transaction Science",
"homepage": "https://wai.transaction.science/quantum-studio/",
"createUpdaterArtifacts": false,
"macOS": {
"signingIdentity": "-"
}
},
"plugins": {
"updater": {
"endpoints": ["https://wai.transaction.science/quantum-studio-desktop/latest.json"],
"pubkey": "",
"requireSignedVersion": true,
"windows": {
"installMode": "passive"
}
}
}
}

View file

@ -4,19 +4,19 @@
"placeholder": "__WAI_QUANTUM_VIEW__",
"limit_bytes": 1048576,
"bytes": {
"page": 802495,
"script": 93783,
"styles": 49020,
"engine_wasm": 193530,
"engine_wasm_base64": 258040,
"engine_js": 401202
"page": 803203,
"script": 94216,
"styles": 49303,
"engine_wasm": 193526,
"engine_wasm_base64": 258036,
"engine_js": 401198
},
"sha256": {
"page": "ca474d482e971c15602c4eb4baf56a3503b9145769426bbd602239280c67dd5e",
"script": "34e530315b898c0474e81db472f9f94f76499ec6a196947d8a4eeafae1c57162",
"styles": "118b40374f3adb9c67b6ee4d54a98c7dbeab9e49c259537393d61b2c26129a4b",
"engine_wasm": "247abbcffc65e03caa5311f5ed37d845d8f7ce2e038a59e3d3855a9928808c25",
"engine_js": "ce35bc6fe6e90afbc7c66288737bae10b1370baf0cf3d8662567de3a829bab31"
"page": "3bb78cf8bef7f6851314f97b59921c4902c06619006d86dfced4e941f3d5f608",
"script": "136d99215a1a8cc7a1302a107a222823272fe7371949ffa2816cbb4f9599776a",
"styles": "3374b613ce5f62a37f5c055a22c3e58e9d904422171f974c4f4538fd1c9d0eb3",
"engine_wasm": "68390ba3ee64bd37096708d73d807ef9c565ba0891f032c26222bba9c42b48db",
"engine_js": "434c0a2354df4d7b51e068186bab8ad9df8612b35ab97cc5b9290900608ee60c"
},
"sources": {
"wai/quantum-studio/build-views.mjs": "da5b161829a990b3d1bb4c1b3ec7c8f254ff7b5078476156e504ae322bc4f010",
@ -44,6 +44,7 @@
"wai/quantum-studio/locales/en/state_views.json": "a467fe4e7c8baffa9c08dee8c66e3b12a07e4b3a6598df0a43e84340808bd037",
"wai/quantum-studio/locales/en/study.json": "851328d1f199db6d2bfe2984d430b89ec5a67b890b8615e2ce14341d04b364ae",
"wai/quantum-studio/locales/en/taste.json": "f26d76d981eb32755c9b7b443b94368bf715c55b3234efd44850d20b0d979386",
"wai/quantum-studio/locales/en/update.json": "fb5bd53755a51a7eebb75a839c375af660433977965266fe022b5c79a0f503ac",
"wai/quantum-studio/locales/en/verdict_view.json": "24f0afd12cd8dab87d9060f3b7ca7a124f09ed450555bc9bf97db8fbfe9d3f38",
"wai/quantum-studio/locales/en/versions.json": "25e491e4a7b4989db08a89b771237fb50130183ee8debd30bbd10b523dedf49c",
"wai/quantum-studio/locales/en/visual_edit.json": "a192c00167a149c34070bc244e2a1b56f322bb9debb00a5db21ebaaed83900cc",
@ -55,7 +56,7 @@
"wai/quantum-studio/src/estimate-view.js": "40cb4bd738afb628dd686cdad44813d9d09910fbc60905098852dcca6cfe1b27",
"wai/quantum-studio/src/i18n.js": "f661474772b05ef6614f2f6096e85d94cd479c4fcd9448f34e8d312a2fd43498",
"wai/quantum-studio/src/result-parts.js": "c0c0bceae031a45e6e9862533706eda4cf119b6f65456237c2572ed534f9513e",
"wai/quantum-studio/src/studio.css": "5156ab565d7127f3d6cc9ea43c0cb1f020551340a1e779b6f77feae2079248f9",
"wai/quantum-studio/src/studio.css": "3c14ecf5e5f70e78588bc0e8a0933495197711966fc427578747e4115aa60037",
"wai/quantum-studio/src/tokens.css": "492fbaf8a6cc5b1f7e89346336af3e0a1930a0b48a23731d80d86c128a9032c3",
"wai/quantum-studio/src/verdict-view.js": "aec18c93c6f502657ffe2981ca4d9ae0956e1277638d1a9bb1b0846ac8e80160",
"wai/quantum-studio/src/views/check.js": "2edae7b446e72a49293478ebcbfe21792b74762dcfb097fb9e75ec3a4feb8d9a",

File diff suppressed because one or more lines are too long