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

121
src/main/games/vdf.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { parseVdf, stringifyVdf, type VdfNode } from './vdf'
describe('parseVdf', () => {
it('parses a simple key-value pair', () => {
const r = parseVdf('"key" "value"')
expect(r).toEqual({ key: 'value' })
})
it('parses nested objects', () => {
const src = `
"Root"
{
"inner" "v1"
"child"
{
"deep" "v2"
}
}
`
const r = parseVdf(src)
expect(r).toEqual({
Root: {
inner: 'v1',
child: { deep: 'v2' }
}
})
})
it('handles unquoted tokens', () => {
const r = parseVdf('key value')
expect(r).toEqual({ key: 'value' })
})
it('skips // line comments', () => {
const src = `
// comment line
"key" "value" // trailing
"another" "v2"
`
const r = parseVdf(src)
expect(r.key).toBe('value')
expect(r.another).toBe('v2')
})
it('decodes escape sequences', () => {
const r = parseVdf('"path" "C:\\\\Steam\\\\steamapps"')
expect(r.path).toBe('C:\\Steam\\steamapps')
})
it('parses Steam libraryfolders shape', () => {
const src = `
"libraryfolders"
{
"0"
{
"path" "C:\\\\Program Files (x86)\\\\Steam"
"apps"
{
"570" "12345"
}
}
}
`
const r = parseVdf(src)
const lib = r.libraryfolders as VdfNode
const slot0 = lib['0'] as VdfNode
expect(slot0.path).toBe('C:\\Program Files (x86)\\Steam')
const apps = slot0.apps as VdfNode
expect(apps['570']).toBe('12345')
})
})
describe('stringifyVdf', () => {
it('renders simple key-value pairs', () => {
const out = stringifyVdf({ key: 'value' })
expect(out).toContain('"key"')
expect(out).toContain('"value"')
})
it('renders nested objects with braces', () => {
const out = stringifyVdf({ root: { inner: 'v' } })
expect(out).toMatch(/"root"\s*\n\{\s*\n\s*"inner"\s+"v"/m)
expect(out).toContain('}')
})
it('escapes special characters', () => {
const out = stringifyVdf({ path: 'C:\\Steam' })
expect(out).toContain('C:\\\\Steam')
})
})
describe('parseVdf ↔ stringifyVdf round-trip', () => {
it('preserves structure', () => {
const orig: VdfNode = {
UserLocalConfigStore: {
Software: {
Valve: {
Steam: {
apps: {
'570': {
LaunchOptions: '-gamestateintegration -other'
}
}
}
}
}
}
}
const text = stringifyVdf(orig)
const parsed = parseVdf(text)
expect(parsed).toEqual(orig)
})
it('handles empty objects', () => {
const orig: VdfNode = { a: {}, b: 'x' }
const text = stringifyVdf(orig)
const parsed = parseVdf(text)
expect(parsed).toEqual(orig)
})
})

View File

@@ -7,6 +7,7 @@ import { flushNow, getState } from './store'
import { wasStartedHidden } from './autostart'
import { broadcastState } from './state-actions'
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
import { initUpdater, stopUpdater } from './updater'
import { IPC } from '@shared/ipc'
const APP_ID = 'com.anril.exercise-reminder'
@@ -36,6 +37,7 @@ if (!gotLock) {
startGamesRegistry().catch((err) =>
console.error('games registry failed:', err)
)
initUpdater()
nativeTheme.on('updated', () => {
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
@@ -69,6 +71,7 @@ if (!gotLock) {
app.on('before-quit', () => {
stopScheduler()
stopUpdater()
void stopGamesRegistry()
flushNow()
})

View File

@@ -27,6 +27,12 @@ import {
toggleGame,
uninstallGame
} from './games/registry'
import {
checkForUpdates,
downloadUpdate,
getUpdaterStatus,
quitAndInstall
} from './updater'
export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => {
@@ -201,4 +207,10 @@ export function registerIpc(): void {
simulateMatchEnd(id, stats)
}
)
// Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
}

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()
}

View File

@@ -8,7 +8,8 @@ import type {
GameStatus,
MatchSummary,
Settings,
Tick
Tick,
UpdaterStatus
} from '@shared/types'
type Unsub = () => void
@@ -78,13 +79,23 @@ const api = {
simulateMatchEnd: (id: GameId, stats: Record<string, number>): Promise<void> =>
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats),
// Auto-updater
updaterStatus: (): Promise<UpdaterStatus> =>
ipcRenderer.invoke(IPC.updaterStatus),
updaterCheck: (): Promise<UpdaterStatus> =>
ipcRenderer.invoke(IPC.updaterCheck),
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => on(IPC.evtThemeChanged, h),
onAccentChanged: (h: Handler<string>): Unsub => on(IPC.evtAccentChanged, h),
onGamesChanged: (h: Handler<GameStatus[]>): Unsub => on(IPC.evtGamesChanged, h)
onGamesChanged: (h: Handler<GameStatus[]>): Unsub => on(IPC.evtGamesChanged, h),
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h)
}
contextBridge.exposeInMainWorld('api', api)

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react'
import {
RefreshCw,
Download,
CheckCircle2,
AlertTriangle,
Sparkles,
PackageCheck
} from 'lucide-react'
import { motion } from 'framer-motion'
import { Button } from './ui/Button'
import type { UpdaterStatus } from '@shared/types'
export function UpdaterCard(): JSX.Element {
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
const [busy, setBusy] = useState(false)
useEffect(() => {
void window.api.updaterStatus().then(setStatus)
return window.api.onUpdaterStatus(setStatus)
}, [])
async function check(): Promise<void> {
setBusy(true)
try {
await window.api.updaterCheck()
} finally {
setBusy(false)
}
}
async function download(): Promise<void> {
setBusy(true)
try {
await window.api.updaterDownload()
} finally {
setBusy(false)
}
}
function install(): void {
void window.api.updaterInstall()
}
return (
<section className="mb-7">
<div className="flex items-center gap-2 mb-3">
<span className="w-7 h-7 rounded-lg bg-accent/15 text-accent grid place-items-center">
<PackageCheck size={14} />
</span>
<h2 className="text-[10px] uppercase tracking-[0.22em] text-muted font-display font-bold">
Обновления
</h2>
</div>
<div className="relative rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
<Body status={status} busy={busy} onCheck={check} onDownload={download} onInstall={install} />
</div>
</section>
)
}
function Body({
status,
busy,
onCheck,
onDownload,
onInstall
}: {
status: UpdaterStatus
busy: boolean
onCheck: () => void
onDownload: () => void
onInstall: () => void
}): JSX.Element {
if (status.kind === 'unsupported') {
return (
<Row
tone="muted"
icon={<AlertTriangle size={18} />}
title="Auto-update недоступен"
subtitle={status.reason}
/>
)
}
if (status.kind === 'checking') {
return (
<Row
tone="accent"
icon={<RefreshCw size={18} className="animate-spin" />}
title="Проверяем наличие обновлений…"
/>
)
}
if (status.kind === 'not-available') {
return (
<Row
tone="victory"
icon={<CheckCircle2 size={18} />}
title="Установлена последняя версия"
subtitle={`Текущая: v${status.currentVersion}`}
action={
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Проверить
</Button>
}
/>
)
}
if (status.kind === 'available') {
return (
<Row
tone="accent"
icon={<Sparkles size={18} />}
title={`Доступно обновление v${status.version}`}
subtitle={status.releaseDate ? new Date(status.releaseDate).toLocaleString('ru-RU') : undefined}
action={
<Button size="sm" onClick={onDownload} disabled={busy}>
<Download size={14} /> Скачать
</Button>
}
/>
)
}
if (status.kind === 'downloading') {
const pct = Math.max(0, Math.min(100, status.percent || 0))
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1)
return (
<div className="px-5 py-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center">
<Download size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide">
Загружаем обновление
</div>
<div className="text-xs text-muted mt-0.5 font-mono-num">
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
</div>
</div>
<div className="font-mono-num font-bold text-lg text-accent">
{pct.toFixed(0)}%
</div>
</div>
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
<motion.div
className="h-full bg-gradient-brand"
animate={{ width: `${pct}%` }}
transition={{ duration: 0.3, ease: 'linear' }}
/>
</div>
</div>
)
}
if (status.kind === 'downloaded') {
return (
<Row
tone="victory"
icon={<CheckCircle2 size={18} />}
title={`Готово · v${status.version} загружена`}
subtitle="Перезапустите приложение для применения"
action={
<Button variant="victory" size="sm" onClick={onInstall}>
Перезапустить
</Button>
}
/>
)
}
if (status.kind === 'error') {
return (
<Row
tone="defeat"
icon={<AlertTriangle size={18} />}
title="Ошибка проверки обновлений"
subtitle={status.message}
action={
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Повторить
</Button>
}
/>
)
}
// idle
return (
<Row
tone="muted"
icon={<PackageCheck size={18} />}
title="Проверить наличие обновлений"
subtitle="Авто-проверка раз в 6 часов"
action={
<Button size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Проверить
</Button>
}
/>
)
}
function Row({
tone,
icon,
title,
subtitle,
action
}: {
tone: 'accent' | 'victory' | 'defeat' | 'muted'
icon: React.ReactNode
title: string
subtitle?: string
action?: React.ReactNode
}): JSX.Element {
const toneClasses = {
accent: 'bg-accent/15 text-accent',
victory: 'bg-victory/15 text-victory',
defeat: 'bg-defeat/15 text-defeat',
muted: 'bg-surface-elevated text-muted'
}[tone]
return (
<div className="flex items-center gap-4 px-5 py-4">
<div
className={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
toneClasses
].join(' ')}
>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide">
{title}
</div>
{subtitle && (
<div className="text-xs text-muted mt-0.5 truncate">{subtitle}</div>
)}
</div>
{action}
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { formatCountdown, formatInterval } from './format'
describe('formatCountdown', () => {
it('returns "сейчас" for zero or negative ms', () => {
expect(formatCountdown(0)).toBe('сейчас')
expect(formatCountdown(-1)).toBe('сейчас')
expect(formatCountdown(-100_000)).toBe('сейчас')
})
it('renders sub-minute as seconds only', () => {
expect(formatCountdown(1_000)).toBe('1с')
expect(formatCountdown(45_000)).toBe('45с')
expect(formatCountdown(59_999)).toBe('59с')
})
it('renders minutes with zero-padded seconds', () => {
expect(formatCountdown(60_000)).toBe('1м 00с')
expect(formatCountdown(65_000)).toBe('1м 05с')
expect(formatCountdown(125_000)).toBe('2м 05с')
expect(formatCountdown(599_000)).toBe('9м 59с')
})
it('renders hours with zero-padded minutes and drops seconds', () => {
expect(formatCountdown(3_600_000)).toBe('1ч 00м')
expect(formatCountdown(3_660_000)).toBe('1ч 01м')
expect(formatCountdown(7_245_000)).toBe('2ч 00м')
expect(formatCountdown(7_320_000)).toBe('2ч 02м')
})
it('floors fractional seconds (no rounding up)', () => {
// 999ms > 0 so not "сейчас"; Math.floor(999/1000) = 0 → "0с"
expect(formatCountdown(999)).toBe('0с')
expect(formatCountdown(500)).toBe('0с')
})
})
describe('formatInterval', () => {
it('renders minutes under an hour', () => {
expect(formatInterval(1)).toBe('1 мин')
expect(formatInterval(30)).toBe('30 мин')
expect(formatInterval(59)).toBe('59 мин')
})
it('renders whole hours without minute remainder', () => {
expect(formatInterval(60)).toBe('1 ч')
expect(formatInterval(120)).toBe('2 ч')
expect(formatInterval(180)).toBe('3 ч')
})
it('renders mixed hours+minutes', () => {
expect(formatInterval(61)).toBe('1 ч 1 мин')
expect(formatInterval(90)).toBe('1 ч 30 мин')
expect(formatInterval(125)).toBe('2 ч 5 мин')
})
})

View File

@@ -1,6 +1,7 @@
import { Bell, Monitor, Palette } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import { UpdaterCard } from '../components/UpdaterCard'
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
export default function SettingsPage(): JSX.Element {
@@ -98,6 +99,8 @@ export default function SettingsPage(): JSX.Element {
]}
/>
</Section>
<UpdaterCard />
</div>
)
}

View File

@@ -36,6 +36,12 @@ export const IPC = {
markChallengeDone: 'challenge:markDone',
closeMatchSummary: 'matchSummary:close',
// Auto-updater
updaterStatus: 'updater:status',
updaterCheck: 'updater:check',
updaterDownload: 'updater:download',
updaterInstall: 'updater:install',
// events from main → renderer
evtTick: 'evt:tick',
evtFire: 'evt:fire',
@@ -43,5 +49,6 @@ export const IPC = {
evtStateChanged: 'evt:stateChanged',
evtThemeChanged: 'evt:themeChanged',
evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged'
evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus'
} as const

43
src/shared/types.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_SETTINGS,
GAME_STATS,
SAMPLE_EXERCISES,
STAT_LABELS,
type GameStat
} from './types'
describe('DEFAULT_SETTINGS', () => {
it('uses safe defaults that do not surprise the user', () => {
expect(DEFAULT_SETTINGS.globalEnabled).toBe(true)
expect(DEFAULT_SETTINGS.notificationMode).toBe('modal')
expect(DEFAULT_SETTINGS.minimizeToTray).toBe(true)
expect(DEFAULT_SETTINGS.startWithWindows).toBe(false) // never auto-enroll
expect(DEFAULT_SETTINGS.snoozeMinutes).toBeGreaterThan(0)
})
})
describe('SAMPLE_EXERCISES', () => {
it('ships at least one enabled sample so the app is not empty on first launch', () => {
expect(SAMPLE_EXERCISES.length).toBeGreaterThan(0)
expect(SAMPLE_EXERCISES.some((e) => e.enabled)).toBe(true)
})
it('all samples have positive reps and intervals', () => {
for (const ex of SAMPLE_EXERCISES) {
expect(ex.reps, `reps for ${ex.name}`).toBeGreaterThan(0)
expect(ex.intervalMinutes, `interval for ${ex.name}`).toBeGreaterThan(0)
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
}
})
})
describe('STAT_LABELS', () => {
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {
for (const stats of Object.values(GAME_STATS)) {
for (const stat of stats as readonly GameStat[]) {
expect(STAT_LABELS[stat], `label for ${stat}`).toBeTruthy()
}
}
})
})

View File

@@ -130,3 +130,20 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true },
{ name: 'Растяжка спины', reps: 1, icon: 'StretchHorizontal', intervalMinutes: 60, enabled: false }
]
export type UpdaterStatus =
| { kind: 'idle' }
| { kind: 'unsupported'; reason: string }
| { kind: 'checking' }
| { kind: 'not-available'; currentVersion: string }
| { kind: 'available'; version: string; releaseDate?: string }
| {
kind: 'downloading'
percent: number
transferred: number
total: number
bytesPerSecond: number
}
| { kind: 'downloaded'; version: string }
| { kind: 'error'; message: string }