feat: auto-update, тесты и CI/CD
Полная автоматизация релизного цикла. == 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:
121
src/main/games/vdf.test.ts
Normal file
121
src/main/games/vdf.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
123
src/main/updater.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user