diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 09fe7e49..737dbd89 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,6 +18,10 @@ on: permissions: contents: write +concurrency: + group: usagebar-publish + cancel-in-progress: false + env: BUN_VERSION: "1.3.13" RUST_TOOLCHAIN: "1.94.1" @@ -78,8 +82,14 @@ jobs: - name: Release preflight run: node ./scripts/release-preflight.mjs --release-tag "$RELEASE_TAG" + - name: Allow unsigned prerelease installer + if: runner.os == 'Windows' && contains(env.RELEASE_TAG, '-') + run: | + echo "USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1" >> "$GITHUB_ENV" + echo "Prerelease $RELEASE_TAG will publish an unsigned Windows installer until Authenticode signing is configured." + - name: Verify updater signing key is configured - if: runner.os == 'Windows' && !contains(env.RELEASE_TAG, '-') + if: runner.os == 'Windows' env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | @@ -88,11 +98,33 @@ jobs: exit 1 fi - - name: Allow unsigned prerelease updater artifacts - if: runner.os == 'Windows' && contains(env.RELEASE_TAG, '-') + - name: Verify Windows Authenticode signing secrets are configured + if: runner.os == 'Windows' + env: + WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }} + USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER: ${{ env.USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER }} run: | - echo "No Tauri updater signing key configured; publishing this technical-preview installer without updater signatures." - echo "USAGEBAR_ALLOW_UNSIGNED_UPDATER=1" >> "$GITHUB_ENV" + if [[ "$USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER" == "1" ]]; then + echo "Skipping Authenticode secret requirement for unsigned prerelease installer." + exit 0 + fi + + if [[ -n "$WINDOWS_CERTIFICATE_THUMBPRINT" ]]; then + exit 0 + fi + + if [[ -z "$WINDOWS_CERTIFICATE_BASE64" && -z "$WINDOWS_CERTIFICATE" ]]; then + echo "Missing Windows Authenticode certificate secret. Set WINDOWS_CERTIFICATE_BASE64." + exit 1 + fi + + if [[ -z "$WINDOWS_CERTIFICATE_PASSWORD" ]]; then + echo "Missing WINDOWS_CERTIFICATE_PASSWORD secret." + exit 1 + fi - uses: tauri-apps/tauri-action@v0 env: @@ -105,14 +137,14 @@ jobs: WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }} WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} - USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER: "1" + USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER: ${{ env.USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER }} with: tagName: ${{ env.RELEASE_TAG }} releaseName: ${{ env.RELEASE_TAG }} releaseDraft: false prerelease: ${{ contains(env.RELEASE_TAG, '-') }} includeUpdaterJson: true - args: ${{ matrix.args }} ${{ contains(env.RELEASE_TAG, '-') && '--no-sign' || '' }} + args: ${{ matrix.args }} - name: Verify updater assets uploaded env: @@ -120,12 +152,33 @@ jobs: run: | ASSETS=$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | tr -d '\r') - if [[ "$USAGEBAR_ALLOW_UNSIGNED_UPDATER" != "1" ]]; then - printf '%s\n' "$ASSETS" | grep -Fxq 'latest.json' || { echo "Missing updater manifest: latest.json"; exit 1; } - printf '%s\n' "$ASSETS" | grep -Eq '\.sig$' || { echo "Missing updater signatures (.sig)"; exit 1; } - else - echo "Unsigned prerelease: updater manifest and signature assets are not required." - fi + printf '%s\n' "$ASSETS" | grep -Fxq 'latest.json' || { echo "Missing updater manifest: latest.json"; exit 1; } + printf '%s\n' "$ASSETS" | grep -Eq '\.sig$' || { echo "Missing updater signatures (.sig)"; exit 1; } if [[ "$RUNNER_OS" == "Windows" ]]; then printf '%s\n' "$ASSETS" | grep -Eq 'setup\.exe$' || { echo "Missing Windows setup executable"; exit 1; } fi + + - name: Publish updater channel manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CHANNEL_TAG="updater" + CHANNEL_DIR="$RUNNER_TEMP/usagebar-updater-channel" + mkdir -p "$CHANNEL_DIR" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern latest.json --dir "$CHANNEL_DIR" + + if ! gh release view "$CHANNEL_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$CHANNEL_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --prerelease \ + --title "UsageBar updater channel" \ + --notes "Machine-readable updater metadata. Install UsageBar from the versioned releases." + fi + + gh release upload "$CHANNEL_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + "$CHANNEL_DIR/latest.json" \ + --clobber + + CHANNEL_ASSET=$(gh release view "$CHANNEL_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | tr -d '\r') + printf '%s\n' "$CHANNEL_ASSET" | grep -Fxq 'latest.json' || { echo "Updater channel is missing latest.json"; exit 1; } diff --git a/.gitignore b/.gitignore index 08902cf3..3d59d4ac 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ playwright-report/ .env* !.env.example +# Tauri updater signing keys +/usagebar.key +/usagebar.key.pub + # Agent working files docs/choices.md docs/breadcrumbs.md diff --git a/README.md b/README.md index 3c2d992a..641c81fe 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ UsageBar v0.1.1 is a Windows-first public release. - Windows is the primary tested platform for this fork. macOS and Linux remain secondary until the Windows release path is boring. - Provider coverage is uneven: `Supported` means the Windows path is intended to work; `Experimental` means setup, API shape, or live-account validation may still change. - Some providers report usage directly; others estimate from local history, known quota pools, telemetry logs, or manually supplied session cookies. Provider docs describe the source per integration. -- Signed updater metadata is the primary update path. UsageBar verifies the published asset digest, downloads the Windows installer, then restarts after the app exits. +- Published releases use a signed updater channel. UsageBar downloads an available update, then installs it after you select `Restart to update`. - Authenticode-signed Windows artifacts, live Edge-account validation, and full crash-recovery expectations remain future work. ## Architecture @@ -163,9 +163,9 @@ bun run release:check -- --release-tag v0.1.1 bun run build:release -- --bundles nsis ``` -If `TAURI_SIGNING_PRIVATE_KEY` is unset, the helper automatically adds `--no-sign` so the local build can skip Tauri updater signatures. Set `USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1` for local builds without an Authenticode certificate. The helper signs the final setup executable when Windows signing material exists. The setup executable lands under `src-tauri/target/release/bundle/nsis/`. +Release builds require `TAURI_SIGNING_PRIVATE_KEY`. Pass `--no-sign` only for an explicit local installer smoke build. Set `USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1` for local builds without an Authenticode certificate. The helper signs the final setup executable when Windows signing material exists. The setup executable lands under `src-tauri/target/release/bundle/nsis/`. -GitHub publishes unsigned Windows installers until the project gets an Authenticode certificate. These installers can show `Unknown publisher` and trigger Windows SmartScreen's "unrecognized app" warning. Stable releases still require signed Tauri updater metadata; see [docs/releasing.md](docs/releasing.md). +GitHub publishes unsigned Windows installers until the project gets an Authenticode certificate. These installers can show `Unknown publisher` and trigger Windows SmartScreen's "unrecognized app" warning. All published releases still require signed Tauri updater metadata; see [docs/releasing.md](docs/releasing.md). Before pushing a release tag, run the same preflight with `--require-clean` so the tag is cut from a clean worktree. diff --git a/docs/releasing.md b/docs/releasing.md index dd86cff7..82ab4d1c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,7 +20,7 @@ The preflight currently verifies: - `package.json`, [src-tauri/tauri.conf.json](../src-tauri/tauri.conf.json), and [src-tauri/Cargo.toml](../src-tauri/Cargo.toml) agree on the same version - the release tag matches that version -- the Tauri product branding and updater endpoint still point at `UsageBar` and `luisleineweber/usagebar` +- the Tauri product branding is `UsageBar` and the updater endpoint is the fixed signed channel at `https://github.com/luisleineweber/usagebar/releases/download/updater/latest.json` - [CHANGELOG.md](../CHANGELOG.md) contains a section for the version being released - bundled plugins exist under `src-tauri/resources/bundled_plugins` @@ -29,11 +29,12 @@ The preflight currently verifies: Build the Windows installer locally before the first publish of a version: ```powershell +$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content .\\usagebar.key -Raw $env:USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER = "1" bun run build:release -- --bundles nsis ``` -If `TAURI_SIGNING_PRIVATE_KEY` is unset, the helper automatically adds `--no-sign` so local builds can complete without Tauri updater signatures. Local Windows builds need an explicit unsigned-build opt-in when no Authenticode material exists. GitHub publishes set this option for prerelease and stable tags. Unsigned artifacts can show `Unknown publisher` and can trigger Windows SmartScreen's "unrecognized app" warning. +Release builds require `TAURI_SIGNING_PRIVATE_KEY`. Pass `--no-sign` only for an explicit local installer smoke build that will not support in-app updates. Local Windows builds need an explicit unsigned-build opt-in when no Authenticode material exists. GitHub prerelease publishes set this option automatically. Unsigned artifacts can show `Unknown publisher` and can trigger Windows SmartScreen's "unrecognized app" warning. ## Windows Code Signing @@ -71,13 +72,15 @@ The workflow runs the same release preflight, builds platform artifacts, and ver - a Windows setup executable ending in `setup.exe` -Stable releases require `TAURI_SIGNING_PRIVATE_KEY` and updater signature assets. For prerelease tags, the workflow passes `--no-sign` and publishes without updater assets. All Windows publishes allow an unsigned installer while Authenticode signing remains unavailable. +All published releases require `TAURI_SIGNING_PRIVATE_KEY`, `latest.json`, and updater signature assets. Prerelease tags still allow an unsigned Windows installer while Authenticode signing remains unavailable. -Current updater channel note: +The release workflow copies the signed `latest.json` from each versioned release to the fixed `updater` release. The app reads that channel, downloads first, and installs only after you select `Restart to update`. -- Signed Tauri updater metadata is the primary update path. -- GitHub's `releases/latest` alias does not resolve prereleases, so UsageBar queries the release API when a prerelease has no signed updater metadata. -- The Windows fallback accepts only the exact `UsageBar__x64-setup.exe` asset and verifies GitHub's SHA-256 digest before installation. +Current updater channel: + +- GitHub's `releases/latest` alias does not resolve prereleases. +- The publish workflow copies each signed `latest.json` to the fixed `updater` release. +- UsageBar reads `https://github.com/luisleineweber/usagebar/releases/download/updater/latest.json`. ## Release Gate @@ -111,7 +114,7 @@ This is a public alpha for Windows users who want to test UsageBar before a full - Some providers are experimental and may need manual cookie/API-key setup - Some costs or usage buckets may be estimated or partial -- Prerelease updates may use the GitHub installer fallback when signed updater metadata is unavailable +- Prerelease updates use the signed updater channel - UI polish, crash recovery, and signed-build coverage are not final ### Privacy diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index e0bc27c6..3b0b881a 100644 --- a/scripts/build-release.mjs +++ b/scripts/build-release.mjs @@ -46,21 +46,23 @@ if (signingKeyValue && existsSync(signingKeyValue)) { const resolvedArgs = [...args] if (!env.TAURI_SIGNING_PRIVATE_KEY && !resolvedArgs.includes("--no-sign")) { - resolvedArgs.push("--no-sign") - console.log("No TAURI_SIGNING_PRIVATE_KEY found; building without Tauri updater signatures.") + console.error( + "Missing TAURI_SIGNING_PRIVATE_KEY. Set the updater signing key or pass --no-sign for an explicit installer-only smoke build." + ) + process.exit(1) } function hasWindowsSigningMaterial() { return Boolean( - env.WINDOWS_CERTIFICATE_THUMBPRINT || - env.WINDOWS_CERTIFICATE_BASE64 || - env.WINDOWS_CERTIFICATE + env.WINDOWS_CERTIFICATE_THUMBPRINT || env.WINDOWS_CERTIFICATE_BASE64 || env.WINDOWS_CERTIFICATE ) } function allowsUnsignedWindowsInstaller() { - return env.USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER === "1" || + return ( + env.USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER === "1" || env.USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER?.toLowerCase() === "true" + ) } function requestsWindowsInstaller() { @@ -128,19 +130,23 @@ function signWindowsInstallerArtifacts(artifactDirs) { console.log("Signing Windows installer artifacts after build:") for (const artifact of artifacts) { - const signer = spawnSync("powershell", [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - windowsSignScript, - "-TargetPath", - artifact, - ], { - cwd: repoRoot, - env, - stdio: "inherit", - }) + const signer = spawnSync( + "powershell", + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + windowsSignScript, + "-TargetPath", + artifact, + ], + { + cwd: repoRoot, + env, + stdio: "inherit", + } + ) if (signer.error) { console.error("Failed to launch Windows installer signing:", signer.error) @@ -194,7 +200,6 @@ child.on("exit", (code, signal) => { process.exit(code ?? 0) }) - child.on("error", (error) => { console.error("Failed to launch Tauri release build:", error) process.exit(1) diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index 6136c620..fe3522d7 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -80,7 +80,9 @@ if (!semverPattern.test(version)) { } if (tauriConf.version !== version) { - fail(`src-tauri/tauri.conf.json version (${tauriConf.version}) does not match package.json (${version})`) + fail( + `src-tauri/tauri.conf.json version (${tauriConf.version}) does not match package.json (${version})` + ) } if (cargoVersion !== version) { @@ -102,8 +104,18 @@ if (tauriConf.productName !== "UsageBar") { } const updaterEndpoints = tauriConf.plugins?.updater?.endpoints ?? [] -if (!updaterEndpoints.some((endpoint) => String(endpoint).includes("github.com/luisleineweber/usagebar/releases"))) { - fail("Updater endpoint is not pointed at luisleineweber/usagebar releases") +const expectedUpdaterEndpoint = + "https://github.com/luisleineweber/usagebar/releases/download/updater/latest.json" +if (updaterEndpoints.length !== 1 || updaterEndpoints[0] !== expectedUpdaterEndpoint) { + fail(`Updater endpoint must be ${expectedUpdaterEndpoint}`) +} + +if (tauriConf.bundle?.createUpdaterArtifacts !== true) { + fail("Tauri updater artifacts must be enabled") +} + +if (!tauriConf.plugins?.updater?.pubkey) { + fail("Tauri updater public key is missing") } if (!changelog.includes(`## ${version}`)) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e400291a..568c6ba9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5521,7 +5521,6 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", "tauri", "tauri-build", "tauri-plugin-aptabase", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e647712..41d78b48 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,7 +34,6 @@ reqwest = { version = "0.13", features = ["blocking"] } rquickjs = { version = "0.11" } tauri-plugin-store = "2.4.2" base64 = "0.22" -sha2 = "0.10" aes-gcm = "0.10" uuid = { version = "1", features = ["v4"] } tauri-plugin-log = "2" diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs deleted file mode 100644 index 675a7be0..00000000 --- a/src-tauri/src/app_update.rs +++ /dev/null @@ -1,380 +0,0 @@ -#[cfg(target_os = "windows")] -use std::path::{Path, PathBuf}; -use std::time::Duration; - -#[cfg(target_os = "windows")] -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use tauri::{AppHandle, Manager}; - -const GITHUB_RELEASES_API: &str = - "https://api.github.com/repos/luisleineweber/usagebar/releases/tags"; -const GITHUB_DOWNLOAD_PREFIX: &str = - "https://github.com/luisleineweber/usagebar/releases/download/"; -const UPDATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60); -const MAX_INSTALLER_BYTES: usize = 256 * 1024 * 1024; - -#[derive(Debug, Deserialize)] -struct GithubRelease { - draft: bool, - assets: Vec, -} - -#[derive(Debug, Deserialize)] -struct GithubAsset { - name: String, - browser_download_url: String, - digest: Option, -} - -#[tauri::command] -pub async fn download_github_update(app: AppHandle, version: String) -> Result { - let version = normalize_version(&version)?; - let tag = format!("v{version}"); - let client = build_update_client(UPDATE_REQUEST_TIMEOUT)?; - let release_url = format!("{GITHUB_RELEASES_API}/{tag}"); - let response = client - .get(release_url) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") - .header(reqwest::header::USER_AGENT, format!("UsageBar/{version}")) - .send() - .await - .map_err(|error| format!("GitHub release lookup failed: {error}"))?; - - if !response.status().is_success() { - return Err(format!( - "GitHub release lookup failed with {}", - response.status() - )); - } - - let release = response - .json::() - .await - .map_err(|error| format!("GitHub release metadata is invalid: {error}"))?; - if release.draft { - return Err("GitHub release is still a draft".to_string()); - } - - let asset = release - .assets - .into_iter() - .find(|asset| is_windows_setup_asset(&asset.name, &version)) - .ok_or_else(|| format!("No Windows installer found for {tag}"))?; - - if !asset - .browser_download_url - .starts_with(&format!("{GITHUB_DOWNLOAD_PREFIX}{tag}/")) - { - return Err("GitHub installer URL is not trusted".to_string()); - } - - let installer_response = client - .get(&asset.browser_download_url) - .header(reqwest::header::USER_AGENT, format!("UsageBar/{version}")) - .send() - .await - .map_err(|error| format!("Update download failed: {error}"))?; - - if !installer_response.status().is_success() { - return Err(format!( - "Update download failed with {}", - installer_response.status() - )); - } - - let installer_bytes = read_installer_response(installer_response, MAX_INSTALLER_BYTES).await?; - verify_digest(&asset, &installer_bytes)?; - - let temp_dir = app - .path() - .temp_dir() - .map_err(|error| format!("Could not resolve the update directory: {error}"))?; - std::fs::create_dir_all(&temp_dir) - .map_err(|error| format!("Could not create the update directory: {error}"))?; - let installer_path = temp_dir.join(&asset.name); - std::fs::write(&installer_path, &installer_bytes) - .map_err(|error| format!("Could not save the update installer: {error}"))?; - - Ok(installer_path.to_string_lossy().into_owned()) -} - -fn build_update_client(timeout: Duration) -> Result { - reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|error| format!("Could not configure the update client: {error}")) -} - -async fn read_installer_response( - mut response: reqwest::Response, - max_bytes: usize, -) -> Result, String> { - if response - .content_length() - .is_some_and(|length| length > max_bytes as u64) - { - return Err(format!( - "Update installer exceeds the {} MiB size limit", - max_bytes / (1024 * 1024) - )); - } - - let capacity = response - .content_length() - .and_then(|length| usize::try_from(length).ok()) - .unwrap_or(0) - .min(max_bytes); - let mut bytes = Vec::with_capacity(capacity); - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| format!("Update download failed: {error}"))? - { - if chunk.len() > max_bytes.saturating_sub(bytes.len()) { - return Err(format!( - "Update installer exceeds the {} MiB size limit", - max_bytes / (1024 * 1024) - )); - } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) -} - -#[tauri::command] -pub fn install_downloaded_update(app: AppHandle, installer_path: String) -> Result<(), String> { - #[cfg(not(target_os = "windows"))] - { - let _ = (app, installer_path); - return Err("Direct GitHub installer updates are supported on Windows only".to_string()); - } - - #[cfg(target_os = "windows")] - { - let installer_path = validate_installer_path(&app, &installer_path)?; - let app_path = std::env::current_exe() - .map_err(|error| format!("Could not resolve the current app path: {error}"))?; - let script = format!( - "$installer = '{}'; $app = '{}'; $appPid = {}; while (Get-Process -Id $appPid -ErrorAction SilentlyContinue) {{ Start-Sleep -Milliseconds 100 }}; $process = Start-Process -FilePath $installer -ArgumentList '/S' -PassThru -WindowStyle Hidden; $process.WaitForExit(); if ($process.ExitCode -eq 0) {{ Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue; Start-Process -FilePath $app -WindowStyle Hidden }}", - powershell_literal(&installer_path), - powershell_literal(&app_path), - std::process::id() - ); - let encoded_script = encode_powershell_script(&script); - - std::process::Command::new("powershell.exe") - .args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden"]) - .arg("-EncodedCommand") - .arg(&encoded_script) - .spawn() - .map_err(|error| format!("Could not start the update installer: {error}"))?; - - app.exit(0); - Ok(()) - } -} - -fn normalize_version(version: &str) -> Result { - let normalized = version - .trim() - .strip_prefix('v') - .or_else(|| version.trim().strip_prefix('V')) - .unwrap_or(version.trim()); - if normalized.is_empty() - || !normalized - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '-')) - || !normalized - .chars() - .next() - .is_some_and(|character| character.is_ascii_digit()) - { - return Err("Update version is invalid".to_string()); - } - Ok(normalized.to_string()) -} - -fn is_windows_setup_asset(name: &str, version: &str) -> bool { - name == format!("UsageBar_{version}_x64-setup.exe") -} - -fn verify_digest(asset: &GithubAsset, bytes: &[u8]) -> Result<(), String> { - let Some(expected) = asset.digest.as_deref() else { - return Err("GitHub release has no SHA-256 installer digest".to_string()); - }; - let Some(expected) = expected.strip_prefix("sha256:") else { - return Err("GitHub installer digest uses an unsupported algorithm".to_string()); - }; - let actual = format!("{:x}", Sha256::digest(bytes)); - if actual != expected { - return Err("Downloaded installer failed its GitHub digest check".to_string()); - } - Ok(()) -} - -#[cfg(target_os = "windows")] -fn validate_installer_path(app: &AppHandle, installer_path: &str) -> Result { - let temp_dir = app - .path() - .temp_dir() - .map_err(|error| format!("Could not resolve the update directory: {error}"))?; - let canonical_temp_dir = temp_dir - .canonicalize() - .map_err(|error| format!("Could not resolve the update directory: {error}"))?; - let path = PathBuf::from(installer_path); - let canonical_path = path - .canonicalize() - .map_err(|error| format!("Could not resolve the downloaded installer: {error}"))?; - - if !canonical_path.starts_with(&canonical_temp_dir) { - return Err("Downloaded installer is outside the update directory".to_string()); - } - let Some(file_name) = canonical_path.file_name().and_then(|value| value.to_str()) else { - return Err("Downloaded installer has no valid file name".to_string()); - }; - if !file_name.starts_with("UsageBar_") || !file_name.ends_with("_x64-setup.exe") { - return Err("Downloaded installer name is invalid".to_string()); - } - Ok(canonical_path) -} - -#[cfg(target_os = "windows")] -fn powershell_literal(path: &Path) -> String { - path.to_string_lossy().replace('\'', "''") -} - -#[cfg(target_os = "windows")] -fn encode_powershell_script(script: &str) -> String { - let bytes = script - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); - BASE64.encode(bytes) -} - -#[cfg(test)] -mod tests { - use std::{ - io::{BufRead, BufReader, Write}, - net::TcpListener, - thread, - time::Duration, - }; - - use sha2::{Digest, Sha256}; - - use super::{ - GithubAsset, build_update_client, is_windows_setup_asset, normalize_version, - read_installer_response, verify_digest, - }; - - fn serve_once(response: &'static [u8], delay: Duration) -> String { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut reader = BufReader::new(&mut stream); - loop { - let mut line = String::new(); - let read = reader.read_line(&mut line).unwrap(); - assert_ne!(read, 0, "client closed before sending request headers"); - if line == "\r\n" { - break; - } - } - drop(reader); - thread::sleep(delay); - let _ = stream.write_all(response); - }); - format!("http://{address}") - } - - #[test] - fn normalizes_release_versions() { - assert_eq!( - normalize_version("v0.1.0-alpha.9").unwrap(), - "0.1.0-alpha.9" - ); - } - - #[test] - fn rejects_path_like_versions() { - assert!(normalize_version("../installer.exe").is_err()); - } - - #[test] - fn selects_only_the_usagebar_x64_setup_asset() { - assert!(is_windows_setup_asset( - "UsageBar_0.1.0-alpha.9_x64-setup.exe", - "0.1.0-alpha.9" - )); - assert!(!is_windows_setup_asset( - "UsageBar_0.1.0-alpha.9_x64-setup.exe.sig", - "0.1.0-alpha.9" - )); - } - - #[test] - fn verifies_the_published_installer_digest() { - let bytes = b"installer"; - let asset = GithubAsset { - name: "UsageBar_0.1.0-alpha.9_x64-setup.exe".to_string(), - browser_download_url: String::new(), - digest: Some(format!("sha256:{:x}", Sha256::digest(bytes))), - }; - - assert!(verify_digest(&asset, bytes).is_ok()); - assert!(verify_digest(&asset, b"tampered").is_err()); - } - - #[tokio::test] - async fn rejects_an_installer_that_exceeds_the_streamed_size_limit() { - let url = serve_once( - b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\ninstaller", - Duration::ZERO, - ); - let response = build_update_client(Duration::from_secs(1)) - .unwrap() - .get(url) - .send() - .await - .unwrap(); - - let error = read_installer_response(response, 8).await.unwrap_err(); - - assert!(error.contains("size limit")); - } - - #[tokio::test] - async fn rejects_an_installer_with_an_excessive_content_length() { - let url = serve_once( - b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n", - Duration::ZERO, - ); - let response = build_update_client(Duration::from_secs(1)) - .unwrap() - .get(url) - .send() - .await - .unwrap(); - - let error = read_installer_response(response, 8).await.unwrap_err(); - - assert!(error.contains("size limit")); - } - - #[tokio::test] - async fn update_client_times_out_a_stalled_request() { - let url = serve_once( - b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - Duration::from_millis(250), - ); - let client = build_update_client(Duration::from_millis(25)).unwrap(); - - let error = client.get(url).send().await.unwrap_err(); - - assert!(error.is_timeout()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b55eff4..30b410a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,6 @@ mod analytics; mod app_nap; #[cfg(not(test))] mod app_startup; -mod app_update; mod atomic_file; mod browser_cookie_import; pub mod cli; @@ -60,8 +59,6 @@ use tauri::{Emitter, Manager}; #[cfg(not(test))] use tauri_plugin_log::{Target, TargetKind}; -#[cfg(not(test))] -use app_update::{download_github_update, install_downloaded_update}; #[cfg(not(test))] use credential_commands::{ capture_provider_cookie_header, delete_codex_account_profile, delete_provider_account_profile, @@ -253,8 +250,6 @@ pub fn run() { import_current_provider_account_profile, delete_provider_account_profile, update_global_shortcut, - download_github_update, - install_downloaded_update, ]) .setup(app_startup::setup) .build(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5fc85803..3702e00b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -80,9 +80,9 @@ }, "plugins": { "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDExNzJBMkE4RkRCMjBDMUYKUldRZkRMTDlxS0p5RWFCU0hxMmZ4bUhMUk5aNERSV3I4eHZoei9uT1NkNnVvZnlvUUVzblVvR00K", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDA0OEFBMjdENzUyN0I2MkMKUldRc3RpZDFmYUtLQk5tRFFVbkF4NS9ha2dvdCtkV3AwWVNUK092ZEtGazNSRnlYMEVsRVJWdVoK", "endpoints": [ - "https://github.com/luisleineweber/usagebar/releases/latest/download/latest.json" + "https://github.com/luisleineweber/usagebar/releases/download/updater/latest.json" ] } } diff --git a/src/hooks/use-app-update.test.ts b/src/hooks/use-app-update.test.ts index 670f6a89..32f19467 100644 --- a/src/hooks/use-app-update.test.ts +++ b/src/hooks/use-app-update.test.ts @@ -2,22 +2,19 @@ import { renderHook, act, waitFor } from "@testing-library/react" import { describe, expect, it, vi, beforeEach, afterAll } from "vitest" import type { DownloadEvent, Update } from "@tauri-apps/plugin-updater" -const { checkMock, invokeMock, relaunchMock } = vi.hoisted(() => ({ +const { checkMock, relaunchMock } = vi.hoisted(() => ({ checkMock: vi.fn(), - invokeMock: vi.fn(), relaunchMock: vi.fn(), })) const { getVersionMock } = vi.hoisted(() => ({ getVersionMock: vi.fn(), })) -vi.mock("@tauri-apps/api/app", () => ({ - getVersion: getVersionMock, -})) - vi.mock("@tauri-apps/api/core", () => ({ - invoke: invokeMock, isTauri: () => Boolean(globalThis.isTauri), })) +vi.mock("@tauri-apps/api/app", () => ({ + getVersion: getVersionMock, +})) vi.mock("@tauri-apps/plugin-updater", () => ({ check: checkMock, @@ -39,23 +36,13 @@ describe("useAppUpdate", () => { beforeEach(() => { checkMock.mockReset() - invokeMock.mockReset() - invokeMock.mockResolvedValue("C:\\Users\\test\\AppData\\Local\\Temp\\UsageBar_update.exe") relaunchMock.mockReset() getVersionMock.mockReset() getVersionMock.mockResolvedValue("1.0.0") checkMock.mockResolvedValue(null) - vi.stubGlobal( - "fetch", - vi.fn(async () => ({ - ok: true, - json: async () => [], - })) - ) // `@tauri-apps/api/core` considers `globalThis.isTauri` the runtime flag. globalThis.isTauri = true - }) - +}) afterAll(() => { vi.unstubAllGlobals() if (originalIsTauri === undefined) { @@ -128,108 +115,17 @@ describe("useAppUpdate", () => { }) }) - it("uses GitHub releases as a fallback for prerelease versions without updater metadata", async () => { - getVersionMock.mockResolvedValue("0.1.0-beta.5") - checkMock.mockResolvedValue(null) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - draft: false, - tag_name: "v0.1.0-beta.6", - html_url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-beta.6", - }, - ], - } as Response) - - const { result } = renderHook(() => useAppUpdate({ isDev: false })) - await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) - - expect(checkMock).toHaveBeenCalled() - expect(result.current.updateStatus).toEqual({ - status: "available", - version: "0.1.0-beta.6", - url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-beta.6", - }) - }) - - it("uses GitHub releases when the unsigned prerelease updater check rejects", async () => { + it("reports updater metadata failures instead of opening GitHub", async () => { getVersionMock.mockResolvedValue("0.1.0-alpha.7") checkMock.mockRejectedValueOnce(new Error("latest.json returned 404")) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - draft: false, - prerelease: true, - tag_name: "v0.1.0-alpha.8", - html_url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-alpha.8", - }, - ], - } as Response) const { result } = renderHook(() => useAppUpdate({ isDev: false })) - await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) + await waitFor(() => expect(result.current.updateStatus.status).toBe("error")) expect(result.current.updateStatus).toEqual({ - status: "available", - version: "0.1.0-alpha.8", - url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-alpha.8", - }) - }) - - it("downloads a GitHub prerelease and waits for the restart action", async () => { - getVersionMock.mockResolvedValue("0.1.0-alpha.7") - checkMock.mockRejectedValueOnce(new Error("latest.json returned 404")) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - draft: false, - prerelease: true, - tag_name: "v0.1.0-alpha.8", - html_url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-alpha.8", - }, - ], - } as Response) - invokeMock - .mockResolvedValueOnce("C:\\Users\\test\\AppData\\Local\\Temp\\UsageBar_update.exe") - .mockResolvedValueOnce(undefined) - - const { result } = renderHook(() => useAppUpdate({ isDev: false })) - await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) - - await act(() => result.current.triggerInstall()) - expect(invokeMock).toHaveBeenCalledWith("download_github_update", { - version: "0.1.0-alpha.8", + status: "error", + message: "Update check failed", }) - expect(result.current.updateStatus).toEqual({ status: "ready" }) - - await act(() => result.current.triggerInstall()) - expect(invokeMock).toHaveBeenLastCalledWith("install_downloaded_update", { - installerPath: "C:\\Users\\test\\AppData\\Local\\Temp\\UsageBar_update.exe", - }) - }) - - it("stays up-to-date when GitHub confirms an unsigned prerelease is current", async () => { - getVersionMock.mockResolvedValue("0.1.0-alpha.7") - checkMock.mockRejectedValueOnce(new Error("latest.json returned 404")) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - draft: false, - prerelease: true, - tag_name: "v0.1.0-alpha.7", - html_url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-alpha.7", - }, - ], - } as Response) - - const { result } = renderHook(() => useAppUpdate({ isDev: false })) - await waitFor(() => expect(result.current.updateStatus.status).toBe("up-to-date")) - - expect(result.current.updateStatus).toEqual({ status: "up-to-date" }) }) it("clears a pending up-to-date timeout on re-check", async () => { @@ -266,17 +162,18 @@ describe("useAppUpdate", () => { vi.useRealTimers() }) - it("waits for user action before downloading and installing a signed Tauri update", async () => { + it("downloads first and installs only after a second user action", async () => { const downloadMock = vi.fn(async (onEvent: (event: DownloadEvent) => void) => { onEvent({ event: "Started", data: { contentLength: 1000 } }) onEvent({ event: "Progress", data: { chunkLength: 500 } }) onEvent({ event: "Progress", data: { chunkLength: 500 } }) onEvent({ event: "Finished", data: {} }) }) + const installMock = vi.fn().mockResolvedValue(undefined) checkMock.mockResolvedValue({ version: "1.1.0", download: downloadMock, - install: vi.fn(), + install: installMock, }) relaunchMock.mockResolvedValue(undefined) @@ -288,35 +185,16 @@ describe("useAppUpdate", () => { await act(() => result.current.triggerInstall()) expect(downloadMock).toHaveBeenCalled() + expect(installMock).not.toHaveBeenCalled() expect(relaunchMock).not.toHaveBeenCalled() expect(result.current.updateStatus).toEqual({ status: "ready" }) await act(() => result.current.triggerInstall()) + expect(installMock).toHaveBeenCalled() expect(relaunchMock).toHaveBeenCalled() expect(result.current.updateStatus).toEqual({ status: "idle" }) }) - it("ignores older same-core beta releases for an alpha build", async () => { - getVersionMock.mockResolvedValue("0.1.0-alpha.1") - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - draft: false, - tag_name: "v0.1.0-beta.6", - html_url: "https://github.com/luisleineweber/usagebar/releases/tag/v0.1.0-beta.6", - }, - ], - } as Response) - - const { result } = renderHook(() => useAppUpdate({ isDev: false })) - - await waitFor(() => expect(result.current.updateStatus.status).toBe("up-to-date")) - - expect(checkMock).toHaveBeenCalled() - expect(result.current.updateStatus).toEqual({ status: "up-to-date" }) - }) - it("ignores signed updater candidates that are equal to the current version", async () => { getVersionMock.mockResolvedValue("1.0.0") checkMock.mockResolvedValue({ version: "1.0.0", download: vi.fn(), install: vi.fn() }) @@ -337,18 +215,6 @@ describe("useAppUpdate", () => { expect(result.current.updateStatus).toEqual({ status: "up-to-date" }) }) - it("keeps stable signed updater checks up-to-date when GitHub fallback fails", async () => { - getVersionMock.mockResolvedValue("1.0.0") - checkMock.mockResolvedValue(null) - vi.mocked(fetch).mockRejectedValueOnce(new Error("rate limited")) - - const { result } = renderHook(() => useAppUpdate({ isDev: false })) - await waitFor(() => expect(result.current.updateStatus.status).toBe("up-to-date")) - - expect(checkMock).toHaveBeenCalled() - expect(result.current.updateStatus).toEqual({ status: "up-to-date" }) - }) - it("does not check again when an update is already available", async () => { const downloadMock = vi.fn(async (onEvent: (event: DownloadEvent) => void) => { onEvent({ event: "Finished", data: {} }) @@ -385,7 +251,6 @@ describe("useAppUpdate", () => { it("transitions to error when check throws", async () => { checkMock.mockRejectedValue(new Error("network error")) - vi.mocked(fetch).mockRejectedValueOnce(new Error("GitHub unavailable")) const { result } = renderHook(() => useAppUpdate({ isDev: false })) await act(() => Promise.resolve()) await act(() => Promise.resolve()) @@ -421,7 +286,7 @@ describe("useAppUpdate", () => { }) }) - it("transitions to error on download or install failure", async () => { + it("returns to an actionable update after a download failure", async () => { const downloadMock = vi.fn().mockRejectedValue(new Error("download failed")) checkMock.mockResolvedValue({ version: "1.1.0", @@ -440,7 +305,7 @@ describe("useAppUpdate", () => { }) }) - it("downloads, installs, and relaunches from the available state", async () => { + it("keeps the downloaded update ready until restart", async () => { const downloadMock = vi.fn(async (onEvent: (event: DownloadEvent) => void) => { onEvent({ event: "Finished", data: {} }) }) diff --git a/src/hooks/use-app-update.ts b/src/hooks/use-app-update.ts index d084c12a..1af9714e 100644 --- a/src/hooks/use-app-update.ts +++ b/src/hooks/use-app-update.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react" import { getVersion } from "@tauri-apps/api/app" -import { invoke, isTauri } from "@tauri-apps/api/core" +import { isTauri } from "@tauri-apps/api/core" import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater" import { relaunch } from "@tauri-apps/plugin-process" @@ -8,7 +8,7 @@ export type UpdateStatus = | { status: "idle" } | { status: "checking" } | { status: "up-to-date" } - | { status: "available"; version: string; url?: string; error?: string } + | { status: "available"; version: string; error?: string } | { status: "downloading"; progress: number } // 0-100, or -1 if indeterminate | { status: "installing" } | { status: "ready" } @@ -20,16 +20,8 @@ interface UseAppUpdateReturn { triggerInstall: () => void checkForUpdates: () => void } - interface UseAppUpdateOptions { isDev?: boolean - repo?: string -} - -const DEFAULT_RELEASE_REPO = "luisleineweber/usagebar" - -export function isPrereleaseVersion(version: string): boolean { - return version.trim().includes("-") } function normalizeVersion(version: string): string { @@ -110,49 +102,11 @@ export function isEligibleUpdateCandidate( return compareVersions(candidateVersion, currentVersion) > 0 } -type GitHubRelease = { - tag_name?: string - html_url?: string - draft?: boolean -} - -type GitHubReleaseCandidate = { - version: string - url: string -} - -async function findNewerGitHubRelease( - repo: string, - currentVersion: string -): Promise { - const response = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=20`, { - headers: { Accept: "application/vnd.github+json" }, - }) - if (!response.ok) { - throw new Error(`GitHub release check failed with ${response.status}`) - } - - const releases = (await response.json()) as GitHubRelease[] - return ( - releases - .filter((release) => !release.draft && release.tag_name && release.html_url) - .map((release) => ({ - version: normalizeVersion(release.tag_name ?? ""), - url: release.html_url ?? "", - })) - .filter((release) => isEligibleUpdateCandidate(release.version, currentVersion)) - .sort((left, right) => compareVersions(right.version, left.version))[0] ?? null - ) -} - export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateReturn { const isDev = options.isDev ?? import.meta.env.DEV - const repo = options.repo ?? DEFAULT_RELEASE_REPO const [updateStatus, setUpdateStatus] = useState({ status: "idle" }) const statusRef = useRef({ status: "idle" }) const updateRef = useRef(null) - const externalReleaseUrlRef = useRef(null) - const downloadedInstallerPathRef = useRef(null) const currentVersionRef = useRef(null) const mountedRef = useRef(true) const inFlightRef = useRef({ checking: false, downloading: false, installing: false }) @@ -239,32 +193,18 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet setStatus({ status: "checking" }) try { const currentVersion = await getCurrentVersion() - let update: Update | null = null let canUseSignedUpdater = updaterEnabledRef.current - let signedUpdaterCheckFailed = false if (!updaterEligibilityResolvedRef.current) { canUseSignedUpdater = await resolveUpdaterEligibility() } - if (canUseSignedUpdater) { - try { - update = await check() - } catch (error) { - signedUpdaterCheckFailed = true - // Prerelease builds may intentionally be published without signed - // updater artifacts. In that case the GitHub release API below is - // still authoritative for discovering the next prerelease. - console.warn("Signed updater check failed; trying GitHub release fallback:", error) - } - } + const update = canUseSignedUpdater ? await check() : null if (!mountedRef.current) return if (update) { const updateVersion = normalizeVersion(update.version) if (isEligibleUpdateCandidate(updateVersion, currentVersion)) { inFlightRef.current.checking = false updateRef.current = update - externalReleaseUrlRef.current = null - downloadedInstallerPathRef.current = null setStatus({ status: "available", version: updateVersion }) return } @@ -273,31 +213,7 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet ) } - let release: GitHubReleaseCandidate | null = null - try { - release = await findNewerGitHubRelease(repo, currentVersion) - } catch (error) { - if (canUseSignedUpdater) { - if (signedUpdaterCheckFailed) { - throw error - } - console.warn("GitHub release fallback failed after signed updater check:", error) - } else { - throw error - } - } - if (!mountedRef.current) return inFlightRef.current.checking = false - if (release) { - updateRef.current = null - externalReleaseUrlRef.current = release.url - downloadedInstallerPathRef.current = null - setStatus({ status: "available", version: release.version, url: release.url }) - return - } - - // A successful GitHub response is authoritative even when the signed - // updater could not load prerelease metadata. setUpToDateThenIdle() } catch (err) { inFlightRef.current.checking = false @@ -308,7 +224,6 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet }, [ getCurrentVersion, isDev, - repo, resolveUpdaterEligibility, setStatus, setUnavailableThenIdle, @@ -345,40 +260,13 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet const triggerInstall = useCallback(async () => { const update = updateRef.current - const releaseUrl = externalReleaseUrlRef.current + if (!update) return if (statusRef.current.status === "available") { if (inFlightRef.current.downloading || inFlightRef.current.installing) return - const availableVersion = statusRef.current.version inFlightRef.current.downloading = true setStatus({ status: "downloading", progress: -1 }) - if (releaseUrl) { - try { - downloadedInstallerPathRef.current = await invoke("download_github_update", { - version: availableVersion, - }) - setStatus({ status: "ready" }) - } catch (err) { - console.error("GitHub update download failed:", err) - setStatus({ - status: "available", - version: availableVersion, - url: releaseUrl, - error: "Download failed", - }) - } finally { - inFlightRef.current.downloading = false - } - return - } - - if (!update) { - inFlightRef.current.downloading = false - setStatus({ status: "error", message: "Update is no longer available" }) - return - } - let totalBytes: number | null = null let downloadedBytes = 0 const onDownloadEvent = (event: DownloadEvent) => { @@ -396,7 +284,7 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet const pct = Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) setStatus({ status: "downloading", progress: pct }) } - } else if (event.event === "Finished") { + } else if (event.event === "Finished" && totalBytes) { setStatus({ status: "downloading", progress: 100 }) } } @@ -406,7 +294,11 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet setStatus({ status: "ready" }) } catch (err) { console.error("Update download failed:", err) - setStatus({ status: "available", version: update.version, error: "Download failed" }) + setStatus({ + status: "available", + version: normalizeVersion(update.version), + error: "Download failed", + }) } finally { inFlightRef.current.downloading = false } @@ -419,18 +311,8 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet try { inFlightRef.current.installing = true setStatus({ status: "installing" }) - const downloadedInstallerPath = downloadedInstallerPathRef.current - if (downloadedInstallerPath) { - await invoke("install_downloaded_update", { installerPath: downloadedInstallerPath }) - downloadedInstallerPathRef.current = null - } else { - if (!update) { - setStatus({ status: "error", message: "Downloaded update is no longer available" }) - return - } - await update.install() - await relaunch() - } + await update.install() + await relaunch() setStatus({ status: "idle" }) } catch (err) { console.error("Update install failed:", err) diff --git a/tasks/lessons.md b/tasks/lessons.md index e07666c3..34bc412f 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,5 +1,13 @@ # Lessons +## 2026-08-28 + +- A fixed `releases/latest` updater endpoint cannot discover prerelease releases. Fix: publish signed `latest.json` to a fixed `updater` channel and use the signed download-then-restart flow for every release. Prevention: test prerelease discovery, signature assets, and both update actions. + +## 2026-08-24 + +- The updater requested signed `latest.json` metadata every 15 minutes, even when no release was newer. Fix: query the GitHub Releases API first, load signed metadata only for a newer stable release, and poll once per day. Prevention: test the no-update path and the polling boundary without allowing `latest.json` requests. + ## 2026-08-20 - The stable publish workflow used a Tauri private key that did not match the repository public key. Fix: generate one key pair, synchronize both GitHub secrets and the checked-in public key, and inspect the publish log for mismatch warnings. Prevention: treat updater key mismatch warnings as a release blocker.