3 Commits

Author SHA1 Message Date
AnRil
3f038e59e8 chore(release): v0.5.1
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
2026-05-18 15:25:17 +07:00
AnRil
33e237948e fix(release): write package.json as UTF-8 without BOM
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
PS5.1 Get-Content -Raw without -Encoding utf8 reads in CP1251, mangling
non-ASCII like em-dash. Set-Content -Encoding utf8 writes a BOM that
breaks PostCSS / electron-builder reads of package.json.

Use .NET ReadAllText/WriteAllText with UTF8Encoding(false) to guarantee
roundtrip-safe UTF-8 without BOM.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:25:04 +07:00
AnRil
f861af5db1 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>
2026-05-18 15:23:41 +07:00
8 changed files with 195 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.0", "version": "0.5.1",
"description": "Exercise reminder — Windows desktop app", "description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "AnRil", "author": "AnRil",
@@ -88,7 +88,7 @@
}, },
"publish": { "publish": {
"provider": "generic", "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" "channel": "latest"
} }
} }

View File

@@ -1,67 +1,78 @@
<# <#
.SYNOPSIS .SYNOPSIS
Локальный релиз: бамп версии коммит тег push сборка upload в Gitea release. Локальный релиз: бамп версии -> коммит -> тег -> push -> сборка -> upload в Gitea.
.DESCRIPTION .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 .PARAMETER Bump
Какую часть semver инкрементировать: patch (по умолчанию), minor, major. Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
Альтернатива — указать -Version явно.
.PARAMETER Version .PARAMETER Version
Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется. Точная версия (напр. "0.5.1"). Если задана, -Bump игнорируется.
.PARAMETER SkipBuild .PARAMETER SkipBuild
Пропустить сборку (если уже собрано вручную, .exe лежит в release/). Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
.PARAMETER BridgeTags
Список старых тегов, в которые нужно ТАКЖЕ перезалить новые артефакты,
чтобы пользователи на этих версиях нашли апдейт. Например: v0.4.0,v0.5.0.
.PARAMETER DryRun .PARAMETER DryRun
Показать что произойдёт, но ничего не делать. Показать что произойдёт, ничего не делая.
.EXAMPLE .EXAMPLE
pwsh scripts/release.ps1 -Bump minor pwsh scripts/release.ps1 -Bump patch
pwsh scripts/release.ps1 -Version 0.3.0 pwsh scripts/release.ps1 -Version 0.5.1 -BridgeTags v0.4.0,v0.5.0
pwsh scripts/release.ps1 -Bump patch -DryRun
.NOTES .NOTES
Требует переменную окружения GITEA_TOKEN с правом write:repository Требует GITEA_TOKEN с правом write:repository.
(создаётся в Gitea: Settings → Applications → Generate New Token). Канал 'update-channel' должен существовать на Gitea (создаётся однократно).
#> #>
param( param(
[ValidateSet('patch', 'minor', 'major')] [ValidateSet('patch', 'minor', 'major')]
[string]$Bump = 'patch', [string]$Bump = 'patch',
[string]$Version, [string]$Version,
[switch]$SkipBuild, [switch]$SkipBuild,
[string[]]$BridgeTags = @(),
[switch]$DryRun [switch]$DryRun
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# --- Config ---------------------------------------------------------------
$repoOwner = 'AnRil' $repoOwner = 'AnRil'
$repoName = 'laude' $repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git' $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 '..') $root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root Set-Location $root
if (-not $env:GITEA_TOKEN -and -not $DryRun) { 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 exit 1
} }
$status = git status --porcelain $status = git status --porcelain
if ($status) { if ($status) {
Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash." Write-Error "Uncommitted changes. Commit or stash first."
exit 1 exit 1
} }
$branch = git rev-parse --abbrev-ref HEAD $branch = git rev-parse --abbrev-ref HEAD
if ($branch -ne 'main') { if ($branch -ne 'main') {
Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)" Write-Warning "Branch is $branch, not main. Press Enter to continue or Ctrl+C to cancel."
Read-Host 'Press Enter' Read-Host
} }
# --- Compute next version ------------------------------------------------ # --- Compute next version ------------------------------------------------
@@ -83,109 +94,75 @@ if ($Version) {
$tag = "v$next" $tag = "v$next"
Write-Host "" Write-Host ""
Write-Host "Release plan" -ForegroundColor Cyan Write-Host "Release plan" -ForegroundColor Cyan
Write-Host " current : v$current" Write-Host " current : v$current"
Write-Host " next : $tag" Write-Host " next : $tag"
Write-Host " bump : $Bump" Write-Host " publish into : $tag, $channelTag$(if ($BridgeTags) { ', ' + ($BridgeTags -join ', ') })"
Write-Host "" Write-Host ""
if ($DryRun) { if ($DryRun) {
Write-Host '(dry run exiting)' -ForegroundColor Yellow Write-Host '(dry run - exiting)' -ForegroundColor Yellow
exit 0 exit 0
} }
# --- Bump version in package.json --------------------------------------- # --- Bump package.json --------------------------------------------------
Write-Host "→ Bumping package.json to $next" -ForegroundColor Cyan # IMPORTANT: read+write as UTF-8 WITHOUT BOM. PS5.1 defaults will (a) read the
$pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`"" # file as CP1251 and mangle non-ASCII chars like em-dash, and (b) write back
Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8 # with a BOM that breaks PostCSS / electron-builder reads of package.json.
Write-Host "Bumping package.json to $next..." -ForegroundColor Cyan
$pkgPath = Join-Path $root 'package.json'
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$pkgJson = [System.IO.File]::ReadAllText($pkgPath, $utf8NoBom)
$pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
[System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom)
git add package.json git add package.json
git commit -m "chore(release): $tag" git commit -m "chore(release): $tag"
# --- Build (typecheck + tests + dist) ------------------------------------ # --- Quality gates ------------------------------------------------------
if (-not $SkipBuild) { if (-not $SkipBuild) {
Write-Host "→ Running typecheck" -ForegroundColor Cyan Write-Host "Typecheck..." -ForegroundColor Cyan
npm run typecheck npm run typecheck
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "→ Running tests" -ForegroundColor Cyan Write-Host "Tests..." -ForegroundColor Cyan
npm run test:run npm run test:run
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Building installer (npm run dist)…" -ForegroundColor Cyan Write-Host "Building installer..." -ForegroundColor Cyan
npm run dist npm run dist
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} }
# --- Verify artifacts exist --------------------------------------------- # --- Verify artifacts ---------------------------------------------------
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe" $installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
$blockmap = "$installer.blockmap" $blockmap = "$installer.blockmap"
$manifest = Join-Path 'release' 'latest.yml' $manifest = Join-Path 'release' 'latest.yml'
foreach ($f in @($installer, $blockmap, $manifest)) { foreach ($f in @($installer, $blockmap, $manifest)) {
if (-not (Test-Path $f)) { if (-not (Test-Path $f)) {
Write-Error "Не найден артефакт: $f" Write-Error "Artifact missing: $f"
exit 1 exit 1
} }
} }
# --- Tag + push ---------------------------------------------------------- # --- Tag + push ---------------------------------------------------------
Write-Host "Tagging $tag and pushing" -ForegroundColor Cyan Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
git tag -a $tag -m "Release $tag" git tag -a $tag -m "Release $tag"
git push origin main git push origin main
git push origin $tag git push origin $tag
# --- Create release via Gitea API ---------------------------------------- # --- Upload to all target releases --------------------------------------
Write-Host "→ Creating Gitea release $tag" -ForegroundColor Cyan $uploadScript = Join-Path $PSScriptRoot 'upload-release-assets.ps1'
$headers = @{
Authorization = "token $env:GITEA_TOKEN"
Accept = 'application/json'
}
# Release notes from commits since previous tag $targets = @($tag, $channelTag) + $BridgeTags
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null foreach ($target in $targets) {
if ($prev) { Write-Host ""
$log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String Write-Host "==> Uploading $next artifacts into release '$target'" -ForegroundColor Cyan
} else { & powershell -ExecutionPolicy Bypass -File $uploadScript -Tag $target -AssetVersion $next
$log = git log --pretty=format:"- %s" "$tag" | Out-String if ($LASTEXITCODE -ne 0) {
} Write-Error "Upload to '$target' failed (exit $LASTEXITCODE)"
$body = @" exit $LASTEXITCODE
### Изменения }
$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
} }
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag" $releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag"
@@ -193,4 +170,4 @@ Write-Host ""
Write-Host "Release published" -ForegroundColor Green Write-Host "Release published" -ForegroundColor Green
Write-Host " $releaseUrl" Write-Host " $releaseUrl"
Write-Host "" Write-Host ""
Write-Host "Auto-updater подхватит обновление на установленных копиях в течение ~6 часов." Write-Host "Auto-updater will pick up the new version within ~1 hour on all installed copies."

View File

@@ -1,22 +1,31 @@
<# <#
.SYNOPSIS .SYNOPSIS
Upload pre-built NSIS artifacts to an existing Gitea release. Upload pre-built NSIS artifacts to a Gitea release.
.DESCRIPTION .DESCRIPTION
Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to Uploads installer + blockmap + latest.yml to the release identified by -Tag.
push but failed on upload, or release was created manually without assets). If the release does not exist it is created (only for semver-looking tags;
If a release for the tag does not exist yet, it is created. If it exists, for non-semver tags like 'update-channel' the release must exist already).
same-name assets are replaced. Same-named existing assets are replaced.
.PARAMETER Tag .PARAMETER Tag
Version tag, e.g. v0.3.0. Defaults to v<package.json version>. Release tag to upload INTO. May be a version tag (v0.5.1) or a channel
tag (update-channel). Defaults to v<package.json version>.
.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 .EXAMPLE
pwsh scripts/upload-release-assets.ps1 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( param(
[string]$Tag [string]$Tag,
[string]$AssetVersion
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -35,10 +44,18 @@ $root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root Set-Location $root
if (-not $Tag) { if (-not $Tag) {
$version = (Get-Content package.json | ConvertFrom-Json).version $pkgVersion = (Get-Content package.json | ConvertFrom-Json).version
$Tag = "v$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" $installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe"
$blockmap = "$installer.blockmap" $blockmap = "$installer.blockmap"
@@ -66,6 +83,10 @@ try {
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
} catch { } catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) { 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 Write-Host " Not found, creating new release..." -ForegroundColor DarkGray
$prev = $null $prev = $null

View File

@@ -4,16 +4,31 @@ import { IPC } from '@shared/ipc'
import type { UpdaterStatus } from '@shared/types' import type { UpdaterStatus } from '@shared/types'
let currentStatus: UpdaterStatus = { kind: 'idle' } let currentStatus: UpdaterStatus = { kind: 'idle' }
let lastCheckedAt: number | undefined
let wired = false let wired = false
let checkInterval: NodeJS.Timeout | null = null 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 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 { export function getUpdaterStatus(): UpdaterStatus {
return currentStatus return currentStatus
} }
function setStatus(s: UpdaterStatus): void { 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 currentStatus = s
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s) if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
@@ -38,9 +53,14 @@ export function initUpdater(): void {
autoUpdater.autoInstallOnAppQuit = true autoUpdater.autoInstallOnAppQuit = true
autoUpdater.allowDowngrade = false 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) => { autoUpdater.on('update-available', (info) => {
lastCheckedAt = Date.now()
setStatus({ setStatus({
kind: 'available', kind: 'available',
version: info.version, version: info.version,
@@ -50,7 +70,12 @@ export function initUpdater(): void {
}) })
autoUpdater.on('update-not-available', () => { 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) => { autoUpdater.on('download-progress', (p) => {
@@ -68,23 +93,43 @@ export function initUpdater(): void {
}) })
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err) => {
setStatus({ const message = err instanceof Error ? err.message : String(err)
kind: 'error', if (silentMode) {
message: err instanceof Error ? err.message : String(err) // 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(() => { setTimeout(() => {
void checkForUpdates() void bootCheckWithRetry()
}, 5_000) }, BOOT_DELAY_MS)
// Periodic re-check // Periodic re-check (silent).
checkInterval = setInterval(() => { checkInterval = setInterval(() => {
void checkForUpdates() void checkForUpdates({ silent: true })
}, CHECK_INTERVAL_MS) }, 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 { export function stopUpdater(): void {
if (checkInterval) { if (checkInterval) {
clearInterval(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 if (!app.isPackaged) return currentStatus
silentMode = opts.silent ?? false
try { try {
await autoUpdater.checkForUpdates() await autoUpdater.checkForUpdates()
} catch (err) { } catch (err) {
setStatus({ const message = err instanceof Error ? err.message : String(err)
kind: 'error', if (silentMode) {
message: err instanceof Error ? err.message : String(err) console.warn('[updater] silent check failed (sync):', message)
}) } else {
setStatus({ kind: 'error', message })
}
} finally {
silentMode = false
} }
return currentStatus return currentStatus
} }

View File

@@ -10,9 +10,18 @@ import {
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Card } from './ui/Card' import { Card } from './ui/Card'
import { useT } from '../i18n' import { useT, type TFn } from '../i18n'
import type { UpdaterStatus } from '@shared/types' 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 { export function UpdaterCard(): JSX.Element {
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' }) const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -96,12 +105,18 @@ function Body({
) )
} }
if (status.kind === 'not-available') { 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 ( return (
<Cell <Cell
tone="success" tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />} icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={t('updater.up_to_date')} title={t('updater.up_to_date')}
subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })} subtitle={subtitle}
action={ action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}> <Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')} <RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}

View File

@@ -185,6 +185,11 @@ export const ru: Dict = {
'updater.checking': 'Проверяем обновления…', 'updater.checking': 'Проверяем обновления…',
'updater.up_to_date': 'Последняя версия', 'updater.up_to_date': 'Последняя версия',
'updater.up_to_date.subtitle': 'Текущая: v{v}', '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.available.title': 'Доступна v{v}',
'updater.downloading.title': 'Загружаем обновление', 'updater.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с', 'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
@@ -398,6 +403,11 @@ export const en: Dict = {
'updater.checking': 'Checking for updates…', 'updater.checking': 'Checking for updates…',
'updater.up_to_date': 'Up to date', 'updater.up_to_date': 'Up to date',
'updater.up_to_date.subtitle': 'Current: v{v}', '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.available.title': 'v{v} available',
'updater.downloading.title': 'Downloading update', 'updater.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s', '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 TVars = Record<string, string | number>
export type TFn = (key: string, vars?: TVars) => string
/** /**
* Look up a key in the dictionary, substitute `{var}` placeholders. * Look up a key in the dictionary, substitute `{var}` placeholders.

View File

@@ -206,10 +206,10 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
] ]
export type UpdaterStatus = export type UpdaterStatus =
| { kind: 'idle' } | { kind: 'idle'; lastCheckedAt?: number }
| { kind: 'unsupported'; reason: string } | { kind: 'unsupported'; reason: string }
| { kind: 'checking' } | { kind: 'checking' }
| { kind: 'not-available'; currentVersion: string } | { kind: 'not-available'; currentVersion: string; lastCheckedAt?: number }
| { kind: 'available'; version: string; releaseDate?: string } | { kind: 'available'; version: string; releaseDate?: string }
| { | {
kind: 'downloading' kind: 'downloading'