From f861af5db1ca262af3b17ec16257742403927d6c Mon Sep 17 00:00:00 2001 From: AnRil Date: Mon, 18 May 2026 15:23:41 +0700 Subject: [PATCH] feat(updater): fixed-URL auto-update channel + silent retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- scripts/release.ps1 | 139 ++++++++------------ scripts/upload-release-assets.ps1 | 43 ++++-- src/main/updater.ts | 84 +++++++++--- src/renderer/src/components/UpdaterCard.tsx | 19 ++- src/renderer/src/i18n/dict.ts | 10 ++ src/renderer/src/i18n/index.ts | 1 + src/shared/types.ts | 4 +- 8 files changed, 186 insertions(+), 116 deletions(-) diff --git a/package.json b/package.json index 73f170f..e043b04 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ }, "publish": { "provider": "generic", - "url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/v${version}", + "url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/update-channel", "channel": "latest" } } diff --git a/scripts/release.ps1 b/scripts/release.ps1 index 6a518c7..c6b4b2a 100644 --- a/scripts/release.ps1 +++ b/scripts/release.ps1 @@ -1,67 +1,78 @@ <# .SYNOPSIS - Локальный релиз: бамп версии → коммит → тег → push → сборка → upload в Gitea release. + Локальный релиз: бамп версии -> коммит -> тег -> push -> сборка -> upload в Gitea. .DESCRIPTION - Один скрипт от и до. Если Gitea Actions не настроено, это рабочая альтернатива. + Single-command release flow. + + Каждый релиз публикует артефакты в ТРИ места: + 1. Тег vX.Y.Z (исторический архив + changelog) + 2. Тег update-channel (фиксированный URL для auto-updater) + 3. Bridge-теги, указанные в -BridgeTags (для миграции пользователей со + старых версий, у которых запечён старый publish.url). + + После того как все пользователи получили версию с новым (фиксированным) + publish.url, аргумент -BridgeTags можно перестать указывать. .PARAMETER Bump Какую часть semver инкрементировать: patch (по умолчанию), minor, major. - Альтернатива — указать -Version явно. .PARAMETER Version - Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется. + Точная версия (напр. "0.5.1"). Если задана, -Bump игнорируется. .PARAMETER SkipBuild Пропустить сборку (если уже собрано вручную, .exe лежит в release/). +.PARAMETER BridgeTags + Список старых тегов, в которые нужно ТАКЖЕ перезалить новые артефакты, + чтобы пользователи на этих версиях нашли апдейт. Например: v0.4.0,v0.5.0. + .PARAMETER DryRun - Показать что произойдёт, но ничего не делать. + Показать что произойдёт, ничего не делая. .EXAMPLE - pwsh scripts/release.ps1 -Bump minor - pwsh scripts/release.ps1 -Version 0.3.0 - pwsh scripts/release.ps1 -Bump patch -DryRun + pwsh scripts/release.ps1 -Bump patch + pwsh scripts/release.ps1 -Version 0.5.1 -BridgeTags v0.4.0,v0.5.0 .NOTES - Требует переменную окружения GITEA_TOKEN с правом write:repository - (создаётся в Gitea: Settings → Applications → Generate New Token). + Требует GITEA_TOKEN с правом write:repository. + Канал 'update-channel' должен существовать на Gitea (создаётся однократно). #> param( [ValidateSet('patch', 'minor', 'major')] [string]$Bump = 'patch', [string]$Version, [switch]$SkipBuild, + [string[]]$BridgeTags = @(), [switch]$DryRun ) $ErrorActionPreference = 'Stop' -# --- Config --------------------------------------------------------------- $repoOwner = 'AnRil' $repoName = 'laude' $giteaHost = 'xn--90adajar8af4h.xn--p1ai/git' -$apiBase = "https://$giteaHost/api/v1" +$channelTag = 'update-channel' -# --- Pre-flight checks --------------------------------------------------- +# --- Pre-flight ---------------------------------------------------------- $root = Resolve-Path (Join-Path $PSScriptRoot '..') Set-Location $root if (-not $env:GITEA_TOKEN -and -not $DryRun) { - Write-Error 'GITEA_TOKEN не задан. Создай в Gitea Settings → Applications и export GITEA_TOKEN=...' + Write-Error 'GITEA_TOKEN not set.' exit 1 } $status = git status --porcelain if ($status) { - Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash." + Write-Error "Uncommitted changes. Commit or stash first." exit 1 } $branch = git rev-parse --abbrev-ref HEAD if ($branch -ne 'main') { - Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)" - Read-Host 'Press Enter' + Write-Warning "Branch is $branch, not main. Press Enter to continue or Ctrl+C to cancel." + Read-Host } # --- Compute next version ------------------------------------------------ @@ -83,109 +94,69 @@ if ($Version) { $tag = "v$next" Write-Host "" -Write-Host "→ Release plan" -ForegroundColor Cyan -Write-Host " current : v$current" -Write-Host " next : $tag" -Write-Host " bump : $Bump" +Write-Host "Release plan" -ForegroundColor Cyan +Write-Host " current : v$current" +Write-Host " next : $tag" +Write-Host " publish into : $tag, $channelTag$(if ($BridgeTags) { ', ' + ($BridgeTags -join ', ') })" Write-Host "" if ($DryRun) { - Write-Host '(dry run — exiting)' -ForegroundColor Yellow + Write-Host '(dry run - exiting)' -ForegroundColor Yellow exit 0 } -# --- Bump version in package.json --------------------------------------- -Write-Host "→ Bumping package.json to $next…" -ForegroundColor Cyan +# --- Bump package.json -------------------------------------------------- +Write-Host "Bumping package.json to $next..." -ForegroundColor Cyan $pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`"" Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8 git add package.json git commit -m "chore(release): $tag" -# --- Build (typecheck + tests + dist) ------------------------------------ +# --- Quality gates ------------------------------------------------------ if (-not $SkipBuild) { - Write-Host "→ Running typecheck…" -ForegroundColor Cyan + Write-Host "Typecheck..." -ForegroundColor Cyan npm run typecheck if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - Write-Host "→ Running tests…" -ForegroundColor Cyan + Write-Host "Tests..." -ForegroundColor Cyan npm run test:run if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - Write-Host "→ Building installer (npm run dist)…" -ForegroundColor Cyan + Write-Host "Building installer..." -ForegroundColor Cyan npm run dist if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } -# --- Verify artifacts exist --------------------------------------------- +# --- Verify artifacts --------------------------------------------------- $installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe" $blockmap = "$installer.blockmap" $manifest = Join-Path 'release' 'latest.yml' foreach ($f in @($installer, $blockmap, $manifest)) { if (-not (Test-Path $f)) { - Write-Error "Не найден артефакт: $f" + Write-Error "Artifact missing: $f" exit 1 } } -# --- Tag + push ---------------------------------------------------------- -Write-Host "→ Tagging $tag and pushing…" -ForegroundColor Cyan +# --- Tag + push --------------------------------------------------------- +Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan git tag -a $tag -m "Release $tag" git push origin main git push origin $tag -# --- Create release via Gitea API ---------------------------------------- -Write-Host "→ Creating Gitea release $tag…" -ForegroundColor Cyan -$headers = @{ - Authorization = "token $env:GITEA_TOKEN" - Accept = 'application/json' -} +# --- Upload to all target releases -------------------------------------- +$uploadScript = Join-Path $PSScriptRoot 'upload-release-assets.ps1' -# Release notes from commits since previous tag -$prev = git describe --tags --abbrev=0 "$tag^" 2>$null -if ($prev) { - $log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String -} else { - $log = git log --pretty=format:"- %s" "$tag" | Out-String -} -$body = @" -### Изменения - -$log - ---- - -**Установщик ниже** — запустить и следовать мастеру. Если приложение уже стояло — обновится поверх, настройки сохранятся. -"@ - -$releaseBody = @{ - tag_name = $tag - name = "Exercise Reminder $tag" - body = $body - draft = $false - prerelease = $false -} | ConvertTo-Json -Depth 5 - -$release = Invoke-RestMethod ` - -Uri "$apiBase/repos/$repoOwner/$repoName/releases" ` - -Method Post ` - -Headers $headers ` - -Body $releaseBody ` - -ContentType 'application/json' - -Write-Host " Release id: $($release.id)" -ForegroundColor DarkGray - -# --- Upload assets ------------------------------------------------------- -foreach ($asset in @($installer, $blockmap, $manifest)) { - $name = Split-Path $asset -Leaf - Write-Host "→ Uploading $name…" -ForegroundColor Cyan - $uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))" - Invoke-RestMethod ` - -Uri $uri ` - -Method Post ` - -Headers $headers ` - -InFile $asset ` - -ContentType 'application/octet-stream' | Out-Null +$targets = @($tag, $channelTag) + $BridgeTags +foreach ($target in $targets) { + Write-Host "" + Write-Host "==> Uploading $next artifacts into release '$target'" -ForegroundColor Cyan + & powershell -ExecutionPolicy Bypass -File $uploadScript -Tag $target -AssetVersion $next + if ($LASTEXITCODE -ne 0) { + Write-Error "Upload to '$target' failed (exit $LASTEXITCODE)" + exit $LASTEXITCODE + } } $releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag" @@ -193,4 +164,4 @@ Write-Host "" Write-Host "Release published" -ForegroundColor Green Write-Host " $releaseUrl" Write-Host "" -Write-Host "Auto-updater подхватит обновление на установленных копиях в течение ~6 часов." +Write-Host "Auto-updater will pick up the new version within ~1 hour on all installed copies." diff --git a/scripts/upload-release-assets.ps1 b/scripts/upload-release-assets.ps1 index b600d08..4f8dc2c 100644 --- a/scripts/upload-release-assets.ps1 +++ b/scripts/upload-release-assets.ps1 @@ -1,22 +1,31 @@ <# .SYNOPSIS - Upload pre-built NSIS artifacts to an existing Gitea release. + Upload pre-built NSIS artifacts to a Gitea release. .DESCRIPTION - Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to - push but failed on upload, or release was created manually without assets). - If a release for the tag does not exist yet, it is created. If it exists, - same-name assets are replaced. + Uploads installer + blockmap + latest.yml to the release identified by -Tag. + If the release does not exist it is created (only for semver-looking tags; + for non-semver tags like 'update-channel' the release must exist already). + Same-named existing assets are replaced. .PARAMETER Tag - Version tag, e.g. v0.3.0. Defaults to v. + Release tag to upload INTO. May be a version tag (v0.5.1) or a channel + tag (update-channel). Defaults to v. + +.PARAMETER AssetVersion + Version of the artifacts being uploaded (e.g. 0.5.1). Defaults to the + numeric part of -Tag. Specify explicitly when uploading version-X.Y.Z + artifacts into a non-version tag (channel or bridge). .EXAMPLE pwsh scripts/upload-release-assets.ps1 - pwsh scripts/upload-release-assets.ps1 -Tag v0.3.0 + pwsh scripts/upload-release-assets.ps1 -Tag v0.5.0 + pwsh scripts/upload-release-assets.ps1 -Tag update-channel -AssetVersion 0.5.1 + pwsh scripts/upload-release-assets.ps1 -Tag v0.4.0 -AssetVersion 0.5.1 #> param( - [string]$Tag + [string]$Tag, + [string]$AssetVersion ) $ErrorActionPreference = 'Stop' @@ -35,10 +44,18 @@ $root = Resolve-Path (Join-Path $PSScriptRoot '..') Set-Location $root if (-not $Tag) { - $version = (Get-Content package.json | ConvertFrom-Json).version - $Tag = "v$version" + $pkgVersion = (Get-Content package.json | ConvertFrom-Json).version + $Tag = "v$pkgVersion" } -$version = $Tag.TrimStart('v') +if (-not $AssetVersion) { + # Derive from tag when possible (vX.Y.Z → X.Y.Z); otherwise read package.json. + if ($Tag -match '^v\d+\.\d+\.\d+') { + $AssetVersion = $Tag.TrimStart('v') + } else { + $AssetVersion = (Get-Content package.json | ConvertFrom-Json).version + } +} +$version = $AssetVersion $installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe" $blockmap = "$installer.blockmap" @@ -66,6 +83,10 @@ try { Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray } catch { if ($_.Exception.Response.StatusCode.value__ -eq 404) { + if ($Tag -notmatch '^v\d+\.\d+\.\d+') { + Write-Error "Release '$Tag' not found and tag is not semver. Create it manually on Gitea (e.g. 'update-channel' is a one-time setup)." + exit 1 + } Write-Host " Not found, creating new release..." -ForegroundColor DarkGray $prev = $null diff --git a/src/main/updater.ts b/src/main/updater.ts index 2615d78..56c2f18 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 { + 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 { +export async function checkForUpdates( + opts: { silent?: boolean } = {} +): Promise { 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 } diff --git a/src/renderer/src/components/UpdaterCard.tsx b/src/renderer/src/components/UpdaterCard.tsx index 72715ba..5db64b1 100644 --- a/src/renderer/src/components/UpdaterCard.tsx +++ b/src/renderer/src/components/UpdaterCard.tsx @@ -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({ 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 ( } title={t('updater.up_to_date')} - subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })} + subtitle={subtitle} action={