feat: auto-update, тесты и CI/CD
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled

Полная автоматизация релизного цикла.

== Auto-update (electron-updater) ==
- src/main/updater.ts — обёртка над autoUpdater с дискриминированным
  UpdaterStatus union и broadcast через IPC. autoDownload=false,
  пользователь сам жмёт «Скачать». allowDowngrade=false. Проверка
  каждые 6 часов, первая через 5с после старта.
- В dev-режиме (app.isPackaged=false) статус сразу становится
  'unsupported' с пояснением — никаких exceptions из updater'а.
- build.publish в package.json: provider=generic, url указывает на
  Gitea release assets конкретной версии.
- src/main/ipc.ts: 4 новых канала — status/check/download/install.
- src/preload: API window.api.updater* + onUpdaterStatus.
- src/renderer/src/components/UpdaterCard.tsx: HUD-карточка в Settings
  с состояниями idle/checking/available/downloading/downloaded/error,
  прогресс-бар с скоростью в МБ/с.

== Тесты (vitest) ==
- vitest.config.ts с алиасами @shared / @renderer
- 23 теста, все зелёные:
  * format.test.ts — formatCountdown, formatInterval (8 cases)
  * vdf.test.ts — parseVdf / stringifyVdf / round-trip (11 cases)
  * types.test.ts — DEFAULT_SETTINGS, SAMPLE_EXERCISES sanity (4)
- npm scripts: test (watch), test:run (CI)

== CI/CD (Gitea Actions) ==
- .gitea/workflows/ci.yml — на push/PR: typecheck + тесты + smoke-сборка
- .gitea/workflows/release.yml — на тег v*.*.*: сборка NSIS + Gitea release

== Локальный релизный скрипт ==
- scripts/release.ps1 — один скрипт от бампа версии до публикации
  через Gitea API (params: -Bump patch/minor/major, -Version, -DryRun)
- npm run release — обёртка
- RELEASING.md — полная инструкция

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-16 20:32:59 +07:00
parent 757352e447
commit 92e15e69a3
16 changed files with 1149 additions and 3 deletions

123
src/main/updater.ts Normal file
View File

@@ -0,0 +1,123 @@
import { app, BrowserWindow } from 'electron'
import { autoUpdater } from 'electron-updater'
import { IPC } from '@shared/ipc'
import type { UpdaterStatus } from '@shared/types'
let currentStatus: UpdaterStatus = { kind: 'idle' }
let wired = false
let checkInterval: NodeJS.Timeout | null = null
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 // every 6 hours
export function getUpdaterStatus(): UpdaterStatus {
return currentStatus
}
function setStatus(s: UpdaterStatus): void {
currentStatus = s
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
}
}
export function initUpdater(): void {
if (wired) return
wired = true
// In dev (electron not packaged) there's no signature / no release feed —
// skip silently. The UI still shows "не поддерживается в dev-режиме".
if (!app.isPackaged) {
setStatus({
kind: 'unsupported',
reason: 'Auto-update недоступен в dev-режиме'
})
return
}
autoUpdater.autoDownload = false // user-confirmed download
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.allowDowngrade = false
autoUpdater.on('checking-for-update', () => setStatus({ kind: 'checking' }))
autoUpdater.on('update-available', (info) => {
setStatus({
kind: 'available',
version: info.version,
releaseDate:
typeof info.releaseDate === 'string' ? info.releaseDate : undefined
})
})
autoUpdater.on('update-not-available', () => {
setStatus({ kind: 'not-available', currentVersion: app.getVersion() })
})
autoUpdater.on('download-progress', (p) => {
setStatus({
kind: 'downloading',
percent: p.percent,
transferred: p.transferred,
total: p.total,
bytesPerSecond: p.bytesPerSecond
})
})
autoUpdater.on('update-downloaded', (info) => {
setStatus({ kind: 'downloaded', version: info.version })
})
autoUpdater.on('error', (err) => {
setStatus({
kind: 'error',
message: err instanceof Error ? err.message : String(err)
})
})
// First check on boot (slight delay so window has time to subscribe).
setTimeout(() => {
void checkForUpdates()
}, 5_000)
// Periodic re-check
checkInterval = setInterval(() => {
void checkForUpdates()
}, CHECK_INTERVAL_MS)
}
export function stopUpdater(): void {
if (checkInterval) {
clearInterval(checkInterval)
checkInterval = null
}
}
export async function checkForUpdates(): Promise<UpdaterStatus> {
if (!app.isPackaged) return currentStatus
try {
await autoUpdater.checkForUpdates()
} catch (err) {
setStatus({
kind: 'error',
message: err instanceof Error ? err.message : String(err)
})
}
return currentStatus
}
export async function downloadUpdate(): Promise<void> {
if (!app.isPackaged) return
try {
await autoUpdater.downloadUpdate()
} catch (err) {
setStatus({
kind: 'error',
message: err instanceof Error ? err.message : String(err)
})
}
}
export function quitAndInstall(): void {
if (!app.isPackaged) return
autoUpdater.quitAndInstall()
}