feat(updater): fixed-URL auto-update channel + silent retries
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled

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:
AnRil
2026-05-18 15:23:41 +07:00
parent c9d4fc237e
commit f861af5db1
8 changed files with 186 additions and 116 deletions

View File

@@ -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
}