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:
242
src/renderer/src/components/UpdaterCard.tsx
Normal file
242
src/renderer/src/components/UpdaterCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/renderer/src/lib/format.test.ts
Normal file
56
src/renderer/src/lib/format.test.ts
Normal 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 мин')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user