Compare commits
3 Commits
update-cha
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f038e59e8 | ||
|
|
33e237948e | ||
|
|
f861af5db1 |
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user