Skip to content

feat(update): non-blocking auto-update — progress, states, busy-gating - #198

Merged
debpalash merged 1 commit into
mainfrom
feat/auto-update-progress
May 31, 2026
Merged

feat(update): non-blocking auto-update — progress, states, busy-gating#198
debpalash merged 1 commit into
mainfrom
feat/auto-update-progress

Conversation

@debpalash

@debpalash debpalash commented May 31, 2026

Copy link
Copy Markdown
Owner

Completes the auto-update plan's core (Phase 1). Replaces the blocking ask() dialog with a state-driven, progress-visible flow that never interrupts in-flight work.

  • updaterSliceidle→checking→available→downloading(pct)→ready/error state machine (transient). 5 reducer tests.
  • utils/updatercheckForUpdate() (launch, non-blocking → store) + installUpdate() (downloadAndInstall with a Started/Progress/Finished → progress callback, then relaunch). No-ops outside packaged Tauri.
  • UpdateBadge — a non-intrusive pill: 'Update vX available · Install & Restart' → progress bar → 'Restart to update'. Install is gated while a dub job is generating (toast) so a relaunch can't lose work.
  • Data safety is structural: user data lives outside the bundle; alembic migrates on next backend start; the bundled backend updates atomically with the shell.
  • i18n (en + zh-CN). Builds on the existing signed tauri-plugin-updater.

Full suite 159/159, typecheck/build/CJK ✓. Preview/main-channel (a release.yml latest-preview.json + channel toggle) is the noted follow-up; this is the stable-channel core.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a notification badge displaying update availability and download progress in the top-right corner
    • Users can now check for, download, and install app updates with a visual progress indicator
    • Non-blocking update workflow allows continued app usage while downloading
    • Added English and Chinese localization for update-related prompts and messages

…gating

Replaces the blocking ask() dialog with a state-driven, progress-visible flow
that never interrupts in-flight work — and preserves existing work by design
(user data lives outside the bundle; alembic migrates on next backend start).

- updaterSlice: idle→checking→available→downloading(pct)→ready/error state
  machine (transient, not persisted). 5 reducer tests.
- utils/updater: checkForUpdate() (launch, non-blocking → store) +
  installUpdate() (downloadAndInstall with a Started/Progress/Finished →
  progress callback, then relaunch). No-ops outside packaged Tauri.
- UpdateBadge: a non-intrusive pill — 'Update vX available · Install & Restart'
  → progress bar → 'Restart to update'. Install is gated while a dub job is
  generating (toast 'finish your dub first') so a relaunch can't lose work.
- App.jsx: launch check now just surfaces availability into the store + mounts
  the badge (no blocking dialog, no silent auto-install).
- i18n (en + zh-CN).

Builds on the existing tauri-plugin-updater (signed, GH-release latest.json).
Preview/main channel (a release.yml latest-preview.json + channel toggle) is a
follow-up; this is the stable-channel core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR refactors the Tauri update system from blocking imperative logic into a non-blocking, store-driven architecture. A new Zustand slice manages updater state transitions, two utility functions orchestrate Tauri update checking and installation with progress tracking, a React badge component surfaces the UI conditionally, and the app startup invokes the check while rendering the badge. Translations are added for English and Chinese locales.

Changes

Tauri Auto-Update System

Layer / File(s) Summary
Updater state slice and store integration
frontend/src/store/updaterSlice.ts, frontend/src/store/updaterSlice.test.ts, frontend/src/store/index.ts
Defines UpdateStatus lifecycle type (idle, checking, available, downloading, ready, error), UpdaterSlice interface with state fields and setters, createUpdaterSlice factory with clamped/rounded progress, and comprehensive vitest coverage. Wires the slice into the root store as non-persisted transient state.
Tauri update utilities
frontend/src/utils/updater.js
Exports isTauri() to detect packaged Tauri context, checkForUpdate(store) for non-blocking availability checking with Tauri plugin dynamic import and store state transitions, and installUpdate(store) for download orchestration with event-driven progress updates and safe relaunch gating.
UpdateBadge component and styling
frontend/src/components/UpdateBadge.jsx, frontend/src/components/UpdateBadge.css
Introduces a conditional badge component that reads updater state (idle/checking/error → hidden, available → install button, downloading → progress UI, ready → restart button). Includes gating during active dub jobs and toast feedback. Styled as a fixed top-right pill with hover effects, progress bar, and "ready" variant.
App lifecycle integration
frontend/src/App.jsx
Removes the prior blocking updater useEffect with Tauri plugin imports and relaunch flow. Replaces it with a non-blocking checkForUpdate(store) call guarded by environment checks. Adds UpdateBadge component to main render and imports both the utility helper and UI component.
Localization
frontend/src/i18n/locales/en.json, frontend/src/i18n/locales/zh-CN.json
Adds update UI translation strings for both English and Chinese, including availability status, installation actions, progress display, restart guidance, and busy-state messaging.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: implementing non-blocking auto-update with progress visibility and busy-state gating.
Description check ✅ Passed The description covers the core changes, implementation details, testing (159/159), and technical approach, but lacks explicit checklist completion and testing methodology details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auto-update-progress

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

...createGenerateSlice(set, get, api),
...createPillSlice(set, get, api),
...createStoriesSlice(set, get, api),
...createUpdaterSlice(set, get, api), // transient — not in partialize
function harness() {
let state: any = {};
const set = (p: any) => { state = { ...state, ...(typeof p === 'function' ? p(state) : p) }; };
state = createUpdaterSlice(set as any, (() => state) as any, {} as any);
@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the blocking ask() dialog updater with a state-driven, non-interrupting update badge. It introduces updaterSlice (state machine + 5 tests), utils/updater (check/install helpers), UpdateBadge (progress-visible pill with busy-gating), and paired i18n keys for EN + zh-CN.

  • State machine (updaterSlice.ts): clean idle→checking→available→downloading→ready/error machine, transient (not persisted), with clamped progress and good test coverage.
  • Update helpers (utils/updater.js): installUpdate calls relaunch() immediately after downloadAndInstall resolves, so the "Restart to update" button is unreachable in the normal flow; when that button is clicked it re-invokes installUpdate triggering a full re-download instead of a simple relaunch.
  • Duplicate isTauri guard: utils/media.js already exports an identical helper; the copy in updater.js risks silent divergence.

Confidence Score: 3/5

The update badge and state machine are structurally sound, but the core install/restart flow has a broken handoff: the app relaunches immediately after download completes, making the Restart to update button unreachable in the normal path.

installUpdate calls relaunch() immediately after downloadAndInstall resolves, so the ready state the PR promises is never shown to users. If the button is ever reached and clicked, it re-downloads the full update binary instead of simply relaunching — the most visible new UX surface behaves incorrectly in the scenarios it was designed for.

frontend/src/utils/updater.js and frontend/src/components/UpdateBadge.jsx need the most attention — the install/relaunch split and the ready-button routing are the areas to fix before merging.

Important Files Changed

Filename Overview
frontend/src/utils/updater.js Core update logic with two design bugs: installUpdate calls relaunch() immediately (user never sees the ready state it sets), and the ready button re-invokes installUpdate which re-downloads the full update instead of just relaunching
frontend/src/components/UpdateBadge.jsx Badge UI correctly maps states to UI; the ready button calls the same onInstall handler as available, triggering a full re-download instead of just relaunching
frontend/src/store/updaterSlice.ts Clean state machine with well-typed transitions, clamped progress, and correct reset behavior; not persisted per store comment
frontend/src/store/updaterSlice.test.ts Five reducer tests covering all state transitions including clamping, null handling, and reset paths
frontend/src/App.jsx Replaces blocking ask() with checkForUpdate(useAppStore.getState()); adds UpdateBadge to the render tree; straightforward integration
frontend/src/store/index.ts Adds UpdaterSlice to AppStore type and composes createUpdaterSlice; correctly excluded from partialize (not persisted)

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([App mount]) -->|packaged Tauri + prod| B[checkForUpdate]
    B --> C{check API}
    C -->|update found| D[setUpdateAvailable status: available]
    C -->|up to date| E[setUpdateIdle status: idle]
    C -->|error| F[setUpdateIdle status: idle]
    D --> G[UpdateBadge: Update vX available]
    G -->|user clicks, not busy| H[installUpdate]
    G -->|user clicks, dub generating| I[toast: finish dub first]
    H --> J[check API again - second network call]
    J -->|null / error| K[setUpdateIdle - badge silently vanishes]
    J -->|update found| L[downloadAndInstall + progress cb]
    L --> M[setUpdateProgress 0-99 status: downloading]
    M -->|Finished event| N[setUpdateReady status: ready]
    N --> O[relaunch - immediate, user never sees ready button]
    O -->|success| Q([App terminates and restarts])
    O -->|throws| R[setUpdateError status: error]
    N2[UpdateBadge: Restart to update] -->|user clicks| H
    N --> N2
    style N2 stroke-dasharray: 5 5
    style K fill:#ff9999
    style O fill:#ff9999
    style J fill:#ffddaa
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(update): non-blocking auto-update w..." | Re-trigger Greptile

Comment on lines +43 to +53
await update.downloadAndInstall((ev) => {
if (ev.event === 'Started') total = ev.data?.contentLength || 0;
else if (ev.event === 'Progress') {
got += ev.data?.chunkLength || 0;
if (total > 0) store.setUpdateProgress(Math.min(99, (got / total) * 100));
} else if (ev.event === 'Finished') {
store.setUpdateReady();
}
});
store.setUpdateReady();
await relaunch();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 relaunch() fires immediately — "Restart to update" button is never reachable in the normal flow

downloadAndInstall fires FinishedsetUpdateReady(), then the promise resolves and relaunch() is invoked immediately. The app terminates before the user can ever see the "Restart to update" button. When that button IS visible (e.g. after a previous partial attempt left status at ready), clicking it routes back through installUpdate, triggering a full re-download instead of a simple relaunch. installUpdate should stop at setUpdateReady(), and a dedicated doRelaunch(store) helper should wrap relaunch() so the "Restart to update" button can call it directly.

Fix in Claude Code

Comment on lines +40 to +43
{status === 'ready' && (
<button type="button" className="update-badge__btn update-badge__btn--ready" onClick={onInstall}>
<RotateCw size={12} /> {t('update.restart')}
</button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ready button calls installUpdate, causing a full re-download instead of a relaunch

Both the available and ready buttons share the same onInstall handler, which always invokes installUpdate. In the ready state the update binary is already on disk; calling installUpdate again re-runs check() + downloadAndInstall from scratch, burning bandwidth and resetting the progress bar to 0. The ready button should call a thin doRelaunch wrapper (exported from utils/updater) that just calls relaunch() directly.

Fix in Claude Code

Comment on lines +34 to +39
const [{ check }, { relaunch }] = await Promise.all([
import('@tauri-apps/plugin-updater'),
import('@tauri-apps/plugin-process'),
]);
const update = await check();
if (!update) { store.setUpdateIdle(); return; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Second check() call can silently reset the badge to idle

installUpdate issues a fresh check() network request even though the store already holds version/notes from checkForUpdate. If the endpoint returns null on this second call (transient 404, CDN hiccup), store.setUpdateIdle() is called with no feedback and the badge vanishes silently. Consider calling setUpdateError rather than setUpdateIdle when the re-check comes back empty.

Fix in Claude Code

Comment on lines +12 to +14
export function isTauri() {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate isTauri export — already exported from utils/media.js

utils/media.js already exports an identical isTauri guard. Two copies risk diverging if __TAURI_INTERNALS__ ever changes. Import from media instead.

Suggested change
export function isTauri() {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
export { isTauri } from './media';

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
frontend/src/utils/updater.js (2)

43-53: 💤 Low value

Redundant setUpdateReady() call after download completes.

The Finished event handler (line 49) already calls store.setUpdateReady(), and then line 52 calls it again after downloadAndInstall completes. This is likely redundant unless the Finished event can fail to fire.

♻️ Proposed fix to remove redundant call

If the Finished event reliably fires before the promise resolves, remove the duplicate:

     await update.downloadAndInstall((ev) => {
       if (ev.event === 'Started') total = ev.data?.contentLength || 0;
       else if (ev.event === 'Progress') {
         got += ev.data?.chunkLength || 0;
         if (total > 0) store.setUpdateProgress(Math.min(99, (got / total) * 100));
       } else if (ev.event === 'Finished') {
         store.setUpdateReady();
       }
     });
-    store.setUpdateReady();
     await relaunch();

Alternatively, if this is defensive programming against a potential Tauri plugin bug, add a clarifying comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/utils/updater.js` around lines 43 - 53, The code calls
store.setUpdateReady() twice: once in the downloadAndInstall event handler when
ev.event === 'Finished' and again immediately after await
update.downloadAndInstall(...); remove the redundant store.setUpdateReady()
after the await (or if you intentionally keep it as defensive against a plugin
bug, add a clear comment above that call explaining why the duplicate is
needed); look for references to update.downloadAndInstall, the ev.event ===
'Finished' branch, store.setUpdateReady, and relaunch to make the change.

16-29: Tauri updater uses GitHub releases; remove/clarify duplicate setUpdateReady()

  • frontend/src-tauri/tauri.conf.json pins plugins.updater.endpoints to a GitHub releases latest/download/latest.json URL, so the updater’s network calls go to the expected GitHub release manifest.
  • frontend/src/utils/updater.js: store.setUpdateReady() is called both on the ev.event === 'Finished' handler and again immediately after await update.downloadAndInstall(...); remove the redundant call or document why both are required.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/utils/updater.js` around lines 16 - 29, The updater currently
calls store.setUpdateReady() twice: once inside the event handler when ev.event
=== 'Finished' and again immediately after await update.downloadAndInstall(...);
in the checkForUpdate flow remove the redundant call to store.setUpdateReady()
(or if there's a specific race/ordering reason, add a comment explaining why
both the event-handler and the post-download call are required). Locate
references in checkForUpdate and the downloadAndInstall promise handling and
ensure only a single, well-documented invocation of setUpdateReady() remains
(prefer the event-driven ev.event === 'Finished' handler as the canonical
signal).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@frontend/src/utils/updater.js`:
- Around line 43-53: The code calls store.setUpdateReady() twice: once in the
downloadAndInstall event handler when ev.event === 'Finished' and again
immediately after await update.downloadAndInstall(...); remove the redundant
store.setUpdateReady() after the await (or if you intentionally keep it as
defensive against a plugin bug, add a clear comment above that call explaining
why the duplicate is needed); look for references to update.downloadAndInstall,
the ev.event === 'Finished' branch, store.setUpdateReady, and relaunch to make
the change.
- Around line 16-29: The updater currently calls store.setUpdateReady() twice:
once inside the event handler when ev.event === 'Finished' and again immediately
after await update.downloadAndInstall(...); in the checkForUpdate flow remove
the redundant call to store.setUpdateReady() (or if there's a specific
race/ordering reason, add a comment explaining why both the event-handler and
the post-download call are required). Locate references in checkForUpdate and
the downloadAndInstall promise handling and ensure only a single,
well-documented invocation of setUpdateReady() remains (prefer the event-driven
ev.event === 'Finished' handler as the canonical signal).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a3573ce4-0f50-403d-b1a0-cbf90ff7f780

📥 Commits

Reviewing files that changed from the base of the PR and between 7b346d9 and a487cc4.

📒 Files selected for processing (9)
  • frontend/src/App.jsx
  • frontend/src/components/UpdateBadge.css
  • frontend/src/components/UpdateBadge.jsx
  • frontend/src/i18n/locales/en.json
  • frontend/src/i18n/locales/zh-CN.json
  • frontend/src/store/index.ts
  • frontend/src/store/updaterSlice.test.ts
  • frontend/src/store/updaterSlice.ts
  • frontend/src/utils/updater.js

@debpalash
debpalash merged commit dfc86da into main May 31, 2026
21 of 22 checks passed
@debpalash
debpalash deleted the feat/auto-update-progress branch May 31, 2026 00:20
debpalash added a commit that referenced this pull request May 31, 2026
Adds a user-selectable updater release channel (Settings -> About -> Update
channel). Stable (default, every install + launch) tracks tagged vX.Y.Z
releases; Preview tracks the latest main build via a rolling "preview"
prerelease, falling back to stable if a stable release is ahead.

Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and
neither the JS check() nor the plugin's registration Builder can change them at
runtime (verified against the 2.10.1 source). The only runtime-endpoint API is
UpdaterExt::endpoints, so check+install move into two Rust commands that mirror
the plugin's own check/download_and_install -- the Stable path behaves
identically to the JS flow it replaces; only which manifest is consulted
changes. Switching is instant (channel is read per check), no restart.

backend (Rust):
- config.rs: update_channel field (default "stable", VALID_CHANNELS) +
  get/set_update_channel commands.
- updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) +
  check_update / install_update commands; install emits update://progress.

frontend:
- utils/updateChannel.js (+test): single source of truth, normalizeChannel.
- utils/updater.js: routes the badge flow (#198) through the Rust commands via
  the same store contract -- UpdateBadge/App.jsx unchanged.
- Settings About: Stable/Preview segmented toggle, channel-aware endpoint row +
  diagnostics; Check-for-updates honors the live channel.
- i18n en + zh-CN.

release.yml: additive, workflow_dispatch-guarded preview publish to a rolling
"preview" prerelease. The v* tag-push stable path evaluates to its exact prior
values (verified) and is never affected. Preview builds are manual -- no
scheduled CI spend, nothing auto-published.

docs/update-channels.md.

Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard,
release.yml YAML parses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
debpalash added a commit that referenced this pull request May 31, 2026
Adds a user-selectable updater release channel (Settings -> About -> Update
channel). Stable (default, every install + launch) tracks tagged vX.Y.Z
releases; Preview tracks the latest main build via a rolling "preview"
prerelease, falling back to stable if a stable release is ahead.

Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and
neither the JS check() nor the plugin's registration Builder can change them at
runtime (verified against the 2.10.1 source). The only runtime-endpoint API is
UpdaterExt::endpoints, so check+install move into two Rust commands that mirror
the plugin's own check/download_and_install -- the Stable path behaves
identically to the JS flow it replaces; only which manifest is consulted
changes. Switching is instant (channel is read per check), no restart.

backend (Rust):
- config.rs: update_channel field (default "stable", VALID_CHANNELS) +
  get/set_update_channel commands.
- updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) +
  check_update / install_update commands; install emits update://progress.

frontend:
- utils/updateChannel.js (+test): single source of truth, normalizeChannel.
- utils/updater.js: routes the badge flow (#198) through the Rust commands via
  the same store contract -- UpdateBadge/App.jsx unchanged.
- Settings About: Stable/Preview segmented toggle, channel-aware endpoint row +
  diagnostics; Check-for-updates honors the live channel.
- i18n en + zh-CN.

release.yml: additive, workflow_dispatch-guarded preview publish to a rolling
"preview" prerelease. The v* tag-push stable path evaluates to its exact prior
values (verified) and is never affected. Preview builds are manual -- no
scheduled CI spend, nothing auto-published.

docs/update-channels.md.

Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard,
release.yml YAML parses.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants