diff --git a/desktop/src/renderer/src/components/UpdateBanner.tsx b/desktop/src/renderer/src/components/UpdateBanner.tsx
index bdd45aff..78c48006 100644
--- a/desktop/src/renderer/src/components/UpdateBanner.tsx
+++ b/desktop/src/renderer/src/components/UpdateBanner.tsx
@@ -14,14 +14,32 @@ const UpdateBanner: React.FC = () => {
const status = state.status
const errored = status?.state === 'error'
+ // Full-screen "installing…" overlay: bridges the otherwise blank window
+ // between clicking "restart to install" and the app actually quitting to
+ // swap the bundle. (The gap AFTER quit, before relaunch, is OS-level and
+ // can't be covered.)
+ if (state.installing) {
+ return (
+
+
+
{t('update_installing')}
+
+ )
+ }
+
// Show the panel when it's open AND we either know of an update or hit an
// error. Keeping it up on error is important: a failed download must surface
// a visible message instead of silently doing nothing.
if (!open || (!available && !errored)) return null
const version = state.version
+ const preparing = state.preparing
const downloading = status?.state === 'downloading'
const downloaded = status?.state === 'downloaded'
+ // macOS emits a second progress pass (verify) after hitting 100%; show it as
+ // an indeterminate "verifying" state rather than a bar restarting from 0.
+ const verifying = downloading && state.progressPeaked
+ const busy = preparing || downloading
return (
@@ -62,7 +80,21 @@ const UpdateBanner: React.FC = () => {
)}
- {!errored && downloading && (
+ {!errored && preparing && (
+
+
+ {t('update_preparing')}
+
+ )}
+
+ {!errored && downloading && verifying && (
+
+
+ {t('update_verifying')}
+
+ )}
+
+ {!errored && downloading && !verifying && (
@@ -74,7 +106,7 @@ const UpdateBanner: React.FC = () => {
)}
- {!errored && !downloading && !downloaded && (
+ {!errored && !busy && !downloaded && (
state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts
index ade035f9..3daf95f9 100644
--- a/desktop/src/renderer/src/i18n.ts
+++ b/desktop/src/renderer/src/i18n.ts
@@ -72,6 +72,9 @@ const translations: Record> = {
update_available: '发现新版本',
update_download: '下载更新',
update_downloading: '正在下载',
+ update_preparing: '正在准备…',
+ update_verifying: '正在校验,即将完成…',
+ update_installing: '正在安装并重启,请稍候…',
update_restart: '重启以更新',
update_later: '稍后',
update_latest: '已是最新版本',
@@ -444,6 +447,9 @@ const translations: Record> = {
update_available: 'New version available',
update_download: 'Download update',
update_downloading: 'Downloading',
+ update_preparing: 'Preparing…',
+ update_verifying: 'Verifying, almost done…',
+ update_installing: 'Installing and restarting, please wait…',
update_restart: 'Restart to update',
update_later: 'Later',
update_latest: 'You are up to date',
diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx
index 92aa90b0..f099b6ac 100644
--- a/desktop/src/renderer/src/layout/NavRail.tsx
+++ b/desktop/src/renderer/src/layout/NavRail.tsx
@@ -155,9 +155,11 @@ const NavRail: React.FC = ({ onLangChange }) => {
const checkUpdate = () => {
setCheckedManually(true)
- // Re-open the update panel if an update is already known; also kicks a
- // fresh check. Closing the menu so the re-opened panel is visible.
- setMenuOpen(false)
+ // If an update is already known, recheck() re-opens its panel, so close the
+ // menu to reveal it. Otherwise keep the menu OPEN: the "up to date" result
+ // shows inline as the menu label — closing it (which resets checkedManually)
+ // is exactly what made the box flash and never show "up to date".
+ if (availableUpdate) setMenuOpen(false)
updateState.recheck()
}
diff --git a/desktop/src/renderer/src/store/updateStore.ts b/desktop/src/renderer/src/store/updateStore.ts
index 2ac4b48c..6dde6faf 100644
--- a/desktop/src/renderer/src/store/updateStore.ts
+++ b/desktop/src/renderer/src/store/updateStore.ts
@@ -8,6 +8,18 @@ interface UpdateState {
version: string | null
/** Download progress 0-100 while state === 'downloading'. */
percent: number
+ /** User clicked "download" but no progress event has arrived yet. Gives the
+ * button an instant busy state so it can't be clicked again during the 1-2s
+ * lead-up to the first progress event. Cleared on the first 'downloading'. */
+ preparing: boolean
+ /** The download progress already reached ~100% once. macOS (Squirrel.Mac)
+ * emits a SECOND progress pass (verify / block-map) after the first, which
+ * used to make the bar visibly restart from 0. Once peaked we render an
+ * indeterminate "verifying" state instead of a confusing second bar. */
+ progressPeaked: boolean
+ /** User clicked "restart to install"; show a full-screen "installing…"
+ * overlay for the brief window before the app quits to swap the bundle. */
+ installing: boolean
/** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null
/** Whether the update panel is currently shown. Lifted here so the "check
@@ -33,15 +45,27 @@ export const useUpdateStore = create((set, get) => ({
status: null,
version: null,
percent: 0,
+ preparing: false,
+ progressPeaked: false,
+ installing: false,
dismissedVersion: null,
panelOpen: false,
setStatus: (s) =>
- set(() => {
+ set((st) => {
// A newly detected version auto-opens the panel.
- if (s.state === 'available') return { status: s, version: s.version, percent: 0, panelOpen: true }
- if (s.state === 'downloading') return { status: s, percent: s.percent }
- if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
+ if (s.state === 'available')
+ return { status: s, version: s.version, percent: 0, preparing: false, progressPeaked: false, panelOpen: true }
+ if (s.state === 'downloading') {
+ // First real progress event clears the "preparing" busy state. Track
+ // when we've hit ~100% so the Squirrel.Mac second pass renders as an
+ // indeterminate "verifying" state instead of a bar restarting from 0.
+ const peaked = st.progressPeaked || s.percent >= 99
+ return { status: s, percent: s.percent, preparing: false, progressPeaked: peaked }
+ }
+ if (s.state === 'downloaded')
+ return { status: s, version: s.version, percent: 100, preparing: false }
+ if (s.state === 'error') return { status: s, preparing: false, installing: false }
return { status: s }
}),
@@ -50,18 +74,29 @@ export const useUpdateStore = create((set, get) => ({
closePanel: () => set({ panelOpen: false }),
recheck: () => {
- // Open the panel and clear any dismiss so the result of this explicit check
- // is always visible — available, error, or (via the menu label) up-to-date.
- // Previously the panel only opened when an update was already known, so a
- // download/check error rendered nothing and looked like a dead click.
- set({ dismissedVersion: null, panelOpen: true })
+ // Clear any dismiss so a known update surfaces again. Only re-open the panel
+ // when an update actually exists — if we're already up to date, opening the
+ // panel would just flash it (the banner renders nothing for not-available)
+ // and the "up to date" feedback belongs in the menu, not a panel. The menu
+ // (NavRail) decides whether to close itself + show the panel based on this.
+ const known = hasAvailableUpdate(get())
+ set({ dismissedVersion: null, panelOpen: known })
// Always kick a fresh check too (picks up newer versions / recovers errors).
// Pass the UI language so downloads route to the China CDN / R2 accordingly.
window.electronAPI?.checkForUpdate?.(getLang())
},
- download: () => window.electronAPI?.downloadUpdate?.(getLang()),
- install: () => window.electronAPI?.installUpdate?.(),
+ download: () => {
+ // Enter a busy state immediately so the button can't be clicked twice while
+ // we wait (1-2s) for the first download-progress event to arrive.
+ set({ preparing: true, progressPeaked: false })
+ window.electronAPI?.downloadUpdate?.(getLang())
+ },
+ install: () => {
+ // Show the "installing…" overlay before the app quits to swap the bundle.
+ set({ installing: true })
+ window.electronAPI?.installUpdate?.()
+ },
}))
// Subscribe to main-process update events. Returns an unsubscribe fn.