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:
65
.gitea/workflows/ci.yml
Normal file
65
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
name: Typecheck + Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck (main + preload + shared)
|
||||||
|
run: npm run typecheck:node
|
||||||
|
|
||||||
|
- name: Typecheck (renderer)
|
||||||
|
run: npm run typecheck:web
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test:run
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build (Windows)
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: quality
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build production bundle (no installer)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Smoke-test unpacked build
|
||||||
|
run: npm run dist:dir
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload unpacked artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: exercise-reminder-unpacked
|
||||||
|
path: release/win-unpacked/
|
||||||
|
retention-days: 7
|
||||||
75
.gitea/workflows/release.yml
Normal file
75
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build installer + publish release
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Verify version matches tag
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$tag = "${{ gitea.ref_name }}"
|
||||||
|
$expected = $tag.TrimStart('v')
|
||||||
|
$actual = (Get-Content package.json | ConvertFrom-Json).version
|
||||||
|
if ($expected -ne $actual) {
|
||||||
|
Write-Error "Tag $tag does not match package.json version $actual"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Version match: $actual"
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test:run
|
||||||
|
|
||||||
|
- name: Build NSIS installer
|
||||||
|
run: npm run dist
|
||||||
|
env:
|
||||||
|
# electron-builder uses this when --publish flag is set; we publish
|
||||||
|
# to a Gitea release manually below to avoid hard-coupling.
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Generate release notes from commits
|
||||||
|
id: notes
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$tag = "${{ gitea.ref_name }}"
|
||||||
|
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null
|
||||||
|
if ($prev) {
|
||||||
|
$log = git log --pretty=format:"- %s" "$prev..$tag"
|
||||||
|
} else {
|
||||||
|
$log = git log --pretty=format:"- %s" "$tag"
|
||||||
|
}
|
||||||
|
$notes = "### Изменения`n`n$log`n`n---`n`nУстановщик ниже — запустить и следовать мастеру. Если приложение уже стояло — обновится поверх с сохранением настроек."
|
||||||
|
$encoded = $notes -replace "`r?`n", "%0A"
|
||||||
|
"notes=$encoded" | Out-File -FilePath $env:GITEA_OUTPUT -Append
|
||||||
|
|
||||||
|
- name: Create Gitea release with artifacts
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
server_url: ${{ gitea.server_url }}
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
name: 'Exercise Reminder ${{ gitea.ref_name }}'
|
||||||
|
body: ${{ steps.notes.outputs.notes }}
|
||||||
|
files: |
|
||||||
|
release/Exercise-Reminder-Setup-*.exe
|
||||||
|
release/Exercise-Reminder-Setup-*.exe.blockmap
|
||||||
|
release/latest.yml
|
||||||
146
RELEASING.md
Normal file
146
RELEASING.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Релиз и автообновления
|
||||||
|
|
||||||
|
Документ описывает три способа выпустить новую версию. Все опираются на
|
||||||
|
один и тот же артефакт — NSIS-инсталлятор `Exercise-Reminder-Setup-X.Y.Z.exe`,
|
||||||
|
который сам решает: устанавливать заново или обновлять существующую копию.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
$env:GITEA_TOKEN = '<token из Gitea Settings → Applications>'
|
||||||
|
npm run release -- -Bump patch # 0.2.0 → 0.2.1
|
||||||
|
# или
|
||||||
|
npm run release -- -Version 0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт сделает всё сам: бамп версии, коммит, тег, push, тесты, сборка
|
||||||
|
инсталлятора, создание Gitea release с заметками из коммитов, загрузка
|
||||||
|
артефактов.
|
||||||
|
|
||||||
|
После публикации релиза установленные у пользователей копии в течение
|
||||||
|
~6 часов проверят `latest.yml` на Gitea и предложат обновление через UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как работает auto-update
|
||||||
|
|
||||||
|
1. На каждом релизе вместе с `.exe` публикуется `latest.yml` —
|
||||||
|
манифест с версией, размером, sha512 хешем.
|
||||||
|
2. Приложение (через `electron-updater`) каждые 6 часов делает HTTP
|
||||||
|
GET на `<gitea>/AnRil/laude/releases/download/v<current>/latest.yml`.
|
||||||
|
3. Если версия в манифесте выше текущей — статус становится
|
||||||
|
`available`, в Settings → Обновления появляется кнопка «Скачать».
|
||||||
|
4. После скачивания — статус `downloaded`, кнопка «Перезапустить».
|
||||||
|
5. При перезапуске NSIS установщик из дельты или полный накатывается
|
||||||
|
поверх существующей инсталляции. Данные в `%APPDATA%\Exercise Reminder\`
|
||||||
|
сохраняются.
|
||||||
|
|
||||||
|
**Важно:** репозиторий `laude` приватный. Чтобы auto-update работал на
|
||||||
|
машинах конечных пользователей, либо:
|
||||||
|
- сделать репозиторий публичным, либо
|
||||||
|
- сделать публичными только релизы (Gitea: Release Settings),
|
||||||
|
- либо подписывать запросы токеном (нужен код в `updater.ts`,
|
||||||
|
использующий `autoUpdater.requestHeaders`).
|
||||||
|
|
||||||
|
## Способ 1 — скрипт релиза (рекомендованный сейчас)
|
||||||
|
|
||||||
|
Самый прямой путь, не зависит от Gitea Actions runners.
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
# Один раз — получить токен в Gitea (Settings → Applications)
|
||||||
|
# и сохранить в переменную окружения. Право — write:repository.
|
||||||
|
[Environment]::SetEnvironmentVariable('GITEA_TOKEN', '<token>', 'User')
|
||||||
|
|
||||||
|
# Релиз
|
||||||
|
npm run release -- -Bump patch # patch (0.2.0 → 0.2.1)
|
||||||
|
npm run release -- -Bump minor # minor (0.2.0 → 0.3.0)
|
||||||
|
npm run release -- -Bump major # major (0.2.0 → 1.0.0)
|
||||||
|
npm run release -- -Version 1.2.3 # точная версия
|
||||||
|
npm run release -- -DryRun # посмотреть план без действий
|
||||||
|
```
|
||||||
|
|
||||||
|
Что делает скрипт:
|
||||||
|
1. Проверяет что нет незакоммиченных изменений
|
||||||
|
2. Бампит версию в `package.json`, коммитит
|
||||||
|
3. Прогоняет `npm run typecheck` и `npm run test:run`
|
||||||
|
4. Собирает `npm run dist` (NSIS + блокмап + latest.yml)
|
||||||
|
5. Создаёт тег `vX.Y.Z`, пушит main и тег в origin
|
||||||
|
6. Через Gitea API создаёт release с заметками из git log
|
||||||
|
7. Загружает три файла как assets: `.exe`, `.exe.blockmap`, `latest.yml`
|
||||||
|
|
||||||
|
## Способ 2 — Gitea Actions (если есть runners)
|
||||||
|
|
||||||
|
Workflows лежат в `.gitea/workflows/`:
|
||||||
|
|
||||||
|
- **`ci.yml`** — на push в main и на PR. Запускает typecheck +
|
||||||
|
unit-тесты + smoke-сборку (без NSIS). Кладёт распакованную сборку
|
||||||
|
как artifact на 7 дней.
|
||||||
|
- **`release.yml`** — на push тега `v*.*.*`. Сверяет тег с версией
|
||||||
|
в `package.json`, прогоняет тесты, собирает NSIS-инсталлятор,
|
||||||
|
создаёт Gitea release с заметками, загружает артефакты.
|
||||||
|
|
||||||
|
Чтобы release workflow работал — в репозитории нужен secret
|
||||||
|
`GITEA_TOKEN` (Gitea Repo Settings → Secrets). Этот же токен может быть
|
||||||
|
переиспользован из `Способа 1`.
|
||||||
|
|
||||||
|
Для запуска release workflow:
|
||||||
|
```bash
|
||||||
|
git tag v0.3.0
|
||||||
|
git push origin v0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Способ 3 — руками
|
||||||
|
|
||||||
|
Если что-то сломалось в автоматизации:
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
npm run typecheck
|
||||||
|
npm run test:run
|
||||||
|
npm run dist
|
||||||
|
# В release/ появятся:
|
||||||
|
# Exercise-Reminder-Setup-X.Y.Z.exe
|
||||||
|
# Exercise-Reminder-Setup-X.Y.Z.exe.blockmap
|
||||||
|
# latest.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем в Gitea UI: Releases → Draft new release → загрузить три файла.
|
||||||
|
|
||||||
|
## Тестирование auto-update
|
||||||
|
|
||||||
|
Удобный способ проверить, что цикл работает:
|
||||||
|
|
||||||
|
1. Релизнуть `0.x.0` через `npm run release`.
|
||||||
|
2. Установить полученный `.exe` на машину.
|
||||||
|
3. Релизнуть `0.x.1` (любой бамп).
|
||||||
|
4. На установленной копии открыть Settings → Обновления → Проверить.
|
||||||
|
Должно показать «Доступно обновление v0.x.1».
|
||||||
|
5. Скачать → Перезапустить → проверить версию в окне «О программе»
|
||||||
|
(или в Settings).
|
||||||
|
|
||||||
|
Для dev-режима (`npm run dev`) auto-updater отключён — статус сразу
|
||||||
|
становится `unsupported` с пояснением.
|
||||||
|
|
||||||
|
## Откат релиза
|
||||||
|
|
||||||
|
Если опубликовали плохой релиз:
|
||||||
|
|
||||||
|
1. Удалить release в Gitea UI (или через API).
|
||||||
|
2. Удалить тег: `git push origin :refs/tags/vX.Y.Z` и локально
|
||||||
|
`git tag -d vX.Y.Z`.
|
||||||
|
3. Откатить bump-коммит: `git revert <hash>` или `git reset --hard HEAD~1`
|
||||||
|
(если ещё не пушили дальше).
|
||||||
|
4. Релизнуть тот же номер заново — auto-updater на клиентах увидит
|
||||||
|
тот же манифест и не предложит обновление (если sha512 совпадёт).
|
||||||
|
Если содержание поменялось — увидит и предложит обновиться. На
|
||||||
|
практике лучше выпустить hotfix-патч `X.Y.Z+1`, чем переписывать
|
||||||
|
существующий релиз.
|
||||||
|
|
||||||
|
## Что попадает в установщик
|
||||||
|
|
||||||
|
См. `build` секцию `package.json`:
|
||||||
|
|
||||||
|
- `out/**/*` — собранный код (main + preload + renderer)
|
||||||
|
- `resources/**/*` — иконки
|
||||||
|
|
||||||
|
Никаких node_modules, исходников, тестов, README — `electron-builder`
|
||||||
|
сам распаковывает и упаковывает только необходимое.
|
||||||
196
scripts/release.ps1
Normal file
196
scripts/release.ps1
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Локальный релиз: бамп версии → коммит → тег → push → сборка → upload в Gitea release.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Один скрипт от и до. Если Gitea Actions не настроено, это рабочая альтернатива.
|
||||||
|
|
||||||
|
.PARAMETER Bump
|
||||||
|
Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
|
||||||
|
Альтернатива — указать -Version явно.
|
||||||
|
|
||||||
|
.PARAMETER Version
|
||||||
|
Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется.
|
||||||
|
|
||||||
|
.PARAMETER SkipBuild
|
||||||
|
Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
|
||||||
|
|
||||||
|
.PARAMETER DryRun
|
||||||
|
Показать что произойдёт, но ничего не делать.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh scripts/release.ps1 -Bump minor
|
||||||
|
pwsh scripts/release.ps1 -Version 0.3.0
|
||||||
|
pwsh scripts/release.ps1 -Bump patch -DryRun
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Требует переменную окружения GITEA_TOKEN с правом write:repository
|
||||||
|
(создаётся в Gitea: Settings → Applications → Generate New Token).
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[ValidateSet('patch', 'minor', 'major')]
|
||||||
|
[string]$Bump = 'patch',
|
||||||
|
[string]$Version,
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# --- Config ---------------------------------------------------------------
|
||||||
|
$repoOwner = 'AnRil'
|
||||||
|
$repoName = 'laude'
|
||||||
|
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
|
||||||
|
$apiBase = "https://$giteaHost/api/v1"
|
||||||
|
|
||||||
|
# --- Pre-flight checks ---------------------------------------------------
|
||||||
|
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
if (-not $env:GITEA_TOKEN -and -not $DryRun) {
|
||||||
|
Write-Error 'GITEA_TOKEN не задан. Создай в Gitea Settings → Applications и export GITEA_TOKEN=...'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = git status --porcelain
|
||||||
|
if ($status) {
|
||||||
|
Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$branch = git rev-parse --abbrev-ref HEAD
|
||||||
|
if ($branch -ne 'main') {
|
||||||
|
Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)"
|
||||||
|
Read-Host 'Press Enter'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Compute next version ------------------------------------------------
|
||||||
|
$pkg = Get-Content package.json | ConvertFrom-Json
|
||||||
|
$current = $pkg.version
|
||||||
|
|
||||||
|
if ($Version) {
|
||||||
|
$next = $Version
|
||||||
|
} else {
|
||||||
|
$parts = $current.Split('.')
|
||||||
|
$major = [int]$parts[0]; $minor = [int]$parts[1]; $patch = [int]$parts[2]
|
||||||
|
switch ($Bump) {
|
||||||
|
'major' { $major++; $minor = 0; $patch = 0 }
|
||||||
|
'minor' { $minor++; $patch = 0 }
|
||||||
|
'patch' { $patch++ }
|
||||||
|
}
|
||||||
|
$next = "$major.$minor.$patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = "v$next"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "→ Release plan" -ForegroundColor Cyan
|
||||||
|
Write-Host " current : v$current"
|
||||||
|
Write-Host " next : $tag"
|
||||||
|
Write-Host " bump : $Bump"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host '(dry run — exiting)' -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Bump version in package.json ---------------------------------------
|
||||||
|
Write-Host "→ Bumping package.json to $next…" -ForegroundColor Cyan
|
||||||
|
$pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
|
||||||
|
Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8
|
||||||
|
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore(release): $tag"
|
||||||
|
|
||||||
|
# --- Build (typecheck + tests + dist) ------------------------------------
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host "→ Running typecheck…" -ForegroundColor Cyan
|
||||||
|
npm run typecheck
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "→ Running tests…" -ForegroundColor Cyan
|
||||||
|
npm run test:run
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "→ Building installer (npm run dist)…" -ForegroundColor Cyan
|
||||||
|
npm run dist
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Verify artifacts exist ---------------------------------------------
|
||||||
|
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
|
||||||
|
$blockmap = "$installer.blockmap"
|
||||||
|
$manifest = Join-Path 'release' 'latest.yml'
|
||||||
|
foreach ($f in @($installer, $blockmap, $manifest)) {
|
||||||
|
if (-not (Test-Path $f)) {
|
||||||
|
Write-Error "Не найден артефакт: $f"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Tag + push ----------------------------------------------------------
|
||||||
|
Write-Host "→ Tagging $tag and pushing…" -ForegroundColor Cyan
|
||||||
|
git tag -a $tag -m "Release $tag"
|
||||||
|
git push origin main
|
||||||
|
git push origin $tag
|
||||||
|
|
||||||
|
# --- Create release via Gitea API ----------------------------------------
|
||||||
|
Write-Host "→ Creating Gitea release $tag…" -ForegroundColor Cyan
|
||||||
|
$headers = @{
|
||||||
|
Authorization = "token $env:GITEA_TOKEN"
|
||||||
|
Accept = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Release notes from commits since previous tag
|
||||||
|
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null
|
||||||
|
if ($prev) {
|
||||||
|
$log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String
|
||||||
|
} else {
|
||||||
|
$log = git log --pretty=format:"- %s" "$tag" | Out-String
|
||||||
|
}
|
||||||
|
$body = @"
|
||||||
|
### Изменения
|
||||||
|
|
||||||
|
$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"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Release published" -ForegroundColor Green
|
||||||
|
Write-Host " $releaseUrl"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Auto-updater подхватит обновление на установленных копиях в течение ~6 часов."
|
||||||
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 { wasStartedHidden } from './autostart'
|
||||||
import { broadcastState } from './state-actions'
|
import { broadcastState } from './state-actions'
|
||||||
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
|
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
|
||||||
|
import { initUpdater, stopUpdater } from './updater'
|
||||||
import { IPC } from '@shared/ipc'
|
import { IPC } from '@shared/ipc'
|
||||||
|
|
||||||
const APP_ID = 'com.anril.exercise-reminder'
|
const APP_ID = 'com.anril.exercise-reminder'
|
||||||
@@ -36,6 +37,7 @@ if (!gotLock) {
|
|||||||
startGamesRegistry().catch((err) =>
|
startGamesRegistry().catch((err) =>
|
||||||
console.error('games registry failed:', err)
|
console.error('games registry failed:', err)
|
||||||
)
|
)
|
||||||
|
initUpdater()
|
||||||
|
|
||||||
nativeTheme.on('updated', () => {
|
nativeTheme.on('updated', () => {
|
||||||
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
||||||
@@ -69,6 +71,7 @@ if (!gotLock) {
|
|||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
|
stopUpdater()
|
||||||
void stopGamesRegistry()
|
void stopGamesRegistry()
|
||||||
flushNow()
|
flushNow()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ import {
|
|||||||
toggleGame,
|
toggleGame,
|
||||||
uninstallGame
|
uninstallGame
|
||||||
} from './games/registry'
|
} from './games/registry'
|
||||||
|
import {
|
||||||
|
checkForUpdates,
|
||||||
|
downloadUpdate,
|
||||||
|
getUpdaterStatus,
|
||||||
|
quitAndInstall
|
||||||
|
} from './updater'
|
||||||
|
|
||||||
export function registerIpc(): void {
|
export function registerIpc(): void {
|
||||||
ipcMain.handle(IPC.getState, () => {
|
ipcMain.handle(IPC.getState, () => {
|
||||||
@@ -201,4 +207,10 @@ export function registerIpc(): void {
|
|||||||
simulateMatchEnd(id, stats)
|
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()
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import type {
|
|||||||
GameStatus,
|
GameStatus,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
Settings,
|
Settings,
|
||||||
Tick
|
Tick,
|
||||||
|
UpdaterStatus
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
|
||||||
type Unsub = () => void
|
type Unsub = () => void
|
||||||
@@ -78,13 +79,23 @@ const api = {
|
|||||||
simulateMatchEnd: (id: GameId, stats: Record<string, number>): Promise<void> =>
|
simulateMatchEnd: (id: GameId, stats: Record<string, number>): Promise<void> =>
|
||||||
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats),
|
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),
|
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
|
||||||
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
||||||
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
|
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
|
||||||
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
|
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
|
||||||
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => on(IPC.evtThemeChanged, h),
|
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => on(IPC.evtThemeChanged, h),
|
||||||
onAccentChanged: (h: Handler<string>): Unsub => on(IPC.evtAccentChanged, 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)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
|||||||
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 { Bell, Monitor, Palette } from 'lucide-react'
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
|
import { UpdaterCard } from '../components/UpdaterCard'
|
||||||
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
|
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
|
||||||
|
|
||||||
export default function SettingsPage(): JSX.Element {
|
export default function SettingsPage(): JSX.Element {
|
||||||
@@ -98,6 +99,8 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<UpdaterCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export const IPC = {
|
|||||||
markChallengeDone: 'challenge:markDone',
|
markChallengeDone: 'challenge:markDone',
|
||||||
closeMatchSummary: 'matchSummary:close',
|
closeMatchSummary: 'matchSummary:close',
|
||||||
|
|
||||||
|
// Auto-updater
|
||||||
|
updaterStatus: 'updater:status',
|
||||||
|
updaterCheck: 'updater:check',
|
||||||
|
updaterDownload: 'updater:download',
|
||||||
|
updaterInstall: 'updater:install',
|
||||||
|
|
||||||
// events from main → renderer
|
// events from main → renderer
|
||||||
evtTick: 'evt:tick',
|
evtTick: 'evt:tick',
|
||||||
evtFire: 'evt:fire',
|
evtFire: 'evt:fire',
|
||||||
@@ -43,5 +49,6 @@ export const IPC = {
|
|||||||
evtStateChanged: 'evt:stateChanged',
|
evtStateChanged: 'evt:stateChanged',
|
||||||
evtThemeChanged: 'evt:themeChanged',
|
evtThemeChanged: 'evt:themeChanged',
|
||||||
evtAccentChanged: 'evt:accentChanged',
|
evtAccentChanged: 'evt:accentChanged',
|
||||||
evtGamesChanged: 'evt:gamesChanged'
|
evtGamesChanged: 'evt:gamesChanged',
|
||||||
|
evtUpdaterStatus: 'evt:updaterStatus'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
43
src/shared/types.test.ts
Normal file
43
src/shared/types.test.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -130,3 +130,20 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
|||||||
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true },
|
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true },
|
||||||
{ name: 'Растяжка спины', reps: 1, icon: 'StretchHorizontal', intervalMinutes: 60, enabled: false }
|
{ 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 }
|
||||||
|
|
||||||
|
|||||||
26
vitest.config.ts
Normal file
26
vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
globals: false,
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: ['src/**/*.test.*', 'src/renderer/index.html']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
|
'@renderer': resolve(__dirname, 'src/renderer/src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user