From 76ad4f6994830d4ee1e752debf874a5f1440c596 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 9 Aug 2026 00:36:05 +0100 Subject: [PATCH 1/4] Add signed updater lifecycle --- .github/workflows/ci.yml | 12 ++ .github/workflows/release-tauri.yml | 143 +++++++++++++ .gitignore | 1 + README.md | 12 +- scripts/Build-WindowsUiAccess.ps1 | 7 +- scripts/create-update-feed.mjs | 38 ++++ scripts/render-updater-config.mjs | 30 +++ src-tauri/src/lib.rs | 320 ++++++++++++++++++++++++++-- src-tauri/src/state.rs | 3 + src-tauri/src/updater.rs | 292 +++++++++++++++++++++++++ src/App.test.tsx | 36 ++++ src/App.tsx | 62 +++++- src/api.test.ts | 14 ++ src/api.ts | 4 + src/styles.css | 4 + src/types.ts | 10 + 16 files changed, 957 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/release-tauri.yml create mode 100644 scripts/create-update-feed.mjs create mode 100644 scripts/render-updater-config.mjs create mode 100644 src-tauri/src/updater.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa720c6..8400c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,18 @@ jobs: cache: npm cache-dependency-path: package-lock.json - run: npm ci + - name: Validate release updater configuration + shell: bash + run: | + npx tauri signer generate --ci --password validation-only --write-keys "$RUNNER_TEMP/updater.key" + SWITCHIFY_UPDATER_PUBLIC_KEY="$(cat "$RUNNER_TEMP/updater.key.pub")" node scripts/render-updater-config.mjs "$RUNNER_TEMP/tauri.release.json" + node -e 'const c=require(process.argv[1]); if (!c.bundle.createUpdaterArtifacts || c.plugins.updater.endpoints.length !== 1 || !c.plugins.updater.pubkey) process.exit(1)' "$RUNNER_TEMP/tauri.release.json" + mkdir -p "$RUNNER_TEMP/artifacts/mac" "$RUNNER_TEMP/artifacts/windows" + touch "$RUNNER_TEMP/artifacts/mac/Switchify.PC.app.tar.gz" "$RUNNER_TEMP/artifacts/windows/Switchify.PC_1.0.0_x64-setup.exe" + printf 'mac-signature' > "$RUNNER_TEMP/artifacts/mac/Switchify.PC.app.tar.gz.sig" + printf 'windows-signature' > "$RUNNER_TEMP/artifacts/windows/Switchify.PC_1.0.0_x64-setup.exe.sig" + node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 tauri-v1.0.0-beta.1 "$RUNNER_TEMP/latest.json" + node -e 'const f=require(process.argv[1]); if (f.platforms["darwin-aarch64"].signature !== "mac-signature" || f.platforms["windows-x86_64"].signature !== "windows-signature") process.exit(1)' "$RUNNER_TEMP/latest.json" - run: npm run lint - run: npm test - run: npm run build diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml new file mode 100644 index 0000000..f386c73 --- /dev/null +++ b/.github/workflows/release-tauri.yml @@ -0,0 +1,143 @@ +name: Release Tauri beta + +on: + workflow_dispatch: + inputs: + version: + description: Version without a leading v + required: true + type: string + +permissions: + contents: write + +concurrency: + group: tauri-release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.value }} + tag: ${{ steps.version.outputs.tag }} + env: + UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - id: version + shell: bash + run: | + version='${{ inputs.version }}' + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'Invalid semantic version.' >&2; exit 1; } + configured=$(node -p "require('./package.json').version") + [[ "$version" == "$configured" ]] || { echo "package.json is $configured, not $version" >&2; exit 1; } + echo "value=$version" >> "$GITHUB_OUTPUT" + echo "tag=tauri-v$version" >> "$GITHUB_OUTPUT" + - name: Require release signing configuration + shell: bash + run: | + missing=0 + for name in UPDATER_PUBLIC_KEY APPLE_SIGNING_IDENTITY CERTUM_CERT_THUMBPRINT TAURI_PRIVATE_KEY TAURI_PRIVATE_KEY_PASSWORD APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + [[ -n "${!name}" ]] || { echo "Missing release configuration: $name" >&2; missing=1; } + done + exit "$missing" + + macos: + needs: prepare + runs-on: macos-14 + env: + SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: { node-version: 24, cache: npm } + - uses: dtolnay/rust-toolchain@stable + with: { toolchain: 1.97.1 } + - run: npm ci + - run: node scripts/render-updater-config.mjs src-tauri/tauri.release.generated.json + - run: npm run tauri build -- --bundles app,dmg --config src-tauri/tauri.release.generated.json + - uses: actions/upload-artifact@v7 + with: + name: macos-release + path: | + src-tauri/target/release/bundle/macos/*.app.tar.gz + src-tauri/target/release/bundle/macos/*.app.tar.gz.sig + src-tauri/target/release/bundle/dmg/*.dmg + + windows: + needs: prepare + runs-on: [self-hosted, Windows, X64, switchify-release] + env: + SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: { node-version: 24, cache: npm } + - uses: dtolnay/rust-toolchain@stable + with: { toolchain: 1.97.1 } + - run: npm ci + - run: node scripts/render-updater-config.mjs src-tauri/tauri.release.windows.generated.json --windows + - shell: powershell + run: ./scripts/Build-WindowsUiAccess.ps1 -TauriConfig src-tauri/tauri.release.windows.generated.json + - shell: powershell + run: ./scripts/Verify-WindowsUiAccessPackage.ps1 + - uses: actions/upload-artifact@v7 + with: + name: windows-release + path: | + src-tauri/target/release/bundle/nsis/*-setup.exe + src-tauri/target/release/bundle/nsis/*-setup.exe.sig + + publish: + needs: [prepare, macos, windows] + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: { node-version: 24 } + - uses: actions/download-artifact@v7 + with: { path: release-artifacts } + - run: node scripts/create-update-feed.mjs release-artifacts '${{ needs.prepare.outputs.version }}' '${{ needs.prepare.outputs.tag }}' latest.json + - name: Publish prerelease assets + shell: bash + run: | + tag='${{ needs.prepare.outputs.tag }}' + gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --prerelease --title "Switchify PC ${{ needs.prepare.outputs.version }}" + find release-artifacts -type f ! -name '*.sig' -print0 | xargs -0 gh release upload "$tag" --clobber + find release-artifacts -type f -name '*.sig' -print0 | xargs -0 gh release upload "$tag" --clobber + - name: Publish dedicated Tauri update feed + shell: bash + run: | + gh api repos/${{ github.repository }}/git/ref/heads/update-feed >/dev/null 2>&1 || \ + gh api --method POST repos/${{ github.repository }}/git/refs -f ref=refs/heads/update-feed -f sha="$GITHUB_SHA" + content=$(base64 -w0 latest.json) + sha=$(gh api repos/${{ github.repository }}/contents/latest.json?ref=update-feed --jq .sha 2>/dev/null || true) + args=(-f message="Update Tauri feed to ${{ needs.prepare.outputs.version }}" -f content="$content" -f branch=update-feed) + [[ -z "$sha" ]] || args+=(-f sha="$sha") + gh api --method PUT repos/${{ github.repository }}/contents/latest.json "${args[@]}" diff --git a/.gitignore b/.gitignore index f706db5..4e13006 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ Thumbs.db *.log src-tauri/icons/android/ src-tauri/icons/ios/ +src-tauri/tauri.release*.generated.json diff --git a/README.md b/README.md index 908b259..8ebef70 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,14 @@ Production macOS releases are Apple Silicon DMGs signed with an Apple-issued Dev The production certificate and App Store Connect API key are held only in the GitHub `production` environment and imported into an ephemeral runner keychain. They are separate from the machine-local `Switchify PC Development` identity used by `npm run macos:run`. See [macOS production releases](docs/macos-releases.md) for certificate creation, GitHub configuration, release, recovery, and rotation instructions. +## Signed updates + +Packaged release builds check a dedicated Tauri feed at `update-feed/latest.json` shortly after startup, every six hours, and on demand. Settings shows availability, verified download progress, cancellation, retry, installation, and restart state. Concurrent checks, downloads, and installs are deduplicated. Development and unsigned CI builds intentionally keep the updater unconfigured and report that state without crashing. + +The manual `Release Tauri beta` workflow renders release-only Tauri configuration, creates signed updater artifacts for Apple-silicon macOS and x64 Windows, publishes them under a `tauri-v` prerelease, and updates the dedicated feed. It does not alter the public C# `v0.10.0` release or its `latest.yml` feed. + +Before the workflow can run, configure `TAURI_UPDATER_PUBLIC_KEY`, `APPLE_SIGNING_IDENTITY`, and `CERTUM_CERT_THUMBPRINT` as repository variables; configure the Tauri updater private key/password and platform signing credentials as protected secrets. The Windows job targets the self-hosted signing runner with SimplySign available. Private signing material is never generated by or committed to this repository. + ## Diagnostics Switchify keeps up to 500 sanitized diagnostic events locally in `diagnostic-history.jsonl`. The history covers application startup, Bluetooth and Accessibility transitions, disconnects, runtime failures, and update checks. It never stores typed text, command payloads, pairing secrets, device names, or full paths; malformed or unwritable history is ignored so diagnostics cannot prevent startup. @@ -92,7 +100,7 @@ Existing public Git history, tags, releases, update metadata, and installer down ## Development boundaries -- Pull-request and `main` macOS CI remains unsigned. Only authorized release tags and manual recovery runs can access the production Developer ID and notarization credentials. Windows production packages continue to use the locally available Certum/SimplySign identity and are not published automatically. +- The macOS development identity is local-only. Production Developer ID signing, notarization, Certum/SimplySign signing, and release publication run only through the credential-gated release workflow; ordinary CI remains unsigned. - Linux may appear in capability data but is not a supported Bluetooth target. - Windows Grid 3 output uses the native `Sensory_SwitchInput` broadcast contract. Grid 3 is omitted from macOS capabilities and profiles. -- Update installation requires a signed Tauri update feed. Local development builds can only report updater configuration errors. +- Update installation requires the credential-gated signed Tauri feed. Local development builds can only report updater configuration errors. diff --git a/scripts/Build-WindowsUiAccess.ps1 b/scripts/Build-WindowsUiAccess.ps1 index e8a126f..5135eab 100644 --- a/scripts/Build-WindowsUiAccess.ps1 +++ b/scripts/Build-WindowsUiAccess.ps1 @@ -1,4 +1,7 @@ -param([switch]$SkipSign) +param( + [switch]$SkipSign, + [string]$TauriConfig = 'src-tauri/tauri.windows-uiaccess.conf.json' +) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot @@ -29,7 +32,7 @@ try { & (Join-Path $PSScriptRoot 'Sign-Windows.ps1') $sidecar } - & $npm run tauri build -- --bundles nsis --config src-tauri/tauri.windows-uiaccess.conf.json + & $npm run tauri build -- --bundles nsis --config $TauriConfig if ($LASTEXITCODE -ne 0) { throw "Tauri package build failed with exit code $LASTEXITCODE." } if (-not $SkipSign) { diff --git a/scripts/create-update-feed.mjs b/scripts/create-update-feed.mjs new file mode 100644 index 0000000..97bac67 --- /dev/null +++ b/scripts/create-update-feed.mjs @@ -0,0 +1,38 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +const [root, version, tag, output] = process.argv.slice(2); +if (!root || !version || !tag || !output) { + throw new Error("Usage: node scripts/create-update-feed.mjs "); +} +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error("Invalid semantic version."); + +const files = []; +const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) walk(path); + else files.push(path); + } +}; +walk(resolve(root)); + +const pick = (predicate, label) => { + const matches = files.filter((path) => predicate(basename(path)) && existsSync(`${path}.sig`)); + if (matches.length !== 1) throw new Error(`Expected one signed ${label} artifact, found ${matches.length}.`); + return matches[0]; +}; +const mac = pick((name) => name.endsWith(".app.tar.gz"), "macOS"); +const windows = pick((name) => name.endsWith("-setup.exe"), "Windows NSIS"); +const asset = (path) => `https://github.com/switchifyapp/switchify-pc/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(basename(path))}`; +const platform = (path) => ({ signature: readFileSync(`${path}.sig`, "utf8").trim(), url: asset(path) }); + +writeFileSync(resolve(output), `${JSON.stringify({ + version, + notes: `Switchify PC ${version}`, + pub_date: new Date().toISOString(), + platforms: { + "darwin-aarch64": platform(mac), + "windows-x86_64": platform(windows), + }, +}, null, 2)}\n`); diff --git a/scripts/render-updater-config.mjs b/scripts/render-updater-config.mjs new file mode 100644 index 0000000..8417004 --- /dev/null +++ b/scripts/render-updater-config.mjs @@ -0,0 +1,30 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const output = process.argv[2]; +const windows = process.argv.includes("--windows"); +const endpoint = process.env.SWITCHIFY_UPDATER_ENDPOINT + ?? "https://raw.githubusercontent.com/switchifyapp/switchify-pc/update-feed/latest.json"; +const pubkey = process.env.SWITCHIFY_UPDATER_PUBLIC_KEY?.trim(); + +if (!output) throw new Error("Usage: node scripts/render-updater-config.mjs [--windows]"); +if (!pubkey) throw new Error("SWITCHIFY_UPDATER_PUBLIC_KEY is required."); +if (!endpoint.startsWith("https://")) throw new Error("SWITCHIFY_UPDATER_ENDPOINT must use HTTPS."); + +const config = { + plugins: { updater: { endpoints: [endpoint], pubkey } }, + bundle: { createUpdaterArtifacts: true }, +}; + +if (windows) { + config.bundle.externalBin = ["binaries/switchify-pc-startup"]; + config.bundle.windows = { + signCommand: { + cmd: "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "../scripts/Sign-Windows.ps1", "%1"], + }, + nsis: { installerHooks: "windows/installer-hooks.nsh" }, + }; +} + +writeFileSync(resolve(output), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48df830..4a36d63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod protocol; mod state; mod storage; mod telemetry; +mod updater; #[cfg(target_os = "windows")] mod windows_runtime; #[cfg(target_os = "windows")] @@ -31,6 +32,7 @@ use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_autostart::ManagerExt as AutostartManagerExt; use tauri_plugin_updater::UpdaterExt; use telemetry::TelemetryConsent; +use updater::{Operation as UpdateOperation, RetryAction, UpdateManager, UpdateView}; #[tauri::command] fn get_app_state(model: State<'_, AppModel>) -> AppState { @@ -715,34 +717,278 @@ fn delete_switch_profile( Ok(list_switch_profiles(model)) } -#[tauri::command] -async fn check_for_updates(app: AppHandle, model: State<'_, AppModel>) -> Result { - if !updater_has_endpoints(app.config().plugins.0.get("updater")) { +fn publish_update(app: &AppHandle, model: &AppModel, update: UpdateView) -> AppState { + model + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .state + .updater = update; + state::emit_state(app, &model.shared); + model.snapshot() +} + +fn update_failure( + app: &AppHandle, + model: &AppModel, + version: Option, + retry: RetryAction, + context: &str, + error: &str, +) -> AppState { + let message = format!("{context}: {error}"); + state::set_activity(&model.shared, ActivityKind::Error, &message); + model.record_updater("failed", Some(error)); + publish_update(app, model, UpdateView::failed(version, message, retry)) +} + +async fn check_for_updates_inner(app: &AppHandle) -> AppState { + let model = app.state::(); + let manager = app.state::(); + if !updater_is_configured(app.config().plugins.0.get("updater")) { state::set_activity( &model.shared, ActivityKind::Info, "Updates are not configured for this build.", ); - model.record_updater("unavailable", Some("update endpoints are not configured")); + model.record_updater( + "unavailable", + Some("update endpoint or public key is missing"), + ); + return publish_update(app, &model, UpdateView::unconfigured()); + } + if manager.has_download() { + return model.snapshot(); + } + if !manager.begin(UpdateOperation::Check) { + return model.snapshot(); + } + publish_update(app, &model, UpdateView::checking()); + let result = match app.updater() { + Ok(updater) => updater.check().await.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + manager.finish(UpdateOperation::Check); + match result { + Ok(Some(update)) => { + let version = update.version.clone(); + manager.replace_available(Some(update)); + state::set_activity( + &model.shared, + ActivityKind::Info, + format!("Switchify PC {version} is available."), + ); + model.record_updater("available", None); + publish_update(app, &model, UpdateView::available(version)) + } + Ok(None) => { + manager.replace_available(None); + state::set_activity( + &model.shared, + ActivityKind::Success, + "Switchify PC is up to date.", + ); + model.record_updater("current", None); + publish_update(app, &model, UpdateView::current()) + } + Err(error) => update_failure( + app, + &model, + None, + RetryAction::Check, + "Update check failed", + &error, + ), + } +} + +#[tauri::command] +async fn check_for_updates(app: AppHandle) -> Result { + Ok(check_for_updates_inner(&app).await) +} + +#[tauri::command] +async fn download_update(app: AppHandle) -> Result { + let model = app.state::(); + let manager = app.state::(); + if !manager.begin(UpdateOperation::Download) { + return Ok(model.snapshot()); + } + let Some(update) = manager.available() else { + manager.finish(UpdateOperation::Download); + return Ok(update_failure( + &app, + &model, + None, + RetryAction::Check, + "Update download could not start", + "check for an update first", + )); + }; + let version = update.version.clone(); + let (cancel_sender, mut cancel_receiver) = tokio::sync::watch::channel(false); + manager.set_download_cancel(cancel_sender); + state::set_activity( + &model.shared, + ActivityKind::Info, + format!("Downloading Switchify PC {version}…"), + ); + publish_update(&app, &model, UpdateView::downloading(version.clone())); + + let progress_app = app.clone(); + let progress_shared = model.shared.clone(); + let download = update.download( + move |chunk, total| { + { + let mut data = progress_shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.state.updater.add_progress(chunk, total); + } + state::emit_state(&progress_app, &progress_shared); + }, + || {}, + ); + tokio::pin!(download); + enum DownloadResult { + Complete(Result, String>), + Cancelled, + } + let result = tokio::select! { + result = &mut download => DownloadResult::Complete(result.map_err(|error| error.to_string())), + changed = cancel_receiver.changed() => { + let _ = changed; + DownloadResult::Cancelled + } + }; + manager.finish(UpdateOperation::Download); + match result { + DownloadResult::Complete(Ok(bytes)) => { + let downloaded_bytes = bytes.len() as u64; + let total_bytes = model.snapshot().updater.total_bytes; + manager.store_download(bytes); + state::set_activity( + &model.shared, + ActivityKind::Success, + format!("Switchify PC {version} is ready to install."), + ); + model.record_updater("ready", None); + Ok(publish_update( + &app, + &model, + UpdateView::ready(version, downloaded_bytes, total_bytes), + )) + } + DownloadResult::Complete(Err(error)) => Ok(update_failure( + &app, + &model, + Some(version), + RetryAction::Download, + "Update download failed", + &error, + )), + DownloadResult::Cancelled => { + state::set_activity( + &model.shared, + ActivityKind::Info, + "Update download cancelled.", + ); + model.record_updater("cancelled", None); + Ok(publish_update(&app, &model, UpdateView::cancelled(version))) + } + } +} + +#[tauri::command] +fn cancel_update_download( + app: AppHandle, + model: State<'_, AppModel>, + manager: State<'_, UpdateManager>, +) -> AppState { + if manager.cancel_download() { + state::set_activity( + &model.shared, + ActivityKind::Info, + "Cancelling update download…", + ); + state::emit_state(&app, &model.shared); + } + model.snapshot() +} + +#[tauri::command] +async fn install_update(app: AppHandle) -> Result { + let model = app.state::(); + let manager = app.state::(); + if !manager.begin(UpdateOperation::Install) { return Ok(model.snapshot()); } - let updater = match app.updater() { - Ok(updater) => updater, - Err(error) => return Err(record_update_failure(&model, &error.to_string())), + let Some(update) = manager.available() else { + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + None, + RetryAction::Check, + "Update installation could not start", + "check for an update first", + )); }; - let update = match updater.check().await { - Ok(update) => update, - Err(error) => return Err(record_update_failure(&model, &error.to_string())), + let Some(bytes) = manager.take_download() else { + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + Some(update.version), + RetryAction::Download, + "Update installation could not start", + "download the update first", + )); }; - let message = update.map_or_else( - || "Switchify PC is up to date.".to_string(), - |update| format!("Switchify PC {} is available.", update.version), + let version = update.version.clone(); + let downloaded_bytes = bytes.len() as u64; + let total_bytes = model.snapshot().updater.total_bytes; + let overlay = app.state::(); + let modifier_overlay = app.state::(); + if let Err(error) = disconnect_all_inner(&app, &model, &overlay, &modifier_overlay) { + manager.store_download(bytes); + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + Some(version), + RetryAction::Install, + "Update installation failed", + &error, + )); + } + state::set_activity( + &model.shared, + ActivityKind::Info, + format!("Installing Switchify PC {version}…"), ); - state::set_activity(&model.shared, ActivityKind::Info, message); - model.record_updater("checked", None); - Ok(model.snapshot()) + publish_update( + &app, + &model, + UpdateView::applying(version.clone(), downloaded_bytes, total_bytes), + ); + if let Err(error) = update.install(&bytes) { + manager.store_download(bytes); + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + Some(version), + RetryAction::Install, + "Update installation failed", + &error.to_string(), + )); + } + model.record_updater("installed", None); + app.restart(); } +#[cfg(test)] fn record_update_failure(model: &AppModel, error: &str) -> String { state::set_activity( &model.shared, @@ -753,11 +999,26 @@ fn record_update_failure(model: &AppModel, error: &str) -> String { error.to_owned() } -fn updater_has_endpoints(config: Option<&serde_json::Value>) -> bool { - config +fn updater_is_configured(config: Option<&serde_json::Value>) -> bool { + let has_endpoints = config .and_then(|config| config.get("endpoints")) .and_then(serde_json::Value::as_array) - .is_some_and(|endpoints| !endpoints.is_empty()) + .is_some_and(|endpoints| !endpoints.is_empty()); + let has_public_key = config + .and_then(|config| config.get("pubkey")) + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.trim().is_empty()); + has_endpoints && has_public_key +} + +fn start_update_scheduler(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + loop { + let _ = check_for_updates_inner(&app).await; + tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await; + } + }); } #[tauri::command] @@ -900,10 +1161,16 @@ pub fn run() { ) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(model) + .manage(UpdateManager::default()) .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { install_tray(app)?; + if updater_is_configured(app.config().plugins.0.get("updater")) { + let model = app.state::(); + publish_update(app.handle(), &model, UpdateView::idle()); + start_update_scheduler(app.handle().clone()); + } { let model = app.state::(); let state = model.snapshot(); @@ -998,6 +1265,9 @@ pub fn run() { cancel_profile_exit, take_navigation_request, check_for_updates, + download_update, + cancel_update_download, + install_update, export_diagnostics ]) .run(tauri::generate_context!()) @@ -1077,7 +1347,7 @@ fn platform_disconnect_all(app: &AppHandle, shared: &state::SharedModel) -> Resu #[cfg(test)] mod tests { use super::{ - has_start_hidden_argument, record_update_failure, updater_has_endpoints, validate_profile, + has_start_hidden_argument, record_update_failure, updater_is_configured, validate_profile, PendingNavigation, PendingProfileExit, ProfileExitAction, TraySnapshot, NAVIGATE_REQUESTED_EVENT, }; @@ -1106,12 +1376,16 @@ mod tests { #[test] fn update_checks_require_a_configured_endpoint() { - assert!(!updater_has_endpoints(None)); - assert!(!updater_has_endpoints(Some(&json!({ + assert!(!updater_is_configured(None)); + assert!(!updater_is_configured(Some(&json!({ "endpoints": [], "pubkey": "" })))); - assert!(updater_has_endpoints(Some(&json!({ + assert!(!updater_is_configured(Some(&json!({ + "endpoints": ["https://updates.example.com/latest.json"], + "pubkey": "" + })))); + assert!(updater_is_configured(Some(&json!({ "endpoints": ["https://updates.example.com/latest.json"], "pubkey": "test-key" })))); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 793472f..33a101d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -9,6 +9,7 @@ use crate::diagnostics::{DiagnosticHistory, DiagnosticSummary}; use crate::protocol::{PendingPairingSummary, ProtocolEngine}; use crate::storage::{AppStorage, PersistedState}; use crate::telemetry::{TelemetryConsent, TelemetryService, TelemetryView}; +use crate::updater::UpdateView; pub const APP_STATE_EVENT: &str = "app-state-changed"; @@ -263,6 +264,7 @@ pub struct AppState { pub diagnostics: DiagnosticSummary, pub telemetry: TelemetryView, pub setup: SetupState, + pub updater: UpdateView, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -370,6 +372,7 @@ impl AppModel { completed: saved.setup_completed, auto_open_eligible: setup_auto_open_eligible, }, + updater: UpdateView::default(), }, })); let model = Self { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..0247052 --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,292 @@ +use std::sync::Mutex; + +use serde::Serialize; +use tauri_plugin_updater::Update; +use tokio::sync::watch; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpdateStatus { + Unconfigured, + Idle, + Checking, + Available, + Downloading, + ReadyToInstall, + Applying, + Current, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RetryAction { + Check, + Download, + Install, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateView { + pub status: UpdateStatus, + pub version: Option, + pub downloaded_bytes: u64, + pub total_bytes: Option, + pub error: Option, + pub retry_action: Option, +} + +impl Default for UpdateView { + fn default() -> Self { + Self::unconfigured() + } +} + +impl UpdateView { + pub fn unconfigured() -> Self { + Self::new(UpdateStatus::Unconfigured) + } + + pub fn idle() -> Self { + Self::new(UpdateStatus::Idle) + } + + pub fn checking() -> Self { + Self::new(UpdateStatus::Checking) + } + + pub fn current() -> Self { + Self::new(UpdateStatus::Current) + } + + pub fn available(version: String) -> Self { + Self { + status: UpdateStatus::Available, + version: Some(version), + ..Self::new(UpdateStatus::Available) + } + } + + pub fn downloading(version: String) -> Self { + Self { + status: UpdateStatus::Downloading, + version: Some(version), + ..Self::new(UpdateStatus::Downloading) + } + } + + pub fn ready(version: String, downloaded_bytes: u64, total_bytes: Option) -> Self { + Self { + status: UpdateStatus::ReadyToInstall, + version: Some(version), + downloaded_bytes, + total_bytes, + error: None, + retry_action: None, + } + } + + pub fn applying(version: String, downloaded_bytes: u64, total_bytes: Option) -> Self { + Self { + status: UpdateStatus::Applying, + version: Some(version), + downloaded_bytes, + total_bytes, + error: None, + retry_action: None, + } + } + + pub fn failed(version: Option, error: String, retry_action: RetryAction) -> Self { + Self { + status: UpdateStatus::Failed, + version, + downloaded_bytes: 0, + total_bytes: None, + error: Some(error), + retry_action: Some(retry_action), + } + } + + pub fn cancelled(version: String) -> Self { + Self { + status: UpdateStatus::Cancelled, + version: Some(version), + downloaded_bytes: 0, + total_bytes: None, + error: None, + retry_action: Some(RetryAction::Download), + } + } + + pub fn add_progress(&mut self, chunk: usize, total: Option) { + self.downloaded_bytes = self.downloaded_bytes.saturating_add(chunk as u64); + if total.is_some() { + self.total_bytes = total; + } + } + + fn new(status: UpdateStatus) -> Self { + Self { + status, + version: None, + downloaded_bytes: 0, + total_bytes: None, + error: None, + retry_action: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operation { + Check, + Download, + Install, +} + +#[derive(Default)] +struct RuntimeData { + active: Option, + available: Option, + downloaded: Option>, + cancel_download: Option>, +} + +#[derive(Default)] +pub struct UpdateManager(Mutex); + +impl UpdateManager { + pub fn begin(&self, operation: Operation) -> bool { + let mut data = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if data.active.is_some() { + return false; + } + data.active = Some(operation); + true + } + + pub fn finish(&self, operation: Operation) { + let mut data = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if data.active == Some(operation) { + data.active = None; + } + if operation == Operation::Download { + data.cancel_download = None; + } + } + + pub fn replace_available(&self, update: Option) { + let mut data = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.available = update; + data.downloaded = None; + } + + pub fn available(&self) -> Option { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .available + .clone() + } + + pub fn store_download(&self, bytes: Vec) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .downloaded = Some(bytes); + } + + pub fn take_download(&self) -> Option> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .downloaded + .take() + } + + pub fn has_download(&self) -> bool { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .downloaded + .is_some() + } + + pub fn set_download_cancel(&self, sender: watch::Sender) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .cancel_download = Some(sender); + } + + pub fn cancel_download(&self) -> bool { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .cancel_download + .as_ref() + .is_some_and(|sender| sender.send(true).is_ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transitions_expose_progress_retry_and_cancellation() { + let mut downloading = UpdateView::downloading("2.0.0".into()); + downloading.add_progress(25, Some(100)); + downloading.add_progress(30, None); + assert_eq!(downloading.downloaded_bytes, 55); + assert_eq!(downloading.total_bytes, Some(100)); + assert_eq!( + UpdateView::ready("2.0.0".into(), 100, Some(100)).status, + UpdateStatus::ReadyToInstall + ); + assert_eq!( + UpdateView::cancelled("2.0.0".into()).retry_action, + Some(RetryAction::Download) + ); + assert_eq!( + UpdateView::failed(None, "offline".into(), RetryAction::Check).retry_action, + Some(RetryAction::Check) + ); + } + + #[test] + fn operation_gate_deduplicates_concurrent_work() { + let manager = UpdateManager::default(); + assert!(manager.begin(Operation::Check)); + assert!(!manager.begin(Operation::Check)); + assert!(!manager.begin(Operation::Install)); + manager.finish(Operation::Check); + assert!(manager.begin(Operation::Install)); + manager.store_download(vec![1, 2, 3]); + assert!(manager.has_download()); + } + + #[tokio::test] + async fn cancellation_signal_is_one_shot_and_non_blocking() { + let manager = UpdateManager::default(); + let (sender, mut receiver) = watch::channel(false); + manager.set_download_cancel(sender); + assert!(manager.cancel_download()); + receiver.changed().await.unwrap(); + assert!(*receiver.borrow()); + manager.finish(Operation::Download); + assert!(!manager.cancel_download()); + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index d375de7..a107902 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -18,6 +18,7 @@ describe("Switchify PC shell", () => { browserState.connectedDeviceName = null; browserState.diagnostics = { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }; browserState.telemetry = { consent: "undecided", available: true }; + browserState.updater = { status: "unconfigured", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; browserState.setup = { shown: true, completed: false, autoOpenEligible: false }; }); @@ -57,6 +58,41 @@ describe("Switchify PC shell", () => { checkForUpdates.mockRestore(); }); + it("shows update progress and exposes cancellation in Settings", async () => { + browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; + const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + + expect(screen.getByRole("status")).toHaveTextContent("Downloading Switchify PC 1.0.0-beta.2"); + expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50"); + expect(document.querySelector(".update-controls > span")).toHaveTextContent("25%"); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("offers the correct retry action after a failure", async () => { + browserState.updater = { status: "failed", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: "Download failed", retryAction: "download" }; + const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("alert")).toHaveTextContent("Download failed"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(download).toHaveBeenCalledOnce(); + }); + + it("offers installation and restart when a download is ready", async () => { + browserState.updater = { status: "readyToInstall", version: "1.0.0-beta.2", downloadedBytes: 200, totalBytes: 200, error: null, retryAction: null }; + const install = vi.spyOn(api, "installUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); + expect(install).toHaveBeenCalledOnce(); + }); + it("routes tray navigation without discarding a dirty profile silently", async () => { let navigate: ((target: "home" | "settings" | "profiles") => void) | undefined; vi.spyOn(api, "onNavigateRequested").mockImplementation(async (handler) => { diff --git a/src/App.tsx b/src/App.tsx index 532df93..ed86145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { ShieldCheck, SlidersHorizontal, Smartphone, Trash2, WifiOff, Wrench, X, } from "lucide-react"; import { api, type ProfileExitAction } from "./api"; -import type { AppSettings, AppState, PendingPairing, SwitchProfile } from "./types"; +import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -302,7 +302,45 @@ function applyLocalSettings(base: AppSettings, local: AppSettings, keys: Set void; chooseTelemetry: (enabled: boolean) => void; checkUpdates: () => void; busy: boolean }) { +type UpdateAction = "check" | "download" | "install"; + +function updateDescription(update: UpdateState) { + switch (update.status) { + case "unconfigured": return "Updates are unavailable in this build because its signed feed is not configured."; + case "idle": return "Automatic update checks are enabled."; + case "checking": return "Checking for updates…"; + case "available": return `Switchify PC ${update.version} is available.`; + case "downloading": return `Downloading Switchify PC ${update.version}…`; + case "readyToInstall": return `Switchify PC ${update.version} is ready to install.`; + case "applying": return `Installing Switchify PC ${update.version}…`; + case "current": return "Switchify PC is up to date."; + case "failed": return update.error ?? "The update operation failed."; + case "cancelled": return "Download cancelled. You can resume when ready."; + } +} + +function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (action: UpdateAction) => void; cancel: () => void }) { + const percent = update.totalBytes && update.totalBytes > 0 + ? Math.min(100, Math.round(update.downloadedBytes * 100 / update.totalBytes)) + : null; + const action = update.status === "available" || update.status === "cancelled" ? "download" + : update.status === "readyToInstall" ? "install" + : update.status === "failed" ? update.retryAction + : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null; + const label = update.status === "failed" ? "Retry" + : action === "download" ? (update.status === "cancelled" ? "Resume download" : "Download") + : action === "install" ? "Install and restart" : "Check for updates"; + return
+

{updateDescription(update)}

+ {update.status === "downloading" && <> + + {percent === null ? `${update.downloadedBytes.toLocaleString()} bytes` : `${percent}%`} + } +
{action && }{update.status === "downloading" && }{(update.status === "checking" || update.status === "applying") && }
+
; +} + +function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean }) { const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value }); return

Settings

Startup, pointer, privacy, and updates

update("startWithSystem", value)} /> @@ -345,7 +383,7 @@ function SettingsView({ state, settings, onChange, chooseTelemetry, checkUpdates } update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

- +
; } @@ -594,6 +632,22 @@ export function App() { finally { setCheckingUpdates(false); } }; + const runUpdate = async (action: UpdateAction) => { + setError(null); + if (action === "check") setCheckingUpdates(true); + try { + const operation = action === "check" ? api.checkForUpdates : action === "download" ? api.downloadUpdate : api.installUpdate; + syncState(await operation()); + } catch (reason) { setError(String(reason)); } + finally { if (action === "check") setCheckingUpdates(false); } + }; + + const cancelUpdate = async () => { + setError(null); + try { syncState(await api.cancelUpdateDownload()); } + catch (reason) { setError(String(reason)); } + }; + const openSetup = () => { setSetupOpen(true); void perform(api.markSetupShown); @@ -709,7 +763,7 @@ export function App() { {view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} onSetup={openSetup} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />} - {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} checkUpdates={() => void checkForUpdates()} busy={busy} />} + {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} />} {view === "support" && void perform(operation)} openSetup={openSetup} />} {setupOpen && perform(() => api.checkAccessibility(true))} reject={(requestId) => perform(() => api.rejectPairing(requestId))} approve={(requestId) => perform(() => api.approvePairing(requestId))} />} diff --git a/src/api.test.ts b/src/api.test.ts index da853f2..49b29dd 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -50,6 +50,20 @@ describe("runtime state events", () => { expect(invoke).toHaveBeenCalledWith("take_navigation_request"); }); + it("invokes each updater lifecycle command", async () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {} }); + + await api.checkForUpdates(); + await api.downloadUpdate(); + await api.cancelUpdateDownload(); + await api.installUpdate(); + + expect(invoke).toHaveBeenCalledWith("check_for_updates", undefined); + expect(invoke).toHaveBeenCalledWith("download_update", undefined); + expect(invoke).toHaveBeenCalledWith("cancel_update_download", undefined); + expect(invoke).toHaveBeenCalledWith("install_update", undefined); + }); + it("delivers tray navigation queued before the listener was ready", async () => { Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, diff --git a/src/api.ts b/src/api.ts index e60d64b..9c30ab3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -29,6 +29,7 @@ export const browserState: AppState = { diagnostics: { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }, telemetry: { consent: "undecided", available: true }, setup: { shown: false, completed: false, autoOpenEligible: true }, + updater: { status: "unconfigured", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }, }; const emptyBindings = () => Array.from({ length: 8 }, (_, index) => ({ @@ -102,6 +103,9 @@ export const api = { ? invoke("cancel_profile_exit") : Promise.resolve(), checkForUpdates: () => call("check_for_updates"), + downloadUpdate: () => call("download_update"), + cancelUpdateDownload: () => call("cancel_update_download"), + installUpdate: () => call("install_update"), exportDiagnostics: () => call("export_diagnostics"), onState: async (handler: (state: AppState) => void): Promise => { if (!("__TAURI_INTERNALS__" in window)) return () => undefined; diff --git a/src/styles.css b/src/styles.css index 3b96922..40d7141 100644 --- a/src/styles.css +++ b/src/styles.css @@ -105,6 +105,10 @@ button:disabled { cursor: default; opacity: 0.55; } .setting-group h2 { font-size: 15px; } .setting-group header p { margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.5; } .setting-controls { display: grid; align-content: start; gap: 17px; } +.update-controls { display: grid; gap: 10px; color: var(--muted); font-size: 12px; } +.update-controls > div { display: flex; flex-wrap: wrap; gap: 8px; } +.update-controls progress { width: 100%; accent-color: var(--brand); } +.update-controls > span { font-variant-numeric: tabular-nums; } .toggle-row, .range-row { display: grid; align-items: center; gap: 12px; font-size: 13px; } .toggle-row { grid-template-columns: 1fr auto; cursor: pointer; } .toggle-row input { position: absolute; width: 1px; height: 1px; opacity: 0; } diff --git a/src/types.ts b/src/types.ts index be27585..b6abfdc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,15 @@ export type DiagnosticSummary = { recentErrors: DiagnosticEvent[]; }; export type TelemetryState = { consent: "undecided" | "enabled" | "disabled"; available: boolean }; +export type UpdateStatus = "unconfigured" | "idle" | "checking" | "available" | "downloading" | "readyToInstall" | "applying" | "current" | "failed" | "cancelled"; +export type UpdateState = { + status: UpdateStatus; + version: string | null; + downloadedBytes: number; + totalBytes: number | null; + error: string | null; + retryAction: "check" | "download" | "install" | null; +}; export type AppSettings = { startWithSystem: boolean; @@ -47,6 +56,7 @@ export type AppState = { diagnostics: DiagnosticSummary; telemetry: TelemetryState; setup: { shown: boolean; completed: boolean; autoOpenEligible: boolean }; + updater: UpdateState; }; export type SwitchBinding = { From 03655eedf41cdf5c8c530f959678e7f4c8450cf5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 9 Aug 2026 00:44:33 +0100 Subject: [PATCH 2/4] Harden release workflow inputs --- .github/workflows/release-tauri.yml | 57 ++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index f386c73..1753c62 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -25,13 +25,6 @@ jobs: UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} - TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -39,8 +32,10 @@ jobs: node-version: 24 - id: version shell: bash + env: + VERSION_INPUT: ${{ inputs.version }} run: | - version='${{ inputs.version }}' + version="$VERSION_INPUT" [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'Invalid semantic version.' >&2; exit 1; } configured=$(node -p "require('./package.json').version") [[ "$version" == "$configured" ]] || { echo "package.json is $configured, not $version" >&2; exit 1; } @@ -50,7 +45,7 @@ jobs: shell: bash run: | missing=0 - for name in UPDATER_PUBLIC_KEY APPLE_SIGNING_IDENTITY CERTUM_CERT_THUMBPRINT TAURI_PRIVATE_KEY TAURI_PRIVATE_KEY_PASSWORD APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + for name in UPDATER_PUBLIC_KEY APPLE_SIGNING_IDENTITY CERTUM_CERT_THUMBPRINT; do [[ -n "${!name}" ]] || { echo "Missing release configuration: $name" >&2; missing=1; } done exit "$missing" @@ -60,16 +55,25 @@ jobs: runs-on: macos-14 env: SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - uses: actions/checkout@v6 + - name: Require macOS release secrets + shell: bash + env: + UPDATER_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + UPDATER_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + missing=0 + for name in UPDATER_PRIVATE_KEY UPDATER_PRIVATE_KEY_PASSWORD APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + [[ -n "${!name}" ]] || { echo "Missing macOS release secret: $name" >&2; missing=1; } + done + exit "$missing" - uses: actions/setup-node@v6 with: { node-version: 24, cache: npm } - uses: dtolnay/rust-toolchain@stable @@ -77,6 +81,14 @@ jobs: - run: npm ci - run: node scripts/render-updater-config.mjs src-tauri/tauri.release.generated.json - run: npm run tauri build -- --bundles app,dmg --config src-tauri/tauri.release.generated.json + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - uses: actions/upload-artifact@v7 with: name: macos-release @@ -91,10 +103,16 @@ jobs: env: SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} steps: - uses: actions/checkout@v6 + - name: Require Windows release secrets + shell: powershell + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + if (-not $env:TAURI_SIGNING_PRIVATE_KEY) { throw 'Missing Windows release secret: TAURI_SIGNING_PRIVATE_KEY' } + if (-not $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) { throw 'Missing Windows release secret: TAURI_SIGNING_PRIVATE_KEY_PASSWORD' } - uses: actions/setup-node@v6 with: { node-version: 24, cache: npm } - uses: dtolnay/rust-toolchain@stable @@ -103,6 +121,9 @@ jobs: - run: node scripts/render-updater-config.mjs src-tauri/tauri.release.windows.generated.json --windows - shell: powershell run: ./scripts/Build-WindowsUiAccess.ps1 -TauriConfig src-tauri/tauri.release.windows.generated.json + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - shell: powershell run: ./scripts/Verify-WindowsUiAccessPackage.ps1 - uses: actions/upload-artifact@v7 From 22cb5aa851d2fdedb81275b27c12a49b0a10709e Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:07:22 +0100 Subject: [PATCH 3/4] Complete signed updater release integration --- .github/actionlint.yaml | 3 + .github/workflows/ci.yml | 16 +- .github/workflows/release-macos.yml | 230 --------------- .github/workflows/release-tauri.yml | 431 ++++++++++++++++++++++------ README.md | 6 +- docs/macos-releases.md | 10 +- scripts/create-update-feed.mjs | 11 +- scripts/render-updater-config.mjs | 10 +- src-tauri/src/lib.rs | 53 ++-- src-tauri/src/updater.rs | 214 +++++++++++++- src/App.test.tsx | 11 + src/App.tsx | 4 +- 12 files changed, 613 insertions(+), 386 deletions(-) create mode 100644 .github/actionlint.yaml delete mode 100644 .github/workflows/release-macos.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..df42e34 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - switchify-signing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8400c04..04ce882 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,14 +13,14 @@ jobs: frontend: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Reject retired product identity run: | retired_identity="pre""view" if git grep -n -i "$retired_identity" -- ':!src-tauri/Cargo.lock'; then exit 1 fi - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 cache: npm @@ -36,8 +36,12 @@ jobs: touch "$RUNNER_TEMP/artifacts/mac/Switchify.PC.app.tar.gz" "$RUNNER_TEMP/artifacts/windows/Switchify.PC_1.0.0_x64-setup.exe" printf 'mac-signature' > "$RUNNER_TEMP/artifacts/mac/Switchify.PC.app.tar.gz.sig" printf 'windows-signature' > "$RUNNER_TEMP/artifacts/windows/Switchify.PC_1.0.0_x64-setup.exe.sig" - node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 tauri-v1.0.0-beta.1 "$RUNNER_TEMP/latest.json" + node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/latest.json" node -e 'const f=require(process.argv[1]); if (f.platforms["darwin-aarch64"].signature !== "mac-signature" || f.platforms["windows-x86_64"].signature !== "windows-signature") process.exit(1)' "$RUNNER_TEMP/latest.json" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.2 "$RUNNER_TEMP/invalid.json"; then + echo 'Mismatched update tag was accepted.' >&2 + exit 1 + fi - run: npm run lint - run: npm test - run: npm run build @@ -53,13 +57,13 @@ jobs: bundles: app,dmg runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 cache: npm cache-dependency-path: package-lock.json - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: 1.97.1 components: rustfmt, clippy diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml deleted file mode 100644 index 244de37..0000000 --- a/.github/workflows/release-macos.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: Release macOS - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: Release tag to publish, for example v1.0.0-beta.1 - required: true - type: string - -permissions: - contents: write - -concurrency: - group: release-macos-${{ github.ref_name || inputs.tag }} - cancel-in-progress: false - -jobs: - release-macos: - name: Sign, notarize, and publish Apple Silicon DMG - runs-on: macos-15 - environment: production - timeout-minutes: 60 - - env: - RELEASE_EVENT_NAME: ${{ github.event_name }} - RELEASE_INPUT_TAG: ${{ inputs.tag }} - RELEASE_REF_NAME: ${{ github.ref_name }} - APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} - SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} - - steps: - - name: Resolve release tag - shell: bash - run: | - set -euo pipefail - release_tag="$RELEASE_REF_NAME" - if [[ "$RELEASE_EVENT_NAME" == 'workflow_dispatch' ]]; then - release_tag="$RELEASE_INPUT_TAG" - fi - if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then - echo "Release tag must be a semantic version beginning with v. Received: $release_tag" >&2 - exit 1 - fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - - - name: Checkout release tag - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - - - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: 24 - cache: npm - cache-dependency-path: package-lock.json - - - name: Set up Rust - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - with: - toolchain: 1.97.1 - targets: aarch64-apple-darwin - - - name: Install dependencies - run: npm ci - - - name: Verify release configuration - shell: bash - run: | - set -euo pipefail - node_version="$(node -p "require('./package.json').version")" - tauri_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" - expected_tag="v${node_version}" - if [[ "$node_version" != "$tauri_version" ]]; then - echo "package.json version $node_version does not match Tauri version $tauri_version." >&2 - exit 1 - fi - if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then - echo "Release tag $RELEASE_TAG does not match app version $expected_tag." >&2 - exit 1 - fi - if [[ "$APPLE_SIGNING_IDENTITY" != 'Developer ID Application: '* ]]; then - echo 'APPLE_SIGNING_IDENTITY must name a Developer ID Application certificate.' >&2 - exit 1 - fi - if [[ "$APPLE_SIGNING_IDENTITY" != *"($APPLE_TEAM_ID)" ]]; then - echo 'APPLE_SIGNING_IDENTITY does not contain the configured APPLE_TEAM_ID.' >&2 - exit 1 - fi - for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY SWITCHIFY_TELEMETRY_ENDPOINT; do - if [[ -z "${!required_name:-}" ]]; then - echo "$required_name is not configured for the production environment." >&2 - exit 1 - fi - done - - - name: Install Developer ID certificate - shell: bash - env: - APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$APPLE_CERTIFICATE_BASE64" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then - echo 'The production Developer ID certificate is not configured.' >&2 - exit 1 - fi - certificate_path="$RUNNER_TEMP/switchify-developer-id.p12" - keychain_path="$RUNNER_TEMP/switchify-signing.keychain-db" - keychain_password="$(openssl rand -base64 32)" - echo "::add-mask::$keychain_password" - printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" - chmod 600 "$certificate_path" - security create-keychain -p "$keychain_password" "$keychain_path" - security set-keychain-settings -lut 21600 "$keychain_path" - security unlock-keychain -p "$keychain_password" "$keychain_path" - security import "$certificate_path" \ - -P "$APPLE_CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 -k "$keychain_path" - security set-key-partition-list \ - -S apple-tool:,apple:,codesign: \ - -s -k "$keychain_password" "$keychain_path" - security list-keychains -d user -s "$keychain_path" - identity_output="$(security find-identity -v -p codesigning "$keychain_path")" - if ! grep -Fq "\"$APPLE_SIGNING_IDENTITY\"" <<< "$identity_output"; then - echo "The configured Developer ID identity was not found in the imported certificate." >&2 - exit 1 - fi - echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" - - - name: Install notarization API key - shell: bash - env: - APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} - run: | - set -euo pipefail - if [[ -z "$APPLE_API_PRIVATE_KEY" ]]; then - echo 'APPLE_API_PRIVATE_KEY is not configured for the production environment.' >&2 - exit 1 - fi - api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" - printf '%s' "$APPLE_API_PRIVATE_KEY" > "$api_key_path" - chmod 600 "$api_key_path" - echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV" - - - name: Build signed and notarized app and signed DMG - run: npm run tauri build -- --bundles app,dmg --target aarch64-apple-darwin - - - name: Notarize and staple DMG - shell: bash - run: | - set -euo pipefail - dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo 'No signed DMG was produced for notarization.' >&2 - exit 1 - fi - xcrun notarytool submit "$dmg_path" \ - --key "$APPLE_API_KEY_PATH" \ - --key-id "$APPLE_API_KEY" \ - --issuer "$APPLE_API_ISSUER" \ - --wait \ - --timeout 20m - xcrun stapler staple "$dmg_path" - - - name: Verify signed release - shell: bash - run: ./scripts/verify-macos-release.sh "$RELEASE_TAG" "$APPLE_SIGNING_IDENTITY" "$APPLE_TEAM_ID" - - - name: Stage release assets - shell: bash - run: | - set -euo pipefail - mkdir -p dist - dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo 'No notarized DMG was produced.' >&2 - exit 1 - fi - cp "$dmg_path" dist/ - asset_name="$(basename "$dmg_path")" - asset_hash="$(shasum -a 256 "dist/$asset_name" | awk '{print $1}')" - printf '%s %s\n' "$asset_hash" "$asset_name" > dist/SHA256SUMS-macos.txt - - - name: Publish GitHub release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - shopt -s nullglob - assets=(dist/*) - if (( ${#assets[@]} == 0 )); then - echo 'No release assets found in dist.' >&2 - exit 1 - fi - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber - else - gh release create "$RELEASE_TAG" "${assets[@]}" \ - --title "$RELEASE_TAG" \ - --generate-notes \ - --verify-tag - fi - - - name: Upload workflow artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: switchify-pc-macos-${{ env.RELEASE_TAG }} - path: dist/* - if-no-files-found: error - - - name: Remove temporary signing material - if: always() - shell: bash - run: | - if [[ -n "${APPLE_KEYCHAIN_PATH:-}" ]]; then - security delete-keychain "$APPLE_KEYCHAIN_PATH" 2>/dev/null || true - fi - rm -f \ - "$RUNNER_TEMP/switchify-developer-id.p12" \ - "$RUNNER_TEMP/AuthKey_${APPLE_API_KEY:-missing}.p8" diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 1753c62..5900b71 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -1,10 +1,13 @@ -name: Release Tauri beta +name: Release Switchify PC on: + push: + tags: + - 'v*' workflow_dispatch: inputs: - version: - description: Version without a leading v + tag: + description: Existing release tag to publish, for example v1.0.0-beta.1 required: true type: string @@ -12,153 +15,393 @@ permissions: contents: write concurrency: - group: tauri-release + group: release-${{ github.ref_name || inputs.tag }} cancel-in-progress: false jobs: prepare: + name: Validate release runs-on: ubuntu-latest outputs: - version: ${{ steps.version.outputs.value }} - tag: ${{ steps.version.outputs.tag }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + ref: ${{ steps.release.outputs.ref }} env: + RELEASE_EVENT_NAME: ${{ github.event_name }} + RELEASE_INPUT_TAG: ${{ inputs.tag }} + RELEASE_REF_NAME: ${{ github.ref_name }} UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} - APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} - CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - name: Resolve release tag + id: release + shell: bash + run: | + set -euo pipefail + release_tag="$RELEASE_REF_NAME" + release_ref="$GITHUB_REF" + if [[ "$RELEASE_EVENT_NAME" == 'workflow_dispatch' ]]; then + release_tag="$RELEASE_INPUT_TAG" + release_ref="refs/tags/$release_tag" + fi + if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a semantic version beginning with v. Received: $release_tag" >&2 + exit 1 + fi + if [[ -z "$UPDATER_PUBLIC_KEY" ]]; then + echo 'TAURI_UPDATER_PUBLIC_KEY is not configured.' >&2 + exit 1 + fi + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "version=${release_tag#v}" >> "$GITHUB_OUTPUT" + echo "ref=$release_ref" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ steps.release.outputs.ref }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 - - id: version + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify source versions shell: bash env: - VERSION_INPUT: ${{ inputs.version }} - run: | - version="$VERSION_INPUT" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'Invalid semantic version.' >&2; exit 1; } - configured=$(node -p "require('./package.json').version") - [[ "$version" == "$configured" ]] || { echo "package.json is $configured, not $version" >&2; exit 1; } - echo "value=$version" >> "$GITHUB_OUTPUT" - echo "tag=tauri-v$version" >> "$GITHUB_OUTPUT" - - name: Require release signing configuration - shell: bash + RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | - missing=0 - for name in UPDATER_PUBLIC_KEY APPLE_SIGNING_IDENTITY CERTUM_CERT_THUMBPRINT; do - [[ -n "${!name}" ]] || { echo "Missing release configuration: $name" >&2; missing=1; } - done - exit "$missing" + set -euo pipefail + node_version="$(node -p "require('./package.json').version")" + tauri_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + cargo_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml | head -1)" + if [[ "$node_version" != "$tauri_version" || "$node_version" != "$cargo_version" ]]; then + echo "Version mismatch: package=$node_version tauri=$tauri_version cargo=$cargo_version" >&2 + exit 1 + fi + if [[ "$RELEASE_TAG" != "v$node_version" ]]; then + echo "Release tag $RELEASE_TAG does not match app version v$node_version." >&2 + exit 1 + fi macos: + name: Sign and notarize macOS needs: prepare - runs-on: macos-14 + runs-on: macos-15 + environment: production + timeout-minutes: 60 env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_REF: ${{ needs.prepare.outputs.ref }} SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} steps: - - uses: actions/checkout@v6 - - name: Require macOS release secrets + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: aarch64-apple-darwin + + - name: Install dependencies + run: npm ci + + - name: Verify macOS release configuration shell: bash env: - UPDATER_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - UPDATER_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - missing=0 - for name in UPDATER_PRIVATE_KEY UPDATER_PRIVATE_KEY_PASSWORD APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do - [[ -n "${!name}" ]] || { echo "Missing macOS release secret: $name" >&2; missing=1; } + set -euo pipefail + if [[ "$APPLE_SIGNING_IDENTITY" != 'Developer ID Application: '* ]] || \ + [[ "$APPLE_SIGNING_IDENTITY" != *"($APPLE_TEAM_ID)" ]]; then + echo 'APPLE_SIGNING_IDENTITY must be a Developer ID Application identity for APPLE_TEAM_ID.' >&2 + exit 1 + fi + for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY \ + SWITCHIFY_TELEMETRY_ENDPOINT TAURI_SIGNING_PRIVATE_KEY \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD SWITCHIFY_UPDATER_PUBLIC_KEY; do + if [[ -z "${!required_name:-}" ]]; then + echo "Missing macOS release configuration: $required_name" >&2 + exit 1 + fi done - exit "$missing" - - uses: actions/setup-node@v6 - with: { node-version: 24, cache: npm } - - uses: dtolnay/rust-toolchain@stable - with: { toolchain: 1.97.1 } - - run: npm ci - - run: node scripts/render-updater-config.mjs src-tauri/tauri.release.generated.json - - run: npm run tauri build -- --bundles app,dmg --config src-tauri/tauri.release.generated.json + + - name: Install Developer ID certificate + shell: bash + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$APPLE_CERTIFICATE_BASE64" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then + echo 'The production Developer ID certificate is not configured.' >&2 + exit 1 + fi + certificate_path="$RUNNER_TEMP/switchify-developer-id.p12" + keychain_path="$RUNNER_TEMP/switchify-signing.keychain-db" + keychain_password="$(openssl rand -base64 32)" + echo "::add-mask::$keychain_password" + printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" + chmod 600 "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$keychain_path" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" + identity_output="$(security find-identity -v -p codesigning "$keychain_path")" + if ! grep -Fq "\"$APPLE_SIGNING_IDENTITY\"" <<< "$identity_output"; then + echo 'The configured Developer ID identity was not found after import.' >&2 + exit 1 + fi + echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" + + - name: Install notarization API key + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + run: | + set -euo pipefail + if [[ -z "$APPLE_API_PRIVATE_KEY" ]]; then + echo 'APPLE_API_PRIVATE_KEY is not configured.' >&2 + exit 1 + fi + api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + printf '%s' "$APPLE_API_PRIVATE_KEY" > "$api_key_path" + chmod 600 "$api_key_path" + echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV" + + - name: Render updater configuration + run: node scripts/render-updater-config.mjs src-tauri/tauri.release.generated.json + + - name: Build signed and notarized app, updater, and DMG env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - uses: actions/upload-artifact@v7 + run: npm run tauri build -- --bundles app,dmg --target aarch64-apple-darwin --config src-tauri/tauri.release.generated.json + + - name: Notarize and staple DMG + shell: bash + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + run: | + set -euo pipefail + dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" + if [[ -z "$dmg_path" ]]; then + echo 'No signed DMG was produced for notarization.' >&2 + exit 1 + fi + xcrun notarytool submit "$dmg_path" --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \ + --wait --timeout 20m + xcrun stapler staple "$dmg_path" + + - name: Verify and stage macOS release + shell: bash + run: | + set -euo pipefail + ./scripts/verify-macos-release.sh "$RELEASE_TAG" "$APPLE_SIGNING_IDENTITY" "$APPLE_TEAM_ID" + bundle_root='src-tauri/target/aarch64-apple-darwin/release/bundle' + dmg_path="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + updater_path="$(find "$bundle_root/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit)" + if [[ -z "$updater_path" || ! -s "$updater_path.sig" ]]; then + echo 'The signed macOS updater artifact is missing.' >&2 + exit 1 + fi + mkdir -p dist/macos + cp "$dmg_path" "$updater_path" "$updater_path.sig" dist/macos/ + (cd dist/macos && shasum -a 256 -- *.dmg *.app.tar.gz > SHA256SUMS-macos.txt) + + - name: Upload macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: macos-release - path: | - src-tauri/target/release/bundle/macos/*.app.tar.gz - src-tauri/target/release/bundle/macos/*.app.tar.gz.sig - src-tauri/target/release/bundle/dmg/*.dmg + path: dist/macos/* + if-no-files-found: error + + - name: Remove temporary signing material + if: always() + shell: bash + run: | + if [[ -n "${APPLE_KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$APPLE_KEYCHAIN_PATH" 2>/dev/null || true + fi + rm -f "$RUNNER_TEMP/switchify-developer-id.p12" "$RUNNER_TEMP"/AuthKey_*.p8 windows: + name: Sign Windows installer needs: prepare - runs-on: [self-hosted, Windows, X64, switchify-release] + runs-on: [self-hosted, Windows, X64, switchify-signing] + environment: production + timeout-minutes: 60 env: + RELEASE_REF: ${{ needs.prepare.outputs.ref }} SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} + SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} steps: - - uses: actions/checkout@v6 - - name: Require Windows release secrets + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: x86_64-pc-windows-msvc + + - name: Install dependencies + run: npm ci + + - name: Verify Windows release configuration shell: powershell env: + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - if (-not $env:TAURI_SIGNING_PRIVATE_KEY) { throw 'Missing Windows release secret: TAURI_SIGNING_PRIVATE_KEY' } - if (-not $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) { throw 'Missing Windows release secret: TAURI_SIGNING_PRIVATE_KEY_PASSWORD' } - - uses: actions/setup-node@v6 - with: { node-version: 24, cache: npm } - - uses: dtolnay/rust-toolchain@stable - with: { toolchain: 1.97.1 } - - run: npm ci - - run: node scripts/render-updater-config.mjs src-tauri/tauri.release.windows.generated.json --windows - - shell: powershell - run: ./scripts/Build-WindowsUiAccess.ps1 -TauriConfig src-tauri/tauri.release.windows.generated.json + foreach ($name in @( + 'SWITCHIFY_UPDATER_PUBLIC_KEY', + 'SWITCHIFY_CERTUM_CERT_THUMBPRINT', + 'SWITCHIFY_TELEMETRY_ENDPOINT', + 'TIMBERLOGS_API_KEY', + 'TAURI_SIGNING_PRIVATE_KEY', + 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD' + )) { + if (-not [Environment]::GetEnvironmentVariable($name)) { + throw "Missing Windows release configuration: $name" + } + } + + - name: Render updater configuration + run: node scripts/render-updater-config.mjs src-tauri/tauri.release.windows.generated.json --windows + + - name: Build signed Windows package + shell: powershell env: + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - shell: powershell + run: ./scripts/Build-WindowsUiAccess.ps1 -TauriConfig src-tauri/tauri.release.windows.generated.json + + - name: Verify Windows package + shell: powershell run: ./scripts/Verify-WindowsUiAccessPackage.ps1 - - uses: actions/upload-artifact@v7 + + - name: Stage Windows release + shell: powershell + run: | + $installer = Get-ChildItem 'src-tauri/target/release/bundle/nsis' -Filter '*-setup.exe' | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $installer -or -not (Test-Path -LiteralPath "$($installer.FullName).sig")) { + throw 'The signed Windows updater artifact is missing.' + } + New-Item -ItemType Directory -Path dist/windows -Force | Out-Null + Copy-Item -LiteralPath $installer.FullName -Destination dist/windows + Copy-Item -LiteralPath "$($installer.FullName).sig" -Destination dist/windows + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer.FullName).Hash.ToLowerInvariant() + "$hash $($installer.Name)" | Set-Content -NoNewline dist/windows/SHA256SUMS-windows.txt + + - name: Upload Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: windows-release - path: | - src-tauri/target/release/bundle/nsis/*-setup.exe - src-tauri/target/release/bundle/nsis/*-setup.exe.sig + path: dist/windows/* + if-no-files-found: error publish: + name: Publish release and updater feed needs: [prepare, macos, windows] runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_REF: ${{ needs.prepare.outputs.ref }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: { node-version: 24 } - - uses: actions/download-artifact@v7 - with: { path: release-artifacts } - - run: node scripts/create-update-feed.mjs release-artifacts '${{ needs.prepare.outputs.version }}' '${{ needs.prepare.outputs.tag }}' latest.json - - name: Publish prerelease assets + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: release-artifacts + + - name: Create updater feed + run: node scripts/create-update-feed.mjs release-artifacts "$RELEASE_VERSION" "$RELEASE_TAG" latest.json + + - name: Publish verified release assets shell: bash run: | - tag='${{ needs.prepare.outputs.tag }}' - gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --prerelease --title "Switchify PC ${{ needs.prepare.outputs.version }}" - find release-artifacts -type f ! -name '*.sig' -print0 | xargs -0 gh release upload "$tag" --clobber - find release-artifacts -type f -name '*.sig' -print0 | xargs -0 gh release upload "$tag" --clobber - - name: Publish dedicated Tauri update feed + set -euo pipefail + mapfile -d '' assets < <(find release-artifacts -type f -print0) + if (( ${#assets[@]} == 0 )); then + echo 'No release assets were downloaded.' >&2 + exit 1 + fi + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber + else + release_args=(--title "$RELEASE_TAG" --generate-notes --verify-tag) + if [[ "$RELEASE_VERSION" == *-* ]]; then + release_args+=(--prerelease) + fi + gh release create "$RELEASE_TAG" "${release_args[@]}" "${assets[@]}" + fi + + - name: Publish updater feed last shell: bash run: | - gh api repos/${{ github.repository }}/git/ref/heads/update-feed >/dev/null 2>&1 || \ - gh api --method POST repos/${{ github.repository }}/git/refs -f ref=refs/heads/update-feed -f sha="$GITHUB_SHA" - content=$(base64 -w0 latest.json) - sha=$(gh api repos/${{ github.repository }}/contents/latest.json?ref=update-feed --jq .sha 2>/dev/null || true) - args=(-f message="Update Tauri feed to ${{ needs.prepare.outputs.version }}" -f content="$content" -f branch=update-feed) - [[ -z "$sha" ]] || args+=(-f sha="$sha") - gh api --method PUT repos/${{ github.repository }}/contents/latest.json "${args[@]}" + set -euo pipefail + if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/update-feed" >/dev/null 2>&1; then + release_sha="$(git rev-parse HEAD)" + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref=refs/heads/update-feed -f sha="$release_sha" + fi + content="$(base64 -w0 latest.json)" + current_sha="$(gh api "repos/${GITHUB_REPOSITORY}/contents/latest.json?ref=update-feed" --jq .sha 2>/dev/null || true)" + args=(-f message="Update Tauri feed to $RELEASE_VERSION" -f content="$content" -f branch=update-feed) + if [[ -n "$current_sha" ]]; then + args+=(-f sha="$current_sha") + fi + gh api --method PUT "repos/${GITHUB_REPOSITORY}/contents/latest.json" "${args[@]}" diff --git a/README.md b/README.md index 8ebef70..b963648 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,15 @@ cargo test --manifest-path src-tauri/Cargo.toml Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`. -## macOS releases +## Production releases and signed updates Production macOS releases are Apple Silicon DMGs signed with an Apple-issued Developer ID Application certificate, submitted to Apple's notarization service, and stapled for offline Gatekeeper verification. A `v*` tag or manual release run creates or updates the matching GitHub Release after verifying the tag, app version, architecture, nested-code signatures, hardened runtime, secure timestamp, and notarization tickets. The production certificate and App Store Connect API key are held only in the GitHub `production` environment and imported into an ephemeral runner keychain. They are separate from the machine-local `Switchify PC Development` identity used by `npm run macos:run`. See [macOS production releases](docs/macos-releases.md) for certificate creation, GitHub configuration, release, recovery, and rotation instructions. -## Signed updates - Packaged release builds check a dedicated Tauri feed at `update-feed/latest.json` shortly after startup, every six hours, and on demand. Settings shows availability, verified download progress, cancellation, retry, installation, and restart state. Concurrent checks, downloads, and installs are deduplicated. Development and unsigned CI builds intentionally keep the updater unconfigured and report that state without crashing. -The manual `Release Tauri beta` workflow renders release-only Tauri configuration, creates signed updater artifacts for Apple-silicon macOS and x64 Windows, publishes them under a `tauri-v` prerelease, and updates the dedicated feed. It does not alter the public C# `v0.10.0` release or its `latest.yml` feed. +The `Release Switchify PC` workflow accepts existing `v` tags, renders release-only updater configuration, and publishes signed Apple-silicon macOS and x64 Windows artifacts into one GitHub release. It updates the dedicated feed only after both platform packages pass verification. It does not alter the public C# `v0.10.0` release or its `latest.yml` feed. Before the workflow can run, configure `TAURI_UPDATER_PUBLIC_KEY`, `APPLE_SIGNING_IDENTITY`, and `CERTUM_CERT_THUMBPRINT` as repository variables; configure the Tauri updater private key/password and platform signing credentials as protected secrets. The Windows job targets the self-hosted signing runner with SimplySign available. Private signing material is never generated by or committed to this repository. diff --git a/docs/macos-releases.md b/docs/macos-releases.md index f064047..19a7210 100644 --- a/docs/macos-releases.md +++ b/docs/macos-releases.md @@ -26,6 +26,8 @@ Configure these environment secrets: | `APPLE_API_KEY` | App Store Connect API key ID | | `APPLE_API_PRIVATE_KEY` | Complete contents of the downloaded `.p8` file | | `TIMBERLOGS_API_KEY` | Production telemetry API key | +| `TAURI_SIGNING_PRIVATE_KEY` | Password-protected Tauri updater private key | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the updater private key | Configure these environment variables: @@ -35,6 +37,8 @@ Configure these environment variables: | `APPLE_TEAM_ID` | Apple Developer team ID from the identity | | `TIMBERLOGS_ENDPOINT` | Production HTTPS telemetry endpoint | +Configure `TAURI_UPDATER_PUBLIC_KEY` as a repository variable. Keep an encrypted offline backup of the updater private key and password: installed applications cannot accept future updates if that key is lost. + Encode the certificate without line wrapping on macOS: ```bash @@ -45,11 +49,9 @@ The workflow writes credentials only beneath the ephemeral runner directory, imp ## Publish a release -Keep `package.json` and `src-tauri/tauri.conf.json` versions identical, then create and push the corresponding tag, for example `v1.0.0-beta.1`. The release workflow checks out that exact tag, builds on an Apple Silicon runner, lets Tauri sign and notarize the app, then separately submits and staples the finished signed DMG. It validates Gatekeeper and both stapled tickets before creating or updating the matching GitHub Release. - -The workflow can be manually dispatched with an existing tag to recover or replace a macOS asset. It never modifies earlier tags or release assets. The published DMG is accompanied by `SHA256SUMS-macos.txt`. +Keep `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml` versions identical, then create and push the corresponding tag, for example `v1.0.0-beta.1`. The release workflow checks out that exact tag, builds on an Apple Silicon runner, lets Tauri sign and notarize the app, then separately submits and staples the finished signed DMG. The Windows job runs on the `switchify-signing` self-hosted runner with SimplySign authenticated. Both packages and updater signatures must pass verification before the matching GitHub Release and `update-feed/latest.json` are updated. -Updater feed generation and updater signing are intentionally separate work. Publishing a DMG does not make the in-app updater functional. +The workflow can be manually dispatched with an existing tag to recover or replace assets for that tag. It never modifies earlier tags or their assets. Checksums accompany both platform installers. ## Rotation and troubleshooting diff --git a/scripts/create-update-feed.mjs b/scripts/create-update-feed.mjs index 97bac67..5373af3 100644 --- a/scripts/create-update-feed.mjs +++ b/scripts/create-update-feed.mjs @@ -5,7 +5,10 @@ const [root, version, tag, output] = process.argv.slice(2); if (!root || !version || !tag || !output) { throw new Error("Usage: node scripts/create-update-feed.mjs "); } -if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error("Invalid semantic version."); +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error("Invalid semantic version."); +} +if (tag !== `v${version}`) throw new Error(`Release tag ${tag} does not match v${version}.`); const files = []; const walk = (directory) => { @@ -25,7 +28,11 @@ const pick = (predicate, label) => { const mac = pick((name) => name.endsWith(".app.tar.gz"), "macOS"); const windows = pick((name) => name.endsWith("-setup.exe"), "Windows NSIS"); const asset = (path) => `https://github.com/switchifyapp/switchify-pc/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(basename(path))}`; -const platform = (path) => ({ signature: readFileSync(`${path}.sig`, "utf8").trim(), url: asset(path) }); +const platform = (path) => { + const signature = readFileSync(`${path}.sig`, "utf8").trim(); + if (!signature) throw new Error(`Updater signature is empty for ${basename(path)}.`); + return { signature, url: asset(path) }; +}; writeFileSync(resolve(output), `${JSON.stringify({ version, diff --git a/scripts/render-updater-config.mjs b/scripts/render-updater-config.mjs index 8417004..f446b3f 100644 --- a/scripts/render-updater-config.mjs +++ b/scripts/render-updater-config.mjs @@ -9,7 +9,13 @@ const pubkey = process.env.SWITCHIFY_UPDATER_PUBLIC_KEY?.trim(); if (!output) throw new Error("Usage: node scripts/render-updater-config.mjs [--windows]"); if (!pubkey) throw new Error("SWITCHIFY_UPDATER_PUBLIC_KEY is required."); -if (!endpoint.startsWith("https://")) throw new Error("SWITCHIFY_UPDATER_ENDPOINT must use HTTPS."); +let endpointUrl; +try { + endpointUrl = new URL(endpoint); +} catch { + throw new Error("SWITCHIFY_UPDATER_ENDPOINT must be a valid URL."); +} +if (endpointUrl.protocol !== "https:") throw new Error("SWITCHIFY_UPDATER_ENDPOINT must use HTTPS."); const config = { plugins: { updater: { endpoints: [endpoint], pubkey } }, @@ -23,7 +29,7 @@ if (windows) { cmd: "powershell.exe", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "../scripts/Sign-Windows.ps1", "%1"], }, - nsis: { installerHooks: "windows/installer-hooks.nsh" }, + nsis: { installMode: "perMachine", installerHooks: "windows/installer-hooks.nsh" }, }; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a36d63..e94e786 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,7 +32,10 @@ use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_autostart::ManagerExt as AutostartManagerExt; use tauri_plugin_updater::UpdaterExt; use telemetry::TelemetryConsent; -use updater::{Operation as UpdateOperation, RetryAction, UpdateManager, UpdateView}; +use updater::{ + download_with_cancel, DownloadResult, Operation as UpdateOperation, RetryAction, + UpdateArtifact, UpdateManager, UpdateView, +}; #[tauri::command] fn get_app_state(model: State<'_, AppModel>) -> AppState { @@ -771,7 +774,7 @@ async fn check_for_updates_inner(app: &AppHandle) -> AppState { manager.finish(UpdateOperation::Check); match result { Ok(Some(update)) => { - let version = update.version.clone(); + let version = update.version().to_owned(); manager.replace_available(Some(update)); state::set_activity( &model.shared, @@ -825,8 +828,8 @@ async fn download_update(app: AppHandle) -> Result { "check for an update first", )); }; - let version = update.version.clone(); - let (cancel_sender, mut cancel_receiver) = tokio::sync::watch::channel(false); + let version = update.version().to_owned(); + let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false); manager.set_download_cancel(cancel_sender); state::set_activity( &model.shared, @@ -837,30 +840,16 @@ async fn download_update(app: AppHandle) -> Result { let progress_app = app.clone(); let progress_shared = model.shared.clone(); - let download = update.download( - move |chunk, total| { - { - let mut data = progress_shared - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - data.state.updater.add_progress(chunk, total); - } - state::emit_state(&progress_app, &progress_shared); - }, - || {}, - ); - tokio::pin!(download); - enum DownloadResult { - Complete(Result, String>), - Cancelled, - } - let result = tokio::select! { - result = &mut download => DownloadResult::Complete(result.map_err(|error| error.to_string())), - changed = cancel_receiver.changed() => { - let _ = changed; - DownloadResult::Cancelled + let result = download_with_cancel(&update, cancel_receiver, move |chunk, total| { + { + let mut data = progress_shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.state.updater.add_progress(chunk, total); } - }; + state::emit_state(&progress_app, &progress_shared); + }); + let result = result.await; manager.finish(UpdateOperation::Download); match result { DownloadResult::Complete(Ok(bytes)) => { @@ -939,13 +928,13 @@ async fn install_update(app: AppHandle) -> Result { return Ok(update_failure( &app, &model, - Some(update.version), + Some(update.version().to_owned()), RetryAction::Download, "Update installation could not start", "download the update first", )); }; - let version = update.version.clone(); + let version = update.version().to_owned(); let downloaded_bytes = bytes.len() as u64; let total_bytes = model.snapshot().updater.total_bytes; let overlay = app.state::(); @@ -972,7 +961,7 @@ async fn install_update(app: AppHandle) -> Result { &model, UpdateView::applying(version.clone(), downloaded_bytes, total_bytes), ); - if let Err(error) = update.install(&bytes) { + if let Err(error) = UpdateArtifact::install(&update, &bytes) { manager.store_download(bytes); manager.finish(UpdateOperation::Install); return Ok(update_failure( @@ -981,7 +970,7 @@ async fn install_update(app: AppHandle) -> Result { Some(version), RetryAction::Install, "Update installation failed", - &error.to_string(), + &error, )); } model.record_updater("installed", None); @@ -1161,7 +1150,7 @@ pub fn run() { ) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(model) - .manage(UpdateManager::default()) + .manage(UpdateManager::::default()) .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 0247052..cf9538c 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -1,9 +1,69 @@ -use std::sync::Mutex; +use std::{future::Future, pin::Pin, sync::Mutex}; use serde::Serialize; use tauri_plugin_updater::Update; use tokio::sync::watch; +type DownloadFuture<'a> = Pin, String>> + Send + 'a>>; + +pub trait UpdateArtifact: Clone + Send + Sync + 'static { + fn version(&self) -> &str; + fn download<'a>( + &'a self, + on_chunk: Box) + Send + 'a>, + ) -> DownloadFuture<'a>; + fn install(&self, bytes: &[u8]) -> Result<(), String>; +} + +impl UpdateArtifact for Update { + fn version(&self) -> &str { + &self.version + } + + fn download<'a>( + &'a self, + on_chunk: Box) + Send + 'a>, + ) -> DownloadFuture<'a> { + Box::pin(async move { + Update::download(self, on_chunk, || {}) + .await + .map_err(|error| error.to_string()) + }) + } + + fn install(&self, bytes: &[u8]) -> Result<(), String> { + Update::install(self, bytes).map_err(|error| error.to_string()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum DownloadResult { + Complete(Result, String>), + Cancelled, +} + +pub async fn download_with_cancel( + update: &U, + mut cancel_receiver: watch::Receiver, + on_chunk: F, +) -> DownloadResult +where + U: UpdateArtifact, + F: FnMut(usize, Option) + Send, +{ + let mut download = update.download(Box::new(on_chunk)); + tokio::select! { + result = &mut download => DownloadResult::Complete(result), + changed = cancel_receiver.changed() => { + if changed.is_ok() && *cancel_receiver.borrow() { + DownloadResult::Cancelled + } else { + DownloadResult::Complete(download.await) + } + } + } +} + #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum UpdateStatus { @@ -148,17 +208,27 @@ pub enum Operation { } #[derive(Default)] -struct RuntimeData { +struct RuntimeData { active: Option, - available: Option, + available: Option, downloaded: Option>, cancel_download: Option>, } -#[derive(Default)] -pub struct UpdateManager(Mutex); +pub struct UpdateManager(Mutex>); -impl UpdateManager { +impl Default for UpdateManager { + fn default() -> Self { + Self(Mutex::new(RuntimeData { + active: None, + available: None, + downloaded: None, + cancel_download: None, + })) + } +} + +impl UpdateManager { pub fn begin(&self, operation: Operation) -> bool { let mut data = self .0 @@ -184,7 +254,7 @@ impl UpdateManager { } } - pub fn replace_available(&self, update: Option) { + pub fn replace_available(&self, update: Option) { let mut data = self .0 .lock() @@ -193,7 +263,7 @@ impl UpdateManager { data.downloaded = None; } - pub fn available(&self) -> Option { + pub fn available(&self) -> Option { self.0 .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -243,8 +313,73 @@ impl UpdateManager { #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Notify; + use super::*; + #[derive(Clone)] + struct FakeUpdate { + version: String, + chunks: Vec>, + download_error: Option, + install_error: Option, + installed: Arc>>>, + gate: Option>, + } + + impl FakeUpdate { + fn successful(chunks: Vec>) -> Self { + Self { + version: "2.0.0".into(), + chunks, + download_error: None, + install_error: None, + installed: Arc::new(Mutex::new(Vec::new())), + gate: None, + } + } + } + + impl UpdateArtifact for FakeUpdate { + fn version(&self) -> &str { + &self.version + } + + fn download<'a>( + &'a self, + mut on_chunk: Box) + Send + 'a>, + ) -> DownloadFuture<'a> { + Box::pin(async move { + if let Some(gate) = &self.gate { + gate.notified().await; + } + if let Some(error) = &self.download_error { + return Err(error.clone()); + } + let total = self.chunks.iter().map(Vec::len).sum::() as u64; + let mut bytes = Vec::new(); + for chunk in &self.chunks { + on_chunk(chunk.len(), Some(total)); + bytes.extend_from_slice(chunk); + } + Ok(bytes) + }) + } + + fn install(&self, bytes: &[u8]) -> Result<(), String> { + if let Some(error) = &self.install_error { + return Err(error.clone()); + } + self.installed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(bytes.to_vec()); + Ok(()) + } + } + #[test] fn transitions_expose_progress_retry_and_cancellation() { let mut downloading = UpdateView::downloading("2.0.0".into()); @@ -268,7 +403,7 @@ mod tests { #[test] fn operation_gate_deduplicates_concurrent_work() { - let manager = UpdateManager::default(); + let manager = UpdateManager::::default(); assert!(manager.begin(Operation::Check)); assert!(!manager.begin(Operation::Check)); assert!(!manager.begin(Operation::Install)); @@ -280,7 +415,7 @@ mod tests { #[tokio::test] async fn cancellation_signal_is_one_shot_and_non_blocking() { - let manager = UpdateManager::default(); + let manager = UpdateManager::::default(); let (sender, mut receiver) = watch::channel(false); manager.set_download_cancel(sender); assert!(manager.cancel_download()); @@ -289,4 +424,63 @@ mod tests { manager.finish(Operation::Download); assert!(!manager.cancel_download()); } + + #[tokio::test] + async fn fake_artifact_download_reports_progress_and_installs_exact_bytes() { + let update = FakeUpdate::successful(vec![vec![1, 2], vec![3, 4, 5]]); + let (_cancel, receiver) = watch::channel(false); + let progress = Arc::new(Mutex::new(Vec::new())); + let progress_events = progress.clone(); + let result = download_with_cancel(&update, receiver, move |chunk, total| { + progress_events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((chunk, total)); + }) + .await; + assert_eq!(result, DownloadResult::Complete(Ok(vec![1, 2, 3, 4, 5]))); + assert_eq!( + *progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + vec![(2, Some(5)), (3, Some(5))] + ); + update.install(&[1, 2, 3]).unwrap(); + assert_eq!( + *update + .installed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + vec![vec![1, 2, 3]] + ); + } + + #[tokio::test] + async fn fake_artifact_exposes_download_and_install_failures() { + let mut update = FakeUpdate::successful(Vec::new()); + update.download_error = Some("network unavailable".into()); + update.install_error = Some("installer rejected".into()); + let (_cancel, receiver) = watch::channel(false); + assert_eq!( + download_with_cancel(&update, receiver, |_, _| {}).await, + DownloadResult::Complete(Err("network unavailable".into())) + ); + assert_eq!(update.install(&[1]), Err("installer rejected".into())); + } + + #[tokio::test] + async fn cancellation_drops_an_incomplete_fake_download() { + let gate = Arc::new(Notify::new()); + let mut update = FakeUpdate::successful(vec![vec![1]]); + update.gate = Some(gate); + let (cancel, receiver) = watch::channel(false); + let download = download_with_cancel(&update, receiver, |_, _| {}); + tokio::pin!(download); + tokio::select! { + result = &mut download => panic!("download finished before cancellation: {result:?}"), + () = tokio::task::yield_now() => {} + } + cancel.send(true).unwrap(); + assert_eq!(download.await, DownloadResult::Cancelled); + } } diff --git a/src/App.test.tsx b/src/App.test.tsx index a107902..99f01d6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -93,6 +93,17 @@ describe("Switchify PC shell", () => { expect(install).toHaveBeenCalledOnce(); }); + it("retries a cancelled download from the beginning", async () => { + browserState.updater = { status: "cancelled", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: "download" }; + const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready."); + fireEvent.click(screen.getByRole("button", { name: "Retry download" })); + expect(download).toHaveBeenCalledOnce(); + }); + it("routes tray navigation without discarding a dirty profile silently", async () => { let navigate: ((target: "home" | "settings" | "profiles") => void) | undefined; vi.spyOn(api, "onNavigateRequested").mockImplementation(async (handler) => { diff --git a/src/App.tsx b/src/App.tsx index ed86145..9ec932f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -315,7 +315,7 @@ function updateDescription(update: UpdateState) { case "applying": return `Installing Switchify PC ${update.version}…`; case "current": return "Switchify PC is up to date."; case "failed": return update.error ?? "The update operation failed."; - case "cancelled": return "Download cancelled. You can resume when ready."; + case "cancelled": return "Download cancelled. You can retry when ready."; } } @@ -328,7 +328,7 @@ function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (ac : update.status === "failed" ? update.retryAction : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null; const label = update.status === "failed" ? "Retry" - : action === "download" ? (update.status === "cancelled" ? "Resume download" : "Download") + : action === "download" ? (update.status === "cancelled" ? "Retry download" : "Download") : action === "install" ? "Install and restart" : "Check for updates"; return

{updateDescription(update)}

From 41eedc77db49f2478f637d4876b7dc75beeb69bb Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:24:44 +0100 Subject: [PATCH 4/4] Verify updater artifacts before publishing --- .github/workflows/ci.yml | 38 ++++- .github/workflows/release-tauri.yml | 13 +- .gitignore | 1 + package.json | 2 +- scripts/create-update-feed.mjs | 137 ++++++++++++++----- scripts/create-update-feed.node-test.mjs | 77 +++++++++++ tools/updater-signature-verifier/Cargo.lock | 23 ++++ tools/updater-signature-verifier/Cargo.toml | 10 ++ tools/updater-signature-verifier/src/main.rs | 92 +++++++++++++ 9 files changed, 349 insertions(+), 44 deletions(-) create mode 100644 scripts/create-update-feed.node-test.mjs create mode 100644 tools/updater-signature-verifier/Cargo.lock create mode 100644 tools/updater-signature-verifier/Cargo.toml create mode 100644 tools/updater-signature-verifier/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04ce882..cbad498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,23 +25,44 @@ jobs: node-version: 24 cache: npm cache-dependency-path: package-lock.json + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 - run: npm ci - name: Validate release updater configuration shell: bash run: | + set -euo pipefail npx tauri signer generate --ci --password validation-only --write-keys "$RUNNER_TEMP/updater.key" SWITCHIFY_UPDATER_PUBLIC_KEY="$(cat "$RUNNER_TEMP/updater.key.pub")" node scripts/render-updater-config.mjs "$RUNNER_TEMP/tauri.release.json" node -e 'const c=require(process.argv[1]); if (!c.bundle.createUpdaterArtifacts || c.plugins.updater.endpoints.length !== 1 || !c.plugins.updater.pubkey) process.exit(1)' "$RUNNER_TEMP/tauri.release.json" - mkdir -p "$RUNNER_TEMP/artifacts/mac" "$RUNNER_TEMP/artifacts/windows" - touch "$RUNNER_TEMP/artifacts/mac/Switchify.PC.app.tar.gz" "$RUNNER_TEMP/artifacts/windows/Switchify.PC_1.0.0_x64-setup.exe" - printf 'mac-signature' > "$RUNNER_TEMP/artifacts/mac/Switchify.PC.app.tar.gz.sig" - printf 'windows-signature' > "$RUNNER_TEMP/artifacts/windows/Switchify.PC_1.0.0_x64-setup.exe.sig" - node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/latest.json" - node -e 'const f=require(process.argv[1]); if (f.platforms["darwin-aarch64"].signature !== "mac-signature" || f.platforms["windows-x86_64"].signature !== "windows-signature") process.exit(1)' "$RUNNER_TEMP/latest.json" - if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.2 "$RUNNER_TEMP/invalid.json"; then + cargo build --locked --manifest-path tools/updater-signature-verifier/Cargo.toml + verifier="$GITHUB_WORKSPACE/tools/updater-signature-verifier/target/debug/switchify-updater-signature-verifier" + mkdir -p "$RUNNER_TEMP/artifacts/macos-release" "$RUNNER_TEMP/artifacts/windows-release" + mac_artifact="$RUNNER_TEMP/artifacts/macos-release/Switchify.PC.app.tar.gz" + windows_artifact="$RUNNER_TEMP/artifacts/windows-release/Switchify.PC_1.0.0_x64-setup.exe" + node -e 'require("fs").writeFileSync(process.argv[1], require("zlib").gzipSync("archive fixture"))' "$mac_artifact" + node -e 'const b=Buffer.alloc(128); b.write("MZ"); b.writeUInt32LE(64, 0x3c); b.write("PE\0\0", 64); require("fs").writeFileSync(process.argv[1], b)' "$windows_artifact" + npx tauri signer sign --private-key-path "$RUNNER_TEMP/updater.key" --password validation-only "$mac_artifact" + npx tauri signer sign --private-key-path "$RUNNER_TEMP/updater.key" --password validation-only "$windows_artifact" + export SWITCHIFY_UPDATER_PUBLIC_KEY="$(cat "$RUNNER_TEMP/updater.key.pub")" + node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/latest.json" "$verifier" + node -e 'const f=require(process.argv[1]); if (!f.platforms["darwin-aarch64"].signature || !f.platforms["windows-x86_64"].signature) process.exit(1)' "$RUNNER_TEMP/latest.json" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.2 "$RUNNER_TEMP/invalid.json" "$verifier"; then echo 'Mismatched update tag was accepted.' >&2 exit 1 fi + printf 'not-a-tauri-signature' > "$mac_artifact.sig" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/invalid.json" "$verifier"; then + echo 'Invalid updater signature was accepted.' >&2 + exit 1 + fi + printf 'not an updater archive' > "$mac_artifact" + npx tauri signer sign --private-key-path "$RUNNER_TEMP/updater.key" --password validation-only "$mac_artifact" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/invalid.json" "$verifier"; then + echo 'Wrong-format updater payload was accepted.' >&2 + exit 1 + fi - run: npm run lint - run: npm test - run: npm run build @@ -71,6 +92,9 @@ jobs: - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - run: cargo fmt --manifest-path tools/updater-signature-verifier/Cargo.toml --check + - run: cargo clippy --locked --manifest-path tools/updater-signature-verifier/Cargo.toml --all-targets -- -D warnings + - run: cargo test --locked --manifest-path tools/updater-signature-verifier/Cargo.toml - if: runner.os == 'Windows' run: cargo fmt --manifest-path src-tauri/startup-launcher/Cargo.toml --check - if: runner.os == 'Windows' diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 5900b71..b7f6797 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -351,6 +351,7 @@ jobs: RELEASE_TAG: ${{ needs.prepare.outputs.tag }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_REF: ${{ needs.prepare.outputs.ref }} + SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} steps: - name: Checkout release tag uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -362,13 +363,23 @@ jobs: with: node-version: 24 + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + - name: Download release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: release-artifacts + - name: Build updater signature verifier + run: cargo build --locked --release --manifest-path tools/updater-signature-verifier/Cargo.toml + - name: Create updater feed - run: node scripts/create-update-feed.mjs release-artifacts "$RELEASE_VERSION" "$RELEASE_TAG" latest.json + run: >- + node scripts/create-update-feed.mjs release-artifacts "$RELEASE_VERSION" "$RELEASE_TAG" latest.json + tools/updater-signature-verifier/target/release/switchify-updater-signature-verifier - name: Publish verified release assets shell: bash diff --git a/.gitignore b/.gitignore index 4e13006..dfcf379 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ src-tauri/target/ src-tauri/binaries/ src-tauri/startup-launcher/target/ +tools/updater-signature-verifier/target/ src-tauri/gen/ .certs/ .env diff --git a/package.json b/package.json index 03e2fb8..70ccff9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "vitest run", + "test": "vitest run && node --test scripts/create-update-feed.node-test.mjs", "lint": "tsc --noEmit", "macos:setup-signing": "./scripts/setup-macos-dev-signing.sh", "macos:run": "./scripts/run-macos-signed.sh", diff --git a/scripts/create-update-feed.mjs b/scripts/create-update-feed.mjs index 5373af3..6a2f8da 100644 --- a/scripts/create-update-feed.mjs +++ b/scripts/create-update-feed.mjs @@ -1,45 +1,112 @@ -import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + openSync, + closeSync, + readFileSync, + readSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; -const [root, version, tag, output] = process.argv.slice(2); -if (!root || !version || !tag || !output) { - throw new Error("Usage: node scripts/create-update-feed.mjs "); -} -if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { - throw new Error("Invalid semantic version."); -} -if (tag !== `v${version}`) throw new Error(`Release tag ${tag} does not match v${version}.`); - -const files = []; -const walk = (directory) => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) walk(path); - else files.push(path); +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +const regularFiles = (directory) => { + if (!existsSync(directory) || !lstatSync(directory).isDirectory()) { + throw new Error(`Expected release artifact directory ${directory}.`); } + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(directory, entry.name)); }; -walk(resolve(root)); -const pick = (predicate, label) => { +const pickSignedArtifact = (directory, predicate, label) => { + const files = regularFiles(directory); const matches = files.filter((path) => predicate(basename(path)) && existsSync(`${path}.sig`)); - if (matches.length !== 1) throw new Error(`Expected one signed ${label} artifact, found ${matches.length}.`); + if (matches.length !== 1) { + throw new Error(`Expected one signed ${label} artifact, found ${matches.length}.`); + } + if (!lstatSync(`${matches[0]}.sig`).isFile()) { + throw new Error(`Updater signature is not a regular file for ${basename(matches[0])}.`); + } return matches[0]; }; -const mac = pick((name) => name.endsWith(".app.tar.gz"), "macOS"); -const windows = pick((name) => name.endsWith("-setup.exe"), "Windows NSIS"); -const asset = (path) => `https://github.com/switchifyapp/switchify-pc/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(basename(path))}`; -const platform = (path) => { - const signature = readFileSync(`${path}.sig`, "utf8").trim(); - if (!signature) throw new Error(`Updater signature is empty for ${basename(path)}.`); - return { signature, url: asset(path) }; + +const validateMacArtifact = (path) => { + const header = Buffer.alloc(2); + const file = openSync(path, "r"); + try { + if (readSync(file, header, 0, header.length, 0) !== header.length || header[0] !== 0x1f || header[1] !== 0x8b) { + throw new Error(`macOS updater artifact is not a gzip archive: ${basename(path)}.`); + } + } finally { + closeSync(file); + } +}; + +const validateWindowsArtifact = (path) => { + const dosHeader = Buffer.alloc(64); + const file = openSync(path, "r"); + try { + if (readSync(file, dosHeader, 0, dosHeader.length, 0) !== dosHeader.length || dosHeader.toString("ascii", 0, 2) !== "MZ") { + throw new Error(`Windows updater artifact is not a PE executable: ${basename(path)}.`); + } + const peOffset = dosHeader.readUInt32LE(0x3c); + const peHeader = Buffer.alloc(4); + if (peOffset < dosHeader.length || readSync(file, peHeader, 0, peHeader.length, peOffset) !== peHeader.length || !peHeader.equals(Buffer.from("PE\0\0"))) { + throw new Error(`Windows updater artifact has an invalid PE header: ${basename(path)}.`); + } + } finally { + closeSync(file); + } }; -writeFileSync(resolve(output), `${JSON.stringify({ - version, - notes: `Switchify PC ${version}`, - pub_date: new Date().toISOString(), - platforms: { - "darwin-aarch64": platform(mac), - "windows-x86_64": platform(windows), - }, -}, null, 2)}\n`); +const verifyWithExecutable = (verifier, artifact, signature) => { + execFileSync(resolve(verifier), [artifact, signature], { stdio: "pipe" }); +}; + +export const createUpdateFeed = ({ root, version, tag, output, verifier, verifySignature }) => { + if (!SEMVER.test(version)) throw new Error("Invalid semantic version."); + if (tag !== `v${version}`) throw new Error(`Release tag ${tag} does not match v${version}.`); + + const artifactRoot = resolve(root); + const mac = pickSignedArtifact(join(artifactRoot, "macos-release"), (name) => name.endsWith(".app.tar.gz"), "macOS"); + const windows = pickSignedArtifact(join(artifactRoot, "windows-release"), (name) => name.endsWith("-setup.exe"), "Windows NSIS"); + validateMacArtifact(mac); + validateWindowsArtifact(windows); + + const verify = verifySignature ?? ((artifact, signature) => verifyWithExecutable(verifier, artifact, signature)); + const platform = (path) => { + const signaturePath = `${path}.sig`; + const signature = readFileSync(signaturePath, "utf8").trim(); + if (!signature) throw new Error(`Updater signature is empty for ${basename(path)}.`); + verify(path, signaturePath); + return { + signature, + url: `https://github.com/switchifyapp/switchify-pc/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(basename(path))}`, + }; + }; + + const feed = { + version, + notes: `Switchify PC ${version}`, + pub_date: new Date().toISOString(), + platforms: { + "darwin-aarch64": platform(mac), + "windows-x86_64": platform(windows), + }, + }; + writeFileSync(resolve(output), `${JSON.stringify(feed, null, 2)}\n`); + return feed; +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [root, version, tag, output, verifier] = process.argv.slice(2); + if (!root || !version || !tag || !output || !verifier) { + throw new Error("Usage: node scripts/create-update-feed.mjs "); + } + createUpdateFeed({ root, version, tag, output, verifier }); +} diff --git a/scripts/create-update-feed.node-test.mjs b/scripts/create-update-feed.node-test.mjs new file mode 100644 index 0000000..87b31cf --- /dev/null +++ b/scripts/create-update-feed.node-test.mjs @@ -0,0 +1,77 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { gzipSync } from "node:zlib"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createUpdateFeed } from "./create-update-feed.mjs"; + +const temporaryDirectories = []; +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +const fixtures = () => { + const root = mkdtempSync(join(tmpdir(), "switchify-feed-")); + temporaryDirectories.push(root); + const macDirectory = join(root, "macos-release"); + const windowsDirectory = join(root, "windows-release"); + mkdirSync(macDirectory); + mkdirSync(windowsDirectory); + const mac = join(macDirectory, "Switchify.PC.app.tar.gz"); + const windows = join(windowsDirectory, "Switchify.PC_1.0.0_x64-setup.exe"); + writeFileSync(mac, gzipSync("archive fixture")); + const pe = Buffer.alloc(128); + pe.write("MZ", 0, "ascii"); + pe.writeUInt32LE(64, 0x3c); + pe.write("PE\0\0", 64, "ascii"); + writeFileSync(windows, pe); + writeFileSync(`${mac}.sig`, "mac-signature"); + writeFileSync(`${windows}.sig`, "windows-signature"); + return { root, mac, windows, output: join(root, "latest.json") }; +}; + +const options = (fixture, verifySignature = () => {}) => ({ + ...fixture, + version: "1.0.0-beta.1", + tag: "v1.0.0-beta.1", + verifySignature, +}); + +describe("createUpdateFeed", () => { + it("publishes only structurally valid artifacts whose signatures verify", () => { + const fixture = fixtures(); + const verified = []; + const feed = createUpdateFeed(options(fixture, (artifact) => verified.push(artifact))); + assert.deepEqual(verified, [fixture.mac, fixture.windows]); + assert.equal(feed.platforms["darwin-aarch64"].signature, "mac-signature"); + assert.equal(feed.platforms["windows-x86_64"].signature, "windows-signature"); + }); + + it("refuses a non-empty signature that fails cryptographic verification", () => { + const fixture = fixtures(); + assert.throws( + () => createUpdateFeed(options(fixture, () => { throw new Error("invalid signature"); })), + /invalid signature/, + ); + assert.equal(existsSync(fixture.output), false); + }); + + it("refuses a wrong-format payload even if its signature verifier succeeds", () => { + const fixture = fixtures(); + writeFileSync(fixture.mac, "not an updater archive"); + assert.throws(() => createUpdateFeed(options(fixture)), /not a gzip archive/); + assert.equal(existsSync(fixture.output), false); + }); + + it("does not search outside the expected platform artifact directories", () => { + const fixture = fixtures(); + rmSync(fixture.mac); + rmSync(`${fixture.mac}.sig`); + const unexpectedDirectory = join(fixture.root, "renamed-download"); + mkdirSync(unexpectedDirectory); + writeFileSync(join(unexpectedDirectory, "Switchify.PC.app.tar.gz"), gzipSync("archive")); + writeFileSync(join(unexpectedDirectory, "Switchify.PC.app.tar.gz.sig"), "signature"); + assert.throws(() => createUpdateFeed(options(fixture)), /Expected one signed macOS artifact, found 0/); + }); +}); diff --git a/tools/updater-signature-verifier/Cargo.lock b/tools/updater-signature-verifier/Cargo.lock new file mode 100644 index 0000000..f052afd --- /dev/null +++ b/tools/updater-signature-verifier/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "switchify-updater-signature-verifier" +version = "0.1.0" +dependencies = [ + "base64", + "minisign-verify", +] diff --git a/tools/updater-signature-verifier/Cargo.toml b/tools/updater-signature-verifier/Cargo.toml new file mode 100644 index 0000000..070fc43 --- /dev/null +++ b/tools/updater-signature-verifier/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "switchify-updater-signature-verifier" +version = "0.1.0" +edition = "2021" +rust-version = "1.97.1" +publish = false + +[dependencies] +base64 = "0.22" +minisign-verify = "0.2.5" diff --git a/tools/updater-signature-verifier/src/main.rs b/tools/updater-signature-verifier/src/main.rs new file mode 100644 index 0000000..98ffb56 --- /dev/null +++ b/tools/updater-signature-verifier/src/main.rs @@ -0,0 +1,92 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use minisign_verify::{PublicKey, Signature}; +use std::{env, fs, fs::File, io::Read, path::PathBuf}; + +fn main() { + if let Err(error) = verify() { + eprintln!("Updater signature verification failed: {error}"); + std::process::exit(1); + } +} + +fn verify() -> Result<(), String> { + let mut arguments = env::args_os().skip(1); + let artifact = PathBuf::from(arguments.next().ok_or("missing artifact path")?); + let signature_path = PathBuf::from(arguments.next().ok_or("missing signature path")?); + if arguments.next().is_some() { + return Err("expected exactly an artifact and signature path".into()); + } + + let public_key_text = env::var("SWITCHIFY_UPDATER_PUBLIC_KEY") + .map_err(|_| "SWITCHIFY_UPDATER_PUBLIC_KEY is not configured")?; + let public_key = decode_public_key(&public_key_text)?; + let signature_text = fs::read_to_string(&signature_path) + .map_err(|error| format!("cannot read signature file: {error}"))?; + let signature = decode_signature(&signature_text)?; + let mut verifier = public_key + .verify_stream(&signature) + .map_err(|error| format!("unsupported updater signature: {error}"))?; + let mut file = + File::open(&artifact).map_err(|error| format!("cannot open artifact: {error}"))?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(|error| format!("cannot read artifact: {error}"))?; + if count == 0 { + break; + } + verifier.update(&buffer[..count]); + } + verifier + .finalize() + .map_err(|error| format!("signature does not match artifact: {error}")) +} + +fn decode_public_key(value: &str) -> Result { + PublicKey::decode(value.trim()) + .or_else(|_| PublicKey::from_base64(value.trim())) + .or_else(|_| { + let decoded = STANDARD + .decode(value.trim()) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + let text = std::str::from_utf8(&decoded) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + PublicKey::decode(text.trim()) + }) + .map_err(|error| format!("invalid updater public key: {error}")) +} + +fn decode_signature(value: &str) -> Result { + Signature::decode(value.trim()) + .or_else(|_| { + let decoded = STANDARD + .decode(value.trim()) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + let text = std::str::from_utf8(&decoded) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + Signature::decode(text.trim()) + }) + .map_err(|error| format!("invalid updater signature: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PUBLIC_KEY: &str = "untrusted comment: minisign public key E7620F1842B4E81F\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3"; + const SIGNATURE: &str = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg=="; + + #[test] + fn decodes_tauri_wrapped_key_and_signature() { + let public_key = decode_public_key(&STANDARD.encode(PUBLIC_KEY)).unwrap(); + let signature = decode_signature(&STANDARD.encode(SIGNATURE)).unwrap(); + public_key.verify(b"test", &signature, false).unwrap(); + } + + #[test] + fn rejects_invalid_wrapped_values() { + assert!(decode_public_key(&STANDARD.encode("not a public key")).is_err()); + assert!(decode_signature(&STANDARD.encode("not a signature")).is_err()); + } +}