diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 10a1104c..a3c5a4d8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,6 +48,10 @@ jobs: - uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" + shared-key: ${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN }}-release + add-rust-environment-hash-key: false + key: ${{ hashFiles('src-tauri/Cargo.lock') }} + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: oven-sh/setup-bun@v2 with: @@ -81,7 +85,7 @@ jobs: 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: | @@ -90,12 +94,6 @@ jobs: exit 1 fi - - name: Allow unsigned prerelease updater artifacts - if: runner.os == 'Windows' && contains(env.RELEASE_TAG, '-') - run: | - echo "No Tauri updater signing key configured; publishing this technical-preview installer without updater signatures." - echo "USAGEBAR_ALLOW_UNSIGNED_UPDATER=1" >> "$GITHUB_ENV" - - name: Verify Windows Authenticode signing secrets are configured if: runner.os == 'Windows' env: @@ -142,7 +140,7 @@ jobs: 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: @@ -150,12 +148,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/CHANGELOG.md b/CHANGELOG.md index ff458d9e..0a0c711c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Notes - Alpha 8 is an unsigned Windows technical preview. Windows may show `Unknown publisher` or SmartScreen warnings. -- No Tauri updater signing key is configured for this prerelease, so updater signature assets are intentionally omitted and prerelease updater checks remain disabled. +- Alpha 8 moves to a signed updater channel with in-app download, explicit restart, installation, and relaunch. - The NSIS installer is the supported prerelease artifact; MSI remains skipped because WiX rejects semver prerelease versions. ## 0.1.0-alpha.7 diff --git a/README.md b/README.md index 0e32641d..7c66549c 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ UsageBar is still pre-release. Alpha 8 is intended to let Windows users install - 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. -- Prerelease auto-updates are intentionally conservative because GitHub's `releases/latest` alias does not resolve prereleases. Prerelease builds may open the matching GitHub release page instead of installing in-app. +- 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 are full-release work, not an Alpha 8 promise. ## Architecture @@ -166,7 +166,7 @@ bun run release:check -- --release-tag v0.1.0-alpha.8 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. Windows installer builds require Authenticode material by default: `WINDOWS_CERTIFICATE_BASE64` plus `WINDOWS_CERTIFICATE_PASSWORD`, `WINDOWS_CERTIFICATE`, or `WINDOWS_CERTIFICATE_THUMBPRINT`. The helper signs the final setup executable after the build so the Windows launch prompt can show the certificate publisher. 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. Windows installer builds require Authenticode material by default: `WINDOWS_CERTIFICATE_BASE64` plus `WINDOWS_CERTIFICATE_PASSWORD`, `WINDOWS_CERTIFICATE`, or `WINDOWS_CERTIFICATE_THUMBPRINT`. The helper signs the final setup executable after the build so the Windows launch prompt can show the certificate publisher. The setup executable lands under `src-tauri/target/release/bundle/nsis/`. For Alpha 8 unsigned technical-preview builds, set `USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1`; those installers can show `Unknown publisher` and trigger Windows SmartScreen's "unrecognized app" warning. Stable/public-confidence Windows builds should be Authenticode-signed; see [docs/releasing.md](docs/releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index 773a80a6..02447722 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -36,7 +36,7 @@ Build the Windows installer locally before the first publish of a version: bun run build:release -- --bundles nsis ``` -If `TAURI_SIGNING_PRIVATE_KEY` is unset, the helper automatically adds `--no-sign` so local builds can still complete without Tauri updater signatures. Windows installer builds require Authenticode material by default. When that material is configured, the helper signs the final NSIS/MSI artifact after the build so the setup executable has a real publisher. +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. Windows installer builds require Authenticode material by default. When that material is configured, the helper signs the final NSIS/MSI artifact after the build so the setup executable has a real publisher. Alpha 8 exception: unsigned Windows prerelease installers are allowed as technical-preview artifacts while Authenticode signing is deferred. Set `USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1` for local unsigned builds. GitHub prerelease publishes set this automatically for tags that contain a prerelease suffix such as `v0.1.0-alpha.8`. These artifacts can show `Unknown publisher`, can trigger Windows SmartScreen's "unrecognized app" warning, and must be described as unsigned in release notes. @@ -55,7 +55,7 @@ Recommended GitHub secrets: - `WINDOWS_CERTIFICATE_PASSWORD`: `.pfx` export password. - `WINDOWS_TIMESTAMP_URL`: optional timestamp server; defaults to `http://timestamp.digicert.com`. -SmartScreen note: Authenticode signing is necessary but not always sufficient. EV certificates usually get immediate SmartScreen reputation. OV certificates and new certificates can still warn until Microsoft has enough reputation for the certificate or submitted binary. +SmartScreen note: Authenticode signing is necessary but not always sufficient. New OV and EV certificates can still warn until Microsoft has enough reputation for the certificate or submitted binary. ## GitHub Publish @@ -70,13 +70,16 @@ 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` when that key is unavailable and publishes an unsigned technical-preview installer without updater assets. Prerelease tags also set `USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1` so Authenticode signing remains optional. +All published releases require `TAURI_SIGNING_PRIVATE_KEY`, `latest.json`, and updater signature assets. Prerelease tags still set `USAGEBAR_ALLOW_UNSIGNED_WINDOWS_INSTALLER=1`, so Authenticode signing remains optional for technical previews. -Current updater channel note: +The release workflow creates `latest.json` automatically through `tauri-action` with `includeUpdaterJson: true`. It then verifies that the versioned release contains `latest.json` and at least one `.sig` file. The workflow copies the manifest to the fixed `updater` release and verifies that channel again. Do not create or upload `latest.json` manually. A release without the manifest or signatures stops before the updater channel is updated. + +Current updater channel: - GitHub's `releases/latest` alias only resolves stable releases, not prereleases. -- UsageBar currently keeps updater checks disabled for prerelease app versions like `0.1.0-alpha.1` and `0.1.0-beta.7`. -- Re-enable prerelease auto-updates only after moving off the stable-only alias or after shipping a stable release channel. +- 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`. +- The app downloads first. It installs and relaunches only after the user selects `Restart to update`. ## Alpha Gate @@ -87,10 +90,10 @@ Before publishing Alpha 8, verify and record: - Install, uninstall, config/data location, and first-run provider setup are documented. - At least one supported provider works from a fresh setup path. - Invalid credentials, offline/network failure, provider API failure, empty data, and refresh-in-progress states do not crash the app. -- README and release notes state privacy, telemetry, crash-log behavior, known limitations, and feedback/debug-info path. +- README and the linked support documentation state privacy, telemetry, crash-log behavior, known limitations, and feedback/debug-info path. - `CHANGELOG.md` includes the exact release version with supported features and known limitations. -Use the Alpha Gate bullets above for the final local artifact or GitHub release candidate before tagging. Historical Alpha 1 smoke evidence is archived at [alpha-smoke-test-0.1.0-alpha.1.md](archive/release/alpha-smoke-test-0.1.0-alpha.1.md). +Use the Alpha Gate bullets above as verification checks for the final local artifact or GitHub release candidate before tagging. Keep `Alpha Notes` short and include only the release-specific points that remain relevant. Historical Alpha 1 smoke evidence is archived at [alpha-smoke-test-0.1.0-alpha.1.md](archive/release/alpha-smoke-test-0.1.0-alpha.1.md). Suggested Alpha 1 release-note shape: @@ -100,21 +103,25 @@ Suggested Alpha 1 release-note shape: This is a public alpha for Windows users who want to test UsageBar before a full release. ### Supported + - Windows NSIS installer - Provider setup for ... - Manual refresh - Local settings storage ### Known limitations + - 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 open GitHub Releases instead of installing in-app - UI polish, crash recovery, and signed-build coverage are not final ### Privacy + UsageBar stores app settings and app-owned provider secrets locally under `%APPDATA%\com.sunstory.usagebar` on Windows. Provider secrets saved by UsageBar are encrypted with Windows DPAPI. Provider credentials and usage payloads are not intentionally sent to UsageBar-owned services. ### Feedback + Report bugs at https://github.com/luisleineweber/usagebar/issues/new and include app version, Windows version, provider, error text, timestamp, and sanitized logs. Do not include API keys, cookies, or raw credential files. ``` diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index e0bc27c6..b3add8c1 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) 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/tauri.conf.json b/src-tauri/tauri.conf.json index e98c8ac7..7e3051c0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -80,9 +80,9 @@ }, "plugins": { "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVGMzA0N0Q1MzEyNDBCQ0IKUldUTEN5UXgxVWN3WDRrbkg1UW5kRFpHVXdMK25zWm5LRGlSZlR4UWdRMGFmODZab0hMYjFlLzkK", + "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 863d6005..99820b1c 100644 --- a/src/hooks/use-app-update.test.ts +++ b/src/hooks/use-app-update.test.ts @@ -1,9 +1,8 @@ import { renderHook, act, waitFor } from "@testing-library/react" import { describe, expect, it, vi, beforeEach, afterAll } from "vitest" -const { checkMock, openUrlMock, relaunchMock } = vi.hoisted(() => ({ +const { checkMock, relaunchMock } = vi.hoisted(() => ({ checkMock: vi.fn(), - openUrlMock: vi.fn(), relaunchMock: vi.fn(), })) const { getVersionMock } = vi.hoisted(() => ({ @@ -17,10 +16,6 @@ vi.mock("@tauri-apps/plugin-updater", () => ({ check: checkMock, })) -vi.mock("@tauri-apps/plugin-opener", () => ({ - openUrl: openUrlMock, -})) - vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: relaunchMock, })) @@ -37,18 +32,10 @@ describe("useAppUpdate", () => { beforeEach(() => { checkMock.mockReset() - openUrlMock.mockReset() 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 }) @@ -109,7 +96,7 @@ describe("useAppUpdate", () => { getVersionMock.mockResolvedValue("0.1.0-beta.5") checkMock.mockResolvedValue({ version: "0.1.0-beta.6", - downloadAndInstall: vi.fn(), + download: vi.fn(), install: vi.fn(), }) @@ -125,77 +112,19 @@ 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", + status: "error", + message: "Update check failed", }) }) - 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 () => { vi.useFakeTimers() const clearTimeoutSpy = vi.spyOn(window, "clearTimeout") @@ -230,17 +159,18 @@ describe("useAppUpdate", () => { vi.useRealTimers() }) - it("waits for user action before downloading and installing a signed Tauri update", async () => { - const downloadAndInstallMock = vi.fn(async (onEvent: (event: unknown) => void) => { + it("downloads first and installs only after a second user action", async () => { + const downloadMock = vi.fn(async (onEvent: (event: unknown) => 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", - downloadAndInstall: downloadAndInstallMock, - install: vi.fn(), + download: downloadMock, + install: installMock, }) relaunchMock.mockResolvedValue(undefined) @@ -248,38 +178,23 @@ describe("useAppUpdate", () => { await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) expect(result.current.updateStatus).toEqual({ status: "available", version: "1.1.0" }) - expect(downloadAndInstallMock).not.toHaveBeenCalled() + expect(downloadMock).not.toHaveBeenCalled() + + 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(downloadAndInstallMock).toHaveBeenCalled() + 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", downloadAndInstall: vi.fn(), install: vi.fn() }) + checkMock.mockResolvedValue({ version: "1.0.0", download: vi.fn(), install: vi.fn() }) const { result } = renderHook(() => useAppUpdate({ isDev: false })) await waitFor(() => expect(result.current.updateStatus.status).toBe("up-to-date")) @@ -289,33 +204,21 @@ describe("useAppUpdate", () => { it("ignores signed updater candidates that are lower than the current version", async () => { getVersionMock.mockResolvedValue("1.0.0") - checkMock.mockResolvedValue({ version: "0.9.9", downloadAndInstall: vi.fn(), install: vi.fn() }) - - 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("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")) + checkMock.mockResolvedValue({ version: "0.9.9", download: vi.fn(), install: vi.fn() }) 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 downloadAndInstallMock = vi.fn(async (onEvent: (event: unknown) => void) => { + const downloadMock = vi.fn(async (onEvent: (event: unknown) => void) => { onEvent({ event: "Finished", data: {} }) }) checkMock.mockResolvedValue({ version: "1.1.0", - downloadAndInstall: downloadAndInstallMock, + download: downloadMock, install: vi.fn(), }) @@ -345,7 +248,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()) @@ -354,7 +256,7 @@ describe("useAppUpdate", () => { it("reports indeterminate progress when content length is unknown", async () => { let resolveDownload: (() => void) | null = null - const downloadAndInstallMock = vi.fn((onEvent: (event: unknown) => void) => { + const downloadMock = vi.fn((onEvent: (event: unknown) => void) => { onEvent({ event: "Started", data: { contentLength: null } }) return new Promise((resolve) => { resolveDownload = resolve @@ -362,7 +264,7 @@ describe("useAppUpdate", () => { }) checkMock.mockResolvedValue({ version: "1.1.0", - downloadAndInstall: downloadAndInstallMock, + download: downloadMock, install: vi.fn(), }) @@ -381,11 +283,11 @@ describe("useAppUpdate", () => { }) }) - it("transitions to error on download or install failure", async () => { - const downloadAndInstallMock = vi.fn().mockRejectedValue(new Error("download failed")) + 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", - downloadAndInstall: downloadAndInstallMock, + download: downloadMock, install: vi.fn(), }) @@ -393,26 +295,30 @@ describe("useAppUpdate", () => { await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) await act(() => result.current.triggerInstall()) - expect(result.current.updateStatus).toEqual({ status: "error", message: "Install failed" }) + expect(result.current.updateStatus).toEqual({ + status: "available", + version: "1.1.0", + error: "Download failed", + }) }) - it("downloads, installs, and relaunches from the available state", async () => { - const downloadAndInstallMock = vi.fn(async (onEvent: (event: unknown) => void) => { + it("keeps the downloaded update ready until restart", async () => { + const downloadMock = vi.fn(async (onEvent: (event: unknown) => void) => { onEvent({ event: "Finished", data: {} }) }) relaunchMock.mockResolvedValue(undefined) checkMock.mockResolvedValue({ version: "1.1.0", - downloadAndInstall: downloadAndInstallMock, + download: downloadMock, install: vi.fn(), }) const { result } = renderHook(() => useAppUpdate({ isDev: false })) await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) await act(() => result.current.triggerInstall()) - expect(downloadAndInstallMock).toHaveBeenCalled() - expect(relaunchMock).toHaveBeenCalled() - expect(result.current.updateStatus).toEqual({ status: "idle" }) + expect(downloadMock).toHaveBeenCalled() + expect(relaunchMock).not.toHaveBeenCalled() + expect(result.current.updateStatus).toEqual({ status: "ready" }) }) it("does not update state after unmount during check", async () => { @@ -426,7 +332,7 @@ describe("useAppUpdate", () => { const { result, unmount } = renderHook(() => useAppUpdate({ isDev: false })) const statusAtUnmount = result.current.updateStatus unmount() - resolveRef.current?.({ version: "1.0.0", downloadAndInstall: vi.fn(), install: vi.fn() }) + resolveRef.current?.({ version: "1.0.0", download: vi.fn(), install: vi.fn() }) await act(() => Promise.resolve()) expect(result.current.updateStatus).toEqual(statusAtUnmount) }) @@ -447,7 +353,7 @@ describe("useAppUpdate", () => { it("does not trigger install while downloading", async () => { let resolveDownload: (() => void) | null = null const installMock = vi.fn().mockResolvedValue(undefined) - const downloadAndInstallMock = vi.fn((onEvent: (event: unknown) => void) => { + const downloadMock = vi.fn((onEvent: (event: unknown) => void) => { onEvent({ event: "Started", data: { contentLength: 100 } }) return new Promise((resolve) => { resolveDownload = resolve @@ -455,7 +361,7 @@ describe("useAppUpdate", () => { }) checkMock.mockResolvedValue({ version: "1.1.0", - downloadAndInstall: downloadAndInstallMock, + download: downloadMock, install: installMock, }) @@ -478,32 +384,33 @@ describe("useAppUpdate", () => { it("prevents concurrent install attempts", async () => { let resolveInstall: (() => void) | null = null - const downloadAndInstallMock = vi.fn((onEvent: (event: unknown) => void) => { - onEvent({ event: "Finished", data: {} }) - return new Promise((resolve) => { - resolveInstall = resolve - }) - }) + const downloadMock = vi.fn().mockResolvedValue(undefined) + const installMock = vi.fn( + () => + new Promise((resolve) => { + resolveInstall = resolve + }) + ) relaunchMock.mockResolvedValue(undefined) checkMock.mockResolvedValue({ version: "1.1.0", - downloadAndInstall: downloadAndInstallMock, - install: vi.fn(), + download: downloadMock, + install: installMock, }) const { result } = renderHook(() => useAppUpdate({ isDev: false })) await waitFor(() => expect(result.current.updateStatus.status).toBe("available")) - act(() => { - void result.current.triggerInstall() - }) - act(() => { - void result.current.triggerInstall() - }) + await act(() => result.current.triggerInstall()) + expect(result.current.updateStatus).toEqual({ status: "ready" }) + + act(() => void result.current.triggerInstall()) + act(() => void result.current.triggerInstall()) await act(() => Promise.resolve()) expect(result.current.updateStatus).toEqual({ status: "installing" }) - expect(downloadAndInstallMock).toHaveBeenCalledTimes(1) + expect(downloadMock).toHaveBeenCalledTimes(1) + expect(installMock).toHaveBeenCalledTimes(1) await act(async () => { resolveInstall?.() diff --git a/src/hooks/use-app-update.ts b/src/hooks/use-app-update.ts index cb05840a..1ea39212 100644 --- a/src/hooks/use-app-update.ts +++ b/src/hooks/use-app-update.ts @@ -2,14 +2,13 @@ import { useState, useEffect, useCallback, useRef } from "react" import { getVersion } from "@tauri-apps/api/app" import { isTauri } from "@tauri-apps/api/core" import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater" -import { openUrl } from "@tauri-apps/plugin-opener" import { relaunch } from "@tauri-apps/plugin-process" export type UpdateStatus = | { status: "idle" } | { status: "checking" } | { status: "up-to-date" } - | { status: "available"; version: string; url?: string } + | { status: "available"; version: string; error?: string } | { status: "downloading"; progress: number } // 0-100, or -1 if indeterminate | { status: "installing" } | { status: "ready" } @@ -24,11 +23,8 @@ interface UseAppUpdateReturn { interface UseAppUpdateOptions { isDev?: boolean - repo?: string } -const DEFAULT_RELEASE_REPO = "luisleineweber/usagebar" - export function isPrereleaseVersion(version: string): boolean { return version.trim().includes("-") } @@ -111,48 +107,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 currentVersionRef = useRef(null) const mountedRef = useRef(true) const inFlightRef = useRef({ checking: false, downloading: false, installing: false }) @@ -239,31 +198,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 signedUpdaterError: unknown = null if (!updaterEligibilityResolvedRef.current) { canUseSignedUpdater = await resolveUpdaterEligibility() } - if (canUseSignedUpdater) { - try { - update = await check() - } catch (error) { - signedUpdaterError = error - // 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 setStatus({ status: "available", version: updateVersion }) return } @@ -272,30 +218,7 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet ) } - let release: GitHubReleaseCandidate | null = null - try { - release = await findNewerGitHubRelease(repo, currentVersion) - } catch (error) { - if (canUseSignedUpdater) { - if (signedUpdaterError) { - 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 - 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 @@ -306,7 +229,6 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet }, [ getCurrentVersion, isDev, - repo, resolveUpdaterEligibility, setStatus, setUnavailableThenIdle, @@ -343,12 +265,6 @@ export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateRet const triggerInstall = useCallback(async () => { const update = updateRef.current - const releaseUrl = externalReleaseUrlRef.current - if (statusRef.current.status === "available" && releaseUrl) { - await openUrl(releaseUrl) - return - } - if (!update) return if (statusRef.current.status === "available") { if (inFlightRef.current.downloading || inFlightRef.current.installing) return @@ -373,18 +289,21 @@ 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") { - setStatus({ status: "installing" }) + } else if (event.event === "Finished" && totalBytes) { + setStatus({ status: "downloading", progress: 100 }) } } try { - await update.downloadAndInstall(onDownloadEvent) - await relaunch() - setStatus({ status: "idle" }) + await update.download(onDownloadEvent) + setStatus({ status: "ready" }) } catch (err) { - console.error("Update download or install failed:", err) - setStatus({ status: "error", message: "Install failed" }) + console.error("Update download failed:", err) + setStatus({ + status: "available", + version: normalizeVersion(update.version), + error: "Download failed", + }) } finally { inFlightRef.current.downloading = false } diff --git a/tasks/lessons.md b/tasks/lessons.md index d3b55a71..927dd992 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -2,6 +2,7 @@ ## 2026-08-07 +- GitHub prerelease discovery exposed an update but only opened the release page because the release omitted signed Tauri updater assets. Fix: require updater signing for every release, publish `latest.json` through a fixed channel, and separate download from restart/install. Prevention: release checks must verify the channel manifest, signature asset, installer, download state, and restart state together. - The available-update action looked like a glowing status badge instead of a native tray action. Fix: use the shared compact button geometry, the product accent, and a download icon. Prevention: review update states against the shared button system in both themes. ## 2026-08-06 @@ -239,3 +240,6 @@ Keep this file short. Retain only recent or frequently relevant prevention rules - Sidebar footer grouping: a fixed action after a scrollable icon list needs its own spacing and separator. Keep Settings in a shrink-resistant footer zone so it does not look like another provider. - External multicolor SVGs: `currentColor` in an `` does not inherit app text color, and SVG media queries do not follow a forced app theme. Expose a theme-specific icon asset and select it from the app theme state. - Tray window corners: CSS `border-radius` cannot cut the corners of an opaque native window. Make the tray window transparent and keep the rounded panel as the visible surface; use an opaque background only for standalone Settings. On Windows, native shadow on an undecorated window also adds a 1px white border, so keep it disabled when the panel uses its own border. +- Release commit lists: `What's Changed` must list every commit between the previous and new release tags. Keep `Alpha Notes` limited to the remaining release-specific notes; track broader gate checks in release documentation. +- Rust release cache: exclude version-only `Cargo.toml` changes from the key. Share a lockfile-keyed release cache between main CI and release builds. +- Cargo target reuse: `--bin usagebar-cli` already compiles its library dependency. Do not add `--lib`; verify reuse with the next app build because changed Tauri build-script inputs can still compile the package again.