fix(desktop): smoother update UX

This commit is contained in:
zhayujie
2026-07-07 18:08:05 +08:00
parent 2e74295807
commit 583217d396
4 changed files with 91 additions and 16 deletions

View File

@@ -14,14 +14,32 @@ const UpdateBanner: React.FC = () => {
const status = state.status const status = state.status
const errored = status?.state === 'error' 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 (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-3 bg-base/90 backdrop-blur-sm">
<Loader2 size={28} className="animate-spin text-accent" />
<p className="text-sm text-content-secondary">{t('update_installing')}</p>
</div>
)
}
// Show the panel when it's open AND we either know of an update or hit an // 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 // error. Keeping it up on error is important: a failed download must surface
// a visible message instead of silently doing nothing. // a visible message instead of silently doing nothing.
if (!open || (!available && !errored)) return null if (!open || (!available && !errored)) return null
const version = state.version const version = state.version
const preparing = state.preparing
const downloading = status?.state === 'downloading' const downloading = status?.state === 'downloading'
const downloaded = status?.state === 'downloaded' 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 ( return (
<div className="absolute bottom-2 left-2 right-2 z-40"> <div className="absolute bottom-2 left-2 right-2 z-40">
@@ -62,7 +80,21 @@ const UpdateBanner: React.FC = () => {
</div> </div>
)} )}
{!errored && downloading && ( {!errored && preparing && (
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_preparing')}</span>
</div>
)}
{!errored && downloading && verifying && (
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_verifying')}</span>
</div>
)}
{!errored && downloading && !verifying && (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-content-secondary"> <div className="flex items-center gap-2 text-xs text-content-secondary">
<Loader2 size={13} className="animate-spin" /> <Loader2 size={13} className="animate-spin" />
@@ -74,7 +106,7 @@ const UpdateBanner: React.FC = () => {
</div> </div>
)} )}
{!errored && !downloading && !downloaded && ( {!errored && !busy && !downloaded && (
<button <button
onClick={() => state.download()} onClick={() => 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" 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"

View File

@@ -72,6 +72,9 @@ const translations: Record<string, Record<string, string>> = {
update_available: '发现新版本', update_available: '发现新版本',
update_download: '下载更新', update_download: '下载更新',
update_downloading: '正在下载', update_downloading: '正在下载',
update_preparing: '正在准备…',
update_verifying: '正在校验,即将完成…',
update_installing: '正在安装并重启,请稍候…',
update_restart: '重启以更新', update_restart: '重启以更新',
update_later: '稍后', update_later: '稍后',
update_latest: '已是最新版本', update_latest: '已是最新版本',
@@ -444,6 +447,9 @@ const translations: Record<string, Record<string, string>> = {
update_available: 'New version available', update_available: 'New version available',
update_download: 'Download update', update_download: 'Download update',
update_downloading: 'Downloading', update_downloading: 'Downloading',
update_preparing: 'Preparing…',
update_verifying: 'Verifying, almost done…',
update_installing: 'Installing and restarting, please wait…',
update_restart: 'Restart to update', update_restart: 'Restart to update',
update_later: 'Later', update_later: 'Later',
update_latest: 'You are up to date', update_latest: 'You are up to date',

View File

@@ -155,9 +155,11 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
const checkUpdate = () => { const checkUpdate = () => {
setCheckedManually(true) setCheckedManually(true)
// Re-open the update panel if an update is already known; also kicks a // If an update is already known, recheck() re-opens its panel, so close the
// fresh check. Closing the menu so the re-opened panel is visible. // menu to reveal it. Otherwise keep the menu OPEN: the "up to date" result
setMenuOpen(false) // 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() updateState.recheck()
} }

View File

@@ -8,6 +8,18 @@ interface UpdateState {
version: string | null version: string | null
/** Download progress 0-100 while state === 'downloading'. */ /** Download progress 0-100 while state === 'downloading'. */
percent: number 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). */ /** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null dismissedVersion: string | null
/** Whether the update panel is currently shown. Lifted here so the "check /** Whether the update panel is currently shown. Lifted here so the "check
@@ -33,15 +45,27 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
status: null, status: null,
version: null, version: null,
percent: 0, percent: 0,
preparing: false,
progressPeaked: false,
installing: false,
dismissedVersion: null, dismissedVersion: null,
panelOpen: false, panelOpen: false,
setStatus: (s) => setStatus: (s) =>
set(() => { set((st) => {
// A newly detected version auto-opens the panel. // 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 === 'available')
if (s.state === 'downloading') return { status: s, percent: s.percent } return { status: s, version: s.version, percent: 0, preparing: false, progressPeaked: false, panelOpen: true }
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 } 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 } return { status: s }
}), }),
@@ -50,18 +74,29 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
closePanel: () => set({ panelOpen: false }), closePanel: () => set({ panelOpen: false }),
recheck: () => { recheck: () => {
// Open the panel and clear any dismiss so the result of this explicit check // Clear any dismiss so a known update surfaces again. Only re-open the panel
// is always visible — available, error, or (via the menu label) up-to-date. // when an update actually exists — if we're already up to date, opening the
// Previously the panel only opened when an update was already known, so a // panel would just flash it (the banner renders nothing for not-available)
// download/check error rendered nothing and looked like a dead click. // and the "up to date" feedback belongs in the menu, not a panel. The menu
set({ dismissedVersion: null, panelOpen: true }) // (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). // 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. // Pass the UI language so downloads route to the China CDN / R2 accordingly.
window.electronAPI?.checkForUpdate?.(getLang()) window.electronAPI?.checkForUpdate?.(getLang())
}, },
download: () => window.electronAPI?.downloadUpdate?.(getLang()), download: () => {
install: () => window.electronAPI?.installUpdate?.(), // 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. // Subscribe to main-process update events. Returns an unsubscribe fn.