feat(updater): fixed-URL auto-update channel + silent retries
The auto-update system used a per-version publish URL
(releases/download/v${version}), so each installed build only ever
checked its own release page for new versions. To deliver an update we
had to manually copy the new manifest into every old release — easy to
forget, and any half-uploaded state showed users red "check failed"
banners.
Architectural fix:
- New rolling 'update-channel' Gitea release. publish.url is now a
fixed path (.../releases/download/update-channel) that never moves.
- release.ps1 uploads each new build to three places:
1. vX.Y.Z (historical archive + changelog)
2. update-channel (what every client polls)
3. -BridgeTags (transition: also fill in old releases so users
still on those versions can find the new build)
- upload-release-assets.ps1 gains -AssetVersion to upload version-X.Y.Z
artifacts into a non-version tag (channel/bridge).
Resilience fixes for the updater itself:
- Hourly checks and the boot check now run in SILENT mode: network
errors don't promote to a red error state, they're logged and
retried on the next tick. Only user-initiated "Check now" surfaces
errors. This prevents the cascade of "Ошибка проверки" cards on
flaky networks or partial uploads.
- Boot check retries up to 3 times (30s/2m/5m backoff) before giving
up until the hourly tick.
- Track lastCheckedAt; "Up to date" subtitle now shows "checked Nm ago".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,31 @@ import { IPC } from '@shared/ipc'
|
||||
import type { UpdaterStatus } from '@shared/types'
|
||||
|
||||
let currentStatus: UpdaterStatus = { kind: 'idle' }
|
||||
let lastCheckedAt: number | undefined
|
||||
let wired = false
|
||||
let checkInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// User-initiated checks surface errors. Background checks stay quiet to avoid
|
||||
// the red banner on transient network blips (504s, DNS, captive portals).
|
||||
let silentMode = false
|
||||
|
||||
const CHECK_INTERVAL_MS = 60 * 60 * 1000 // every hour
|
||||
const BOOT_DELAY_MS = 5_000
|
||||
// Boot retry: if the first check fails (e.g. network not yet up), retry a few
|
||||
// times with exponential backoff before giving up until the hourly tick.
|
||||
const BOOT_RETRY_DELAYS = [30_000, 120_000, 300_000] // 30s, 2min, 5min
|
||||
|
||||
export function getUpdaterStatus(): UpdaterStatus {
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
function setStatus(s: UpdaterStatus): void {
|
||||
// Preserve lastCheckedAt across status transitions where applicable.
|
||||
if (s.kind === 'not-available' || s.kind === 'idle') {
|
||||
if (lastCheckedAt && !('lastCheckedAt' in s)) {
|
||||
;(s as { lastCheckedAt?: number }).lastCheckedAt = lastCheckedAt
|
||||
}
|
||||
}
|
||||
currentStatus = s
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
|
||||
@@ -38,9 +53,14 @@ export function initUpdater(): void {
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
autoUpdater.allowDowngrade = false
|
||||
|
||||
autoUpdater.on('checking-for-update', () => setStatus({ kind: 'checking' }))
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
// Don't replace the prior status with "checking" during silent polls — the
|
||||
// UI would briefly flicker for users opening Settings during a tick.
|
||||
if (!silentMode) setStatus({ kind: 'checking' })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
lastCheckedAt = Date.now()
|
||||
setStatus({
|
||||
kind: 'available',
|
||||
version: info.version,
|
||||
@@ -50,7 +70,12 @@ export function initUpdater(): void {
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
setStatus({ kind: 'not-available', currentVersion: app.getVersion() })
|
||||
lastCheckedAt = Date.now()
|
||||
setStatus({
|
||||
kind: 'not-available',
|
||||
currentVersion: app.getVersion(),
|
||||
lastCheckedAt
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (p) => {
|
||||
@@ -68,23 +93,43 @@ export function initUpdater(): void {
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (silentMode) {
|
||||
// Background check failed — keep previous status, don't show red banner.
|
||||
// Will retry on the next hourly tick.
|
||||
console.warn('[updater] silent check failed:', message)
|
||||
return
|
||||
}
|
||||
setStatus({ kind: 'error', message })
|
||||
})
|
||||
|
||||
// First check on boot (slight delay so window has time to subscribe).
|
||||
// First check on boot with retry-on-failure.
|
||||
setTimeout(() => {
|
||||
void checkForUpdates()
|
||||
}, 5_000)
|
||||
void bootCheckWithRetry()
|
||||
}, BOOT_DELAY_MS)
|
||||
|
||||
// Periodic re-check
|
||||
// Periodic re-check (silent).
|
||||
checkInterval = setInterval(() => {
|
||||
void checkForUpdates()
|
||||
void checkForUpdates({ silent: true })
|
||||
}, CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function bootCheckWithRetry(): Promise<void> {
|
||||
for (let attempt = 0; attempt <= BOOT_RETRY_DELAYS.length; attempt++) {
|
||||
await checkForUpdates({ silent: true })
|
||||
if (
|
||||
currentStatus.kind === 'available' ||
|
||||
currentStatus.kind === 'not-available' ||
|
||||
currentStatus.kind === 'downloaded'
|
||||
) {
|
||||
return // success
|
||||
}
|
||||
const delay = BOOT_RETRY_DELAYS[attempt]
|
||||
if (delay === undefined) return // exhausted retries
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
|
||||
export function stopUpdater(): void {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval)
|
||||
@@ -92,15 +137,22 @@ export function stopUpdater(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdates(): Promise<UpdaterStatus> {
|
||||
export async function checkForUpdates(
|
||||
opts: { silent?: boolean } = {}
|
||||
): Promise<UpdaterStatus> {
|
||||
if (!app.isPackaged) return currentStatus
|
||||
silentMode = opts.silent ?? false
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (silentMode) {
|
||||
console.warn('[updater] silent check failed (sync):', message)
|
||||
} else {
|
||||
setStatus({ kind: 'error', message })
|
||||
}
|
||||
} finally {
|
||||
silentMode = false
|
||||
}
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user