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

@@ -10,9 +10,18 @@ import {
import { motion } from 'framer-motion'
import { Button } from './ui/Button'
import { Card } from './ui/Card'
import { useT } from '../i18n'
import { useT, type TFn } from '../i18n'
import type { UpdaterStatus } from '@shared/types'
function formatChecked(ts: number, t: TFn): string {
const diffMs = Date.now() - ts
const diffMin = Math.max(0, Math.round(diffMs / 60_000))
if (diffMin < 1) return t('updater.checked.just_now')
if (diffMin < 60) return t('updater.checked.minutes_ago', { n: diffMin })
const diffH = Math.round(diffMin / 60)
return t('updater.checked.hours_ago', { n: diffH })
}
export function UpdaterCard(): JSX.Element {
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
const [busy, setBusy] = useState(false)
@@ -96,12 +105,18 @@ function Body({
)
}
if (status.kind === 'not-available') {
const subtitle = status.lastCheckedAt
? t('updater.up_to_date.subtitle_checked', {
v: status.currentVersion,
when: formatChecked(status.lastCheckedAt, t)
})
: t('updater.up_to_date.subtitle', { v: status.currentVersion })
return (
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={t('updater.up_to_date')}
subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })}
subtitle={subtitle}
action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}

View File

@@ -185,6 +185,11 @@ export const ru: Dict = {
'updater.checking': 'Проверяем обновления…',
'updater.up_to_date': 'Последняя версия',
'updater.up_to_date.subtitle': 'Текущая: v{v}',
'updater.up_to_date.subtitle_checked': 'Текущая: v{v} · проверено {when}',
'updater.last_checked': 'проверено {when}',
'updater.checked.just_now': 'только что',
'updater.checked.minutes_ago': '{n} мин назад',
'updater.checked.hours_ago': '{n} ч назад',
'updater.available.title': 'Доступна v{v}',
'updater.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
@@ -398,6 +403,11 @@ export const en: Dict = {
'updater.checking': 'Checking for updates…',
'updater.up_to_date': 'Up to date',
'updater.up_to_date.subtitle': 'Current: v{v}',
'updater.up_to_date.subtitle_checked': 'Current: v{v} · checked {when}',
'updater.last_checked': 'checked {when}',
'updater.checked.just_now': 'just now',
'updater.checked.minutes_ago': '{n}m ago',
'updater.checked.hours_ago': '{n}h ago',
'updater.available.title': 'v{v} available',
'updater.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',

View File

@@ -9,6 +9,7 @@ export function getDict(lang: Language): Dict {
}
export type TVars = Record<string, string | number>
export type TFn = (key: string, vars?: TVars) => string
/**
* Look up a key in the dictionary, substitute `{var}` placeholders.