fix(desktop): auto-update install now actually replaces the app

This commit is contained in:
zhayujie
2026-07-07 17:23:10 +08:00
parent 93bf8844de
commit 2e74295807
5 changed files with 62 additions and 18 deletions

View File

@@ -253,7 +253,14 @@ function setupIPC() {
setUpdateLanguage(lang)
startDownload()
})
ipcMain.handle('update-install', () => quitAndInstall())
ipcMain.handle('update-install', () => {
// Let the window actually close so the app can fully quit — otherwise the
// close-to-tray handler preventDefault()s it, the process stays alive, and
// Squirrel.Mac can't swap the app bundle (the update silently no-ops and
// relaunching still shows the old version).
isQuitting = true
quitAndInstall()
})
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
// to pick a sensible default UI language on first run before any paint.

View File

@@ -229,6 +229,16 @@ function attemptDownload(): void {
export function quitAndInstall(): void {
if (!app.isPackaged) return
log('quitAndInstall: relaunching to install update')
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
autoUpdater.quitAndInstall(false, true)
// isSilent MUST be true on Windows: our NSIS installer is an "assisted"
// installer (oneClick:false + allowToChangeInstallationDirectory), so a
// non-silent update re-shows the install-dir / install-mode wizard instead of
// updating in place. Silent skips the wizard and updates directly. macOS
// (Squirrel.Mac) ignores isSilent, so false there is fine.
// isForceRunAfter=true relaunches the app once the install finishes.
// Drop window-all-closed handlers first: with an assisted NSIS installer a
// lingering handler can keep the process alive and stop the installer from
// replacing files / relaunching (a documented electron-updater gotcha).
app.removeAllListeners('window-all-closed')
const isSilent = process.platform === 'win32'
autoUpdater.quitAndInstall(isSilent, true)
}

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react'
import { t } from '../i18n'
import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
@@ -12,9 +12,12 @@ const UpdateBanner: React.FC = () => {
const available = hasAvailableUpdate(state)
const status = state.status
const errored = status?.state === 'error'
// Render nothing when there's no update, or the panel is closed (dismissed).
if (!available || !open) return null
// 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 downloading = status?.state === 'downloading'
@@ -25,8 +28,12 @@ const UpdateBanner: React.FC = () => {
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
<p className="text-[13px] font-semibold text-content">
{errored ? t('update_failed') : t('update_available')}
</p>
{!errored && version && (
<p className="text-xs text-content-tertiary mt-0.5">v{version}</p>
)}
</div>
<button
onClick={() => state.dismiss()}
@@ -37,7 +44,25 @@ const UpdateBanner: React.FC = () => {
</button>
</div>
{downloading && (
{errored && (
<div className="space-y-2.5">
<div className="flex items-start gap-2 text-xs text-content-secondary">
<AlertTriangle size={13} className="text-amber-500 flex-shrink-0 mt-0.5" />
<span className="break-words">
{status?.state === 'error' ? status.message : ''}
</span>
</div>
<button
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"
>
<RefreshCw size={15} />
{t('update_retry')}
</button>
</div>
)}
{!errored && downloading && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-content-secondary">
<Loader2 size={13} className="animate-spin" />
@@ -49,7 +74,7 @@ const UpdateBanner: React.FC = () => {
</div>
)}
{!downloading && !downloaded && (
{!errored && !downloading && !downloaded && (
<button
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"
@@ -59,7 +84,7 @@ const UpdateBanner: React.FC = () => {
</button>
)}
{downloaded && (
{!errored && downloaded && (
<button
onClick={() => state.install()}
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

@@ -77,6 +77,8 @@ const translations: Record<string, Record<string, string>> = {
update_latest: '已是最新版本',
update_check: '检查更新',
update_checking: '正在检查…',
update_failed: '更新失败',
update_retry: '重试',
menu_more: '更多',
menu_theme_light: '浅色模式',
menu_theme_dark: '深色模式',
@@ -447,6 +449,8 @@ const translations: Record<string, Record<string, string>> = {
update_latest: 'You are up to date',
update_check: 'Check for updates',
update_checking: 'Checking…',
update_failed: 'Update failed',
update_retry: 'Retry',
menu_more: 'More',
menu_theme_light: 'Light mode',
menu_theme_dark: 'Dark mode',

View File

@@ -50,13 +50,11 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
closePanel: () => set({ panelOpen: false }),
recheck: () => {
const st = get()
// If we already know about an available/downloaded update, just re-open the
// panel (clearing the dismiss for this version) instead of waiting on a
// network round-trip — the user asked to see it.
if (hasAvailableUpdate(st)) {
set({ dismissedVersion: null, panelOpen: true })
}
// 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 })
// 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())