feat(update): non-blocking auto-update — progress, states, busy-gating - #198
Conversation
…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>
📝 WalkthroughWalkthroughThe 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. ChangesTauri Auto-Update System
🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add 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. Comment |
| ...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); |
|
| 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
Reviews (1): Last reviewed commit: "feat(update): non-blocking auto-update w..." | Re-trigger Greptile
| 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(); |
There was a problem hiding this comment.
relaunch() fires immediately — "Restart to update" button is never reachable in the normal flow
downloadAndInstall fires Finished → setUpdateReady(), 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.
| {status === 'ready' && ( | ||
| <button type="button" className="update-badge__btn update-badge__btn--ready" onClick={onInstall}> | ||
| <RotateCw size={12} /> {t('update.restart')} | ||
| </button> |
There was a problem hiding this comment.
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.
| 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; } |
There was a problem hiding this comment.
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.
| export function isTauri() { | ||
| return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; | ||
| } |
There was a problem hiding this comment.
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.
| 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!
There was a problem hiding this comment.
🧹 Nitpick comments (2)
frontend/src/utils/updater.js (2)
43-53: 💤 Low valueRedundant
setUpdateReady()call after download completes.The
Finishedevent handler (line 49) already callsstore.setUpdateReady(), and then line 52 calls it again afterdownloadAndInstallcompletes. This is likely redundant unless theFinishedevent can fail to fire.♻️ Proposed fix to remove redundant call
If the
Finishedevent 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 duplicatesetUpdateReady()
frontend/src-tauri/tauri.conf.jsonpinsplugins.updater.endpointsto a GitHub releaseslatest/download/latest.jsonURL, so the updater’s network calls go to the expected GitHub release manifest.frontend/src/utils/updater.js:store.setUpdateReady()is called both on theev.event === 'Finished'handler and again immediately afterawait 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
📒 Files selected for processing (9)
frontend/src/App.jsxfrontend/src/components/UpdateBadge.cssfrontend/src/components/UpdateBadge.jsxfrontend/src/i18n/locales/en.jsonfrontend/src/i18n/locales/zh-CN.jsonfrontend/src/store/index.tsfrontend/src/store/updaterSlice.test.tsfrontend/src/store/updaterSlice.tsfrontend/src/utils/updater.js
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>
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>
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.updaterSlice—idle→checking→available→downloading(pct)→ready/errorstate machine (transient). 5 reducer tests.utils/updater—checkForUpdate()(launch, non-blocking → store) +installUpdate()(downloadAndInstallwith a Started/Progress/Finished → progress callback, thenrelaunch). 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.tauri-plugin-updater.Full suite 159/159, typecheck/build/CJK ✓. Preview/main-channel (a
release.ymllatest-preview.json+ channel toggle) is the noted follow-up; this is the stable-channel core.🤖 Generated with Claude Code
Summary by CodeRabbit