Compare commits
13 Commits
2503b27d42
...
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd62177375 | ||
|
|
0a753ad4c7 | ||
|
|
34fb03b265 | ||
|
|
e7ccca98e7 | ||
|
|
4745f5e091 | ||
|
|
a41dce511b | ||
|
|
9378cabfe5 | ||
|
|
c735659567 | ||
|
|
c5c05ee651 | ||
|
|
36085f225f | ||
|
|
03ab4eebf5 | ||
|
|
a64f03b3cc | ||
|
|
e96ca06587 |
126
CHANGELOG.md
126
CHANGELOG.md
@@ -6,6 +6,126 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.5] — 2026-05-22
|
||||||
|
|
||||||
|
Большой sweep по ревизии: 4 спринта правок (≈14 пунктов), все 135 тестов
|
||||||
|
зелёные. Главное — UI больше не залипает при retry'ях I/O, GSI порт не
|
||||||
|
зависает в TIME_WAIT после выхода, sandbox включён, шрифты self-hosted.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **`sandbox: true`** на обоих BrowserWindow. Preload использует только
|
||||||
|
contextBridge + ipcRenderer (sandbox-safe), никаких Node-built-ins.
|
||||||
|
OS-уровневый sandbox изолирует renderer на уровне процессов — даже
|
||||||
|
RCE в зависимости рендерера не получит Node-доступа через preload.
|
||||||
|
- **CSP ужесточён.** Убраны `https://fonts.googleapis.com` и
|
||||||
|
`https://fonts.gstatic.com` origins (шрифты теперь self-hosted),
|
||||||
|
добавлены `connect-src 'self'`, `base-uri 'self'`,
|
||||||
|
`frame-ancestors 'none'`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Self-hosted шрифты.** Plus Jakarta Sans, Bricolage Grotesque,
|
||||||
|
JetBrains Mono подключены через `@fontsource/*` пакеты — в bundle
|
||||||
|
лежат локально, без интернета шрифты работают, CSP без внешних
|
||||||
|
origins. +22 .woff/.woff2 (~500KB) в installer.
|
||||||
|
- **`src/main/logger.ts`** — структурный logger с уровнями
|
||||||
|
(debug/info/warn/error) и ротацией. Пишет в
|
||||||
|
`%APPDATA%/Exercise Reminder/logs/latest.log` (≤1MB) и дублирует
|
||||||
|
в console. При 1MB ротируется в `prev.log`. `LAUDE_DEBUG=1`
|
||||||
|
включает debug-уровень. Подключён в hot paths: store, updater,
|
||||||
|
GSI server, registry, dota2 provider — особенно полезно для
|
||||||
|
диагностики «челленджи не срабатывают» (видно token verify,
|
||||||
|
POST_GAME detection, фильтрацию challenges).
|
||||||
|
- `<html lang>` синхронизируется с `settings.language` через
|
||||||
|
ThemeProvider — screen readers корректно произносят язык.
|
||||||
|
- `dev:simulateMatchEnd` channel вынесен в IPC enum
|
||||||
|
(`IPC.devSimulateMatchEnd`).
|
||||||
|
- `test:coverage` npm script.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`broadcastState` больше не шлёт `history`** через IPC. Раньше
|
||||||
|
каждый markDone/snooze отправлял весь state включая до 10k
|
||||||
|
history-записей (~500KB JSON) к каждому BrowserWindow. Теперь
|
||||||
|
`AppState` (renderer-facing) без `history`, а `PersistedState`
|
||||||
|
(internal) с историей. Renderer и так дёргал `getHistory()`
|
||||||
|
отдельно, поведение не изменилось — только perf.
|
||||||
|
- **`lib/icon.tsx`**: `import * as Lucide` (wildcard, ~500KB всех
|
||||||
|
1500+ иконок в bundle) → explicit named imports + ICON_MAP.
|
||||||
|
В bundle только 18 ICON_CHOICES.
|
||||||
|
- **ChallengeEditor**: multiplier клампится в UI до [0.5, 1000]
|
||||||
|
(совпадает с validate.ts). Раньше save с 9999 молча отклонялся
|
||||||
|
IPC-валидатором.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`atomicWrite` spin-loop → async setTimeout.** Раньше при retry
|
||||||
|
на EBUSY/EPERM (антивирус, OneDrive) main process замораживался
|
||||||
|
на 50/200/800ms × до 3 итераций ≈ секунда залипания UI. Сейчас
|
||||||
|
async sleep — event-loop живёт. Аналогичный фикс в
|
||||||
|
`games/steam-launch-options.ts`. Сохранён sync-вариант для
|
||||||
|
`flushNow` в `before-quit` (там event-loop уже не работает).
|
||||||
|
- **`before-quit` дожидается `stopGamesRegistry`** через
|
||||||
|
`e.preventDefault()` + `app.exit(0)`. Раньше GSI HTTP server
|
||||||
|
не успевал `closeAllConnections` до exit, и следующий запуск
|
||||||
|
получал EADDRINUSE на порту 4701 (TIME_WAIT) — GSI молча не
|
||||||
|
работал.
|
||||||
|
- **IPC `getState` не мутирует кэш.** Раньше `state.settings.startWithWindows`
|
||||||
|
перезаписывалось напрямую, разъезжаясь с persisted-disk-значением
|
||||||
|
до следующего mutation. Сейчас возвращается поверхностная копия.
|
||||||
|
|
||||||
|
## [0.5.4] — 2026-05-19
|
||||||
|
|
||||||
|
Обновление приложения теперь по-настоящему фоновое + почти моментальный
|
||||||
|
рестарт в новую версию.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Скачивание апдейта — фоновое.** Раньше клик «Скачать» блокировал
|
||||||
|
кнопку (`busy=true`) до конца download'а (минуты на медленной сети).
|
||||||
|
Теперь IPC `updaterDownload` — fire-and-forget, прогресс приходит
|
||||||
|
через события. Пользователь сразу может уйти на Dashboard и
|
||||||
|
продолжать упражнения, апдейт качается в фоне.
|
||||||
|
- **«Рестарт» — почти моментальный.** `quitAndInstall(true, true)`:
|
||||||
|
isSilent=true — NSIS без UI установщика (~1-2 сек вместо ~5-10),
|
||||||
|
isForceRunAfter=true — гарантия что приложение откроется после.
|
||||||
|
Раньше показывался диалог установщика с прогрессом, теперь —
|
||||||
|
только мгновение между закрытием и появлением новой версии.
|
||||||
|
- Подсказка на экране скачивания: «можно закрыть это окно, продолжится
|
||||||
|
в фоне». На downloaded-экране: «нажми Рестарт — приложение
|
||||||
|
моментально откроется в новой версии».
|
||||||
|
|
||||||
|
## [0.5.3] — 2026-05-19
|
||||||
|
|
||||||
|
Полировка кастомного тайтлбара и размера окна.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Maximize/Restore.** Средняя кнопка тайтлбара (иконка квадрата)
|
||||||
|
раньше была «спрятать в трей» — выглядела как нативная Windows
|
||||||
|
maximize и сбивала с толку. Теперь это настоящий toggle на
|
||||||
|
весь экран: иконка свапается `Square` ↔ `Copy` в зависимости
|
||||||
|
от состояния, aria-label локализован.
|
||||||
|
- **Double-click по тайтлбару** тоже toggleMaximize — стандартный
|
||||||
|
Windows-жест.
|
||||||
|
- **CLAUDE.md** в корне — контекст проекта для будущих сессий
|
||||||
|
Claude Code (стек, архитектура, команды, тех. долг).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Drag-зона тайтлбара.** Окно не двигалось, если хватать его
|
||||||
|
рядом с кнопками свернуть/закрыть. Класс `titlebar-nodrag` стоял
|
||||||
|
на обёртке кластера с `flex-1 basis-0`, поэтому пустое место
|
||||||
|
слева от иконок тоже было no-drag. Перенесли `no-drag` на сами
|
||||||
|
кнопки — теперь тащить можно отовсюду, кроме самих квадратиков.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Минимальный размер окна** 900×600 → 1100×700. Гарантирует
|
||||||
|
срабатывание Tailwind `lg:` (4 hero-stat в один ряд, heatmap
|
||||||
|
и сетка упражнений помещаются без горизонтального скролла).
|
||||||
|
|
||||||
## [0.5.2] — 2026-05-19
|
## [0.5.2] — 2026-05-19
|
||||||
|
|
||||||
Большая внутренняя итерация: тройной независимый аудит (~220 находок),
|
Большая внутренняя итерация: тройной независимый аудит (~220 находок),
|
||||||
@@ -171,7 +291,11 @@
|
|||||||
иконки), системный трей, автозапуск с Windows, native-уведомления,
|
иконки), системный трей, автозапуск с Windows, native-уведомления,
|
||||||
NSIS-инсталлятор, auto-update через electron-updater.
|
NSIS-инсталлятор, auto-update через electron-updater.
|
||||||
|
|
||||||
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.1...HEAD
|
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.5...HEAD
|
||||||
|
[0.5.5]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.5
|
||||||
|
[0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4
|
||||||
|
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3
|
||||||
|
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2
|
||||||
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
|
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
|
||||||
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0
|
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0
|
||||||
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0
|
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0
|
||||||
|
|||||||
168
CLAUDE.md
Normal file
168
CLAUDE.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Контекст проекта для Claude Code. Читается при старте каждой сессии.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.5**. Один разработчик (AnRil), один remote — self-hosted Gitea.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- **Runtime**: Electron 33 (main + preload + renderer)
|
||||||
|
- **Build**: electron-vite 2 + Vite 5 + electron-builder 25 (NSIS, x64 only)
|
||||||
|
- **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5
|
||||||
|
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал
|
||||||
|
- **Тесты**: Vitest 4 (53 теста, все зелёные)
|
||||||
|
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
|
||||||
|
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
|
||||||
|
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
|
||||||
|
|
||||||
|
## Архитектура (важное)
|
||||||
|
|
||||||
|
### Процессы
|
||||||
|
- **main** (`src/main/`) — Node, scheduler, GSI HTTP-сервер, IPC, окна, tray, updater, persistence
|
||||||
|
- **preload** (`src/preload/index.ts`) — contextBridge → `window.api`, dev-only методы вырезаны на проде (`import.meta.env.MODE !== 'production'`)
|
||||||
|
- **renderer** (`src/renderer/src/`) — React, zustand-store, страницы Dashboard/Games/Settings/About, ReminderApp в отдельном окне
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
- Единственный JSON-файл: `%APPDATA%\Exercise Reminder\app-state.json`
|
||||||
|
- **Атомарная запись**: tmp + rename + retry на EBUSY/EPERM (антивирус, OneDrive)
|
||||||
|
- **Не теряет данные**: corrupt JSON → quarantine в `app-state.json.corrupt-<ts>`, не silent wipe
|
||||||
|
- **Schema migrations**: `__schemaVersion` поле + `MIGRATIONS: Record<number, (s)=>s>` map в `src/main/store.ts`
|
||||||
|
- **Debounced writes**: pendingWrite с `.unref()`
|
||||||
|
|
||||||
|
### IPC
|
||||||
|
- Типизированные каналы — `src/shared/ipc.ts`
|
||||||
|
- **Validation layer** — `src/main/validate.ts` (hand-rolled, без zod):
|
||||||
|
- `intervalMinutes ∈ [1, 1440]`, `reps ∈ [1, 9999]`, `multiplier ∈ [0, 1000]`
|
||||||
|
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
|
||||||
|
- HH:MM regex для quietHours, dedup days
|
||||||
|
- Strip `id` из updateExercise/updateChallenge patch
|
||||||
|
- **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged`
|
||||||
|
|
||||||
|
### Auto-update (КРИТИЧНО)
|
||||||
|
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
|
||||||
|
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
|
||||||
|
- Hourly silent auto-check (транзитные сетевые ошибки не показывают красный баннер; только ручной клик показывает ошибку)
|
||||||
|
- Boot-check: 3 ретрая с backoff 30s/2m/5m
|
||||||
|
- `lastCheckedAt` → UI «проверено N мин назад»
|
||||||
|
- Релиз через `scripts/release.ps1` публикует одной командой в:
|
||||||
|
1. `vX.Y.Z` (постоянный архивный тег)
|
||||||
|
2. `update-channel` (rolling — клиенты проверяют отсюда)
|
||||||
|
3. Опциональные `-BridgeTags` для миграции старых пользователей
|
||||||
|
|
||||||
|
### Безопасность
|
||||||
|
- **GSI server** (`src/main/games/gsi-server.ts`): per-install token verify через `timingSafeEqual`, reject Origin/Sec-Fetch-Site (CSRF), 256KB body cap, require `application/json`, generic 400
|
||||||
|
- **shell.openExternal allowlist**: только `http:`/`https:`/`mailto:` (`src/main/windows.ts`)
|
||||||
|
- **will-navigate** блокирует non-file:// и non-dev URL
|
||||||
|
- **Modal focus trap** + focus restore, aria-labelledby
|
||||||
|
|
||||||
|
### Quiet hours
|
||||||
|
- `isQuietAt(time, settings)` в `src/shared/types.ts`
|
||||||
|
- Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна
|
||||||
|
- Тесты в `src/shared/quiet-hours.test.ts`
|
||||||
|
|
||||||
|
### История / стрики
|
||||||
|
- `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика)
|
||||||
|
- Cap 10k записей, trim oldest 10% на overflow
|
||||||
|
- HistoryHeatmap: percentile-based bucketing (p25/p50/p85), а не flat ratio (защищает от outlier-дней)
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
- Самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка)
|
||||||
|
- Хук `useT()`, плюрализация CLDR rules для RU (one/few/many)
|
||||||
|
- Интерполяция через split/join (не regex — защита от regex-инъекций в значениях var)
|
||||||
|
- Tray menu тоже локализован (`TRAY_STRINGS` в `src/main/tray.ts`)
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # electron-vite dev
|
||||||
|
npm run typecheck # tsc по node + web
|
||||||
|
npm run test:run # vitest один раз
|
||||||
|
npm run lint # eslint --max-warnings 0
|
||||||
|
npm run format # prettier --write
|
||||||
|
npm run dist # сборка + NSIS installer → release/
|
||||||
|
|
||||||
|
# Релиз (всё в одном)
|
||||||
|
npm run release -- -Bump patch
|
||||||
|
# или -Bump minor / -Bump major / -Version 1.2.3
|
||||||
|
# опционально: -BridgeTags v0.4.0,v0.4.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Скрипты релиза
|
||||||
|
|
||||||
|
- `scripts/release.ps1` — bump → typecheck → test → build → tag → push → upload в Gitea (vX.Y.Z + update-channel + bridges)
|
||||||
|
- `scripts/upload-release-assets.ps1` — curl.exe с retry/backoff (15s/45s/2m/5m × 4) на 504/TLS, проверяет уже-залилось через list assets перед ретраем
|
||||||
|
- **PowerShell 5.1 gotchas**:
|
||||||
|
- Default reads CP1251 → файлы скриптов **ASCII-only**, без em-dash/кириллицы в коде
|
||||||
|
- `Set-Content -Encoding utf8` добавляет BOM → ломает PostCSS. Для UTF-8 без BOM использовать `[System.IO.File]::WriteAllText` + `new UTF8Encoding($false)`
|
||||||
|
- Никогда `-i` флаги (rebase -i, add -i) — нет interactive input
|
||||||
|
|
||||||
|
## Gitea remote
|
||||||
|
|
||||||
|
- URL: `https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude` (Punycode для `президент.рф`)
|
||||||
|
- User: `anril`
|
||||||
|
- Auth: см. `~/.claude/projects/.../memory/gitea_remote.md`
|
||||||
|
- **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены
|
||||||
|
- `.gitea/workflows/` пустая (раньше там лежали yml → queued runs копились)
|
||||||
|
|
||||||
|
## Файлы, которые часто правлю
|
||||||
|
|
||||||
|
| Файл | Что |
|
||||||
|
|---|---|
|
||||||
|
| `package.json` | version, publish.url, scripts, deps |
|
||||||
|
| `src/main/store.ts` | persistence, migrations, validation, atomic writes |
|
||||||
|
| `src/main/ipc.ts` | IPC handlers с валидацией |
|
||||||
|
| `src/main/scheduler.ts` | таймеры упражнений, powerMonitor |
|
||||||
|
| `src/main/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей |
|
||||||
|
| `src/main/updater.ts` | auto-update logic, silent retries |
|
||||||
|
| `src/shared/types.ts` | shared типы, дефолты, isQuietAt |
|
||||||
|
| `src/shared/ipc.ts` | IPC channel types |
|
||||||
|
| `src/renderer/src/i18n/dict.ts` | словари |
|
||||||
|
| `src/renderer/src/pages/Dashboard.tsx` | главная |
|
||||||
|
| `src/renderer/src/ReminderApp.tsx` | окно напоминания |
|
||||||
|
|
||||||
|
## Тесты (53)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/types.test.ts (4)
|
||||||
|
src/shared/quiet-hours.test.ts (7)
|
||||||
|
src/renderer/src/lib/format.test.ts (8)
|
||||||
|
src/renderer/src/lib/history.test.ts (13)
|
||||||
|
src/main/games/vdf.test.ts (11)
|
||||||
|
src/renderer/src/i18n/i18n.test.ts (10)
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрываются: helpers, история/стрики (DST), тихие часы (wrap+filter), VDF-парсер Steam, i18n с плюрализацией, дефолты.
|
||||||
|
|
||||||
|
## Технический долг (не для пользователя)
|
||||||
|
|
||||||
|
- `sandbox: true` на BrowserWindow — нужен smoke-тест preload в sandbox-режиме
|
||||||
|
- Self-host Google Fonts (сейчас внешняя CSP-зависимость)
|
||||||
|
- ReminderApp race: первое напоминание может прийти без озвучки до загрузки settings
|
||||||
|
- Мажорные апдейты (React 18→19, Electron 33→42, Tailwind 3→4) — каждый ломающий, отдельная итерация
|
||||||
|
- Code-signing NSIS — ~$300/год, уберёт SmartScreen warning
|
||||||
|
- Скриншоты в README (есть TODO в самом README)
|
||||||
|
|
||||||
|
## Стиль кода
|
||||||
|
|
||||||
|
- Prettier: semi:false, singleQuote, trailingComma:none, printWidth:80
|
||||||
|
- ESLint: eslint:recommended + ts + react + react-hooks (без style rules — это Prettier)
|
||||||
|
- TypeScript strict, никакого `any` в новом коде
|
||||||
|
- Комментарии на русском там, где объясняют **почему**, не **что**
|
||||||
|
- Коммиты на русском, формат `тип(scope): кратко` (feat/fix/docs/refactor/test/chore)
|
||||||
|
- Co-Authored-By футер в коммитах от Claude
|
||||||
|
|
||||||
|
## Управление контекстом
|
||||||
|
|
||||||
|
- **Ужимать контекст при достижении 250k токенов** — вызывать `/compact` (или эквивалент) когда суммарный контекст подходит к 250 000 токенов. Не дожидаться authentic переполнения и автоматического сжатия от рантайма — сделать это контролируемо, чтобы важный контекст (открытые правки, недокоммиченные решения, текущая ветка задачи) попал в summary, а не выпал.
|
||||||
|
|
||||||
|
## Чего НЕ делать
|
||||||
|
|
||||||
|
- Не пушить в `update-channel` руками — только через `release.ps1`
|
||||||
|
- Не добавлять `.gitea/workflows/*.yml` — has_actions выключен, runs зависнут
|
||||||
|
- Не использовать regex в i18n-интерполяции — только split/join
|
||||||
|
- Не silent wipe corrupt JSON — quarantine с timestamp
|
||||||
|
- Не возвращать ms-арифметику в history.ts — DST сломается
|
||||||
|
- Не убирать validation layer из IPC — compromised renderer может засунуть NaN/негативы
|
||||||
|
- Не амендить коммиты без явной просьбы пользователя
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
|
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
|
||||||
|
|
||||||
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## Что внутри
|
## Что внутри
|
||||||
|
|||||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.5.1",
|
"version": "0.5.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.5.1",
|
"version": "0.5.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
@@ -1264,6 +1267,33 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/bricolage-grotesque": {
|
||||||
|
"version": "5.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.2.10.tgz",
|
||||||
|
"integrity": "sha512-V2xS+1P7C8IrSypXLUx/bLtX/LsTlYtV2k2CsU+S/0t8qepZ2hvKSlyJIx7Ub/iY8Bbnj+IjAuUF9nvFz+BbIg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/jetbrains-mono": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/plus-jakarta-sans": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-P5qE49fqdeD+7DXH1KBxmMPlB17LTz1zvBhFH0tFzfnYTKVJVyb0pR6plh0ZGXxcB+Oayb54FZZw3V42/DawTw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@gar/promisify": {
|
"node_modules/@gar/promisify": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.5.2",
|
"version": "0.5.5",
|
||||||
"description": "Exercise reminder — Windows desktop app",
|
"description": "Exercise reminder — Windows desktop app",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "AnRil",
|
"author": "AnRil",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\" \".github/**/*.yml\"",
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\" \".github/**/*.yml\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\"",
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\"",
|
||||||
"lint": "eslint src --ext .ts,.tsx --max-warnings 0",
|
"lint": "eslint src --ext .ts,.tsx --max-warnings 0",
|
||||||
@@ -24,6 +25,9 @@
|
|||||||
"gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1"
|
"gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
isSteamRunning
|
isSteamRunning
|
||||||
} from './steam-launch-options'
|
} from './steam-launch-options'
|
||||||
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
|
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
|
||||||
|
import { log } from '../logger'
|
||||||
|
|
||||||
const APP_ID = '570'
|
const APP_ID = '570'
|
||||||
const INSTALL_DIR = 'dota 2 beta'
|
const INSTALL_DIR = 'dota 2 beta'
|
||||||
@@ -198,6 +199,8 @@ export class Dota2Provider implements GameProvider {
|
|||||||
this.latest = undefined
|
this.latest = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rejectedTokenLogged = false
|
||||||
|
|
||||||
private handle(g: DotaGsi): void {
|
private handle(g: DotaGsi): void {
|
||||||
// Verify the per-install token. Dota always sends auth.token; anything
|
// Verify the per-install token. Dota always sends auth.token; anything
|
||||||
// without it (or with the wrong one) is some other process on localhost
|
// without it (or with the wrong one) is some other process on localhost
|
||||||
@@ -207,6 +210,15 @@ export class Dota2Provider implements GameProvider {
|
|||||||
typeof incoming !== 'string' ||
|
typeof incoming !== 'string' ||
|
||||||
!safeEqualStrings(incoming, this.token)
|
!safeEqualStrings(incoming, this.token)
|
||||||
) {
|
) {
|
||||||
|
// Логируем только ОДИН раз за процесс — Dota шлёт payload каждые
|
||||||
|
// ~100ms во время матча, иначе zass'мём latest.log.
|
||||||
|
if (!this.rejectedTokenLogged) {
|
||||||
|
this.rejectedTokenLogged = true
|
||||||
|
log.warn(
|
||||||
|
'[dota2] GSI payload with invalid/missing token rejected. ' +
|
||||||
|
'Если приложение переустанавливалось — заново подключи Dota 2 в Games.'
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +247,12 @@ export class Dota2Provider implements GameProvider {
|
|||||||
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
|
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
|
||||||
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
|
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - this.lastMatchEndAt < 30_000) return
|
if (now - this.lastMatchEndAt < 30_000) {
|
||||||
|
log.debug('[dota2] suppressed duplicate POST_GAME within 30s window')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.lastMatchEndAt = now
|
this.lastMatchEndAt = now
|
||||||
|
log.info('[dota2] POST_GAME detected, emitting match_end event')
|
||||||
|
|
||||||
const p = this.latest?.player ?? {}
|
const p = this.latest?.player ?? {}
|
||||||
const m = this.latest?.map ?? {}
|
const m = this.latest?.map ?? {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type Server,
|
type Server,
|
||||||
type ServerResponse
|
type ServerResponse
|
||||||
} from 'node:http'
|
} from 'node:http'
|
||||||
|
import { log } from '../logger'
|
||||||
|
|
||||||
export type GsiHandler = (
|
export type GsiHandler = (
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
@@ -87,7 +88,7 @@ async function onRequest(
|
|||||||
payload = text.length > 0 ? JSON.parse(text) : {}
|
payload = text.length > 0 ? JSON.parse(text) : {}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log the real reason locally; do not echo it to the client.
|
// Log the real reason locally; do not echo it to the client.
|
||||||
console.warn('[gsi] bad request:', err instanceof Error ? err.message : err)
|
log.warn('[gsi] bad request', err instanceof Error ? err.message : err)
|
||||||
res.statusCode = 400
|
res.statusCode = 400
|
||||||
res.end()
|
res.end()
|
||||||
return
|
return
|
||||||
@@ -99,7 +100,7 @@ async function onRequest(
|
|||||||
res.setHeader('Content-Type', 'text/plain')
|
res.setHeader('Content-Type', 'text/plain')
|
||||||
res.end('ok')
|
res.end('ok')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[gsi] handler threw:', err)
|
log.error('[gsi] handler threw', err)
|
||||||
res.statusCode = 500
|
res.statusCode = 500
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
import { STAT_LABELS } from '@shared/types'
|
import { STAT_LABELS } from '@shared/types'
|
||||||
import { getChallenges, getGamesEnabled } from '../store'
|
import { getChallenges, getGamesEnabled } from '../store'
|
||||||
import { fireMatchSummary } from '../notifications'
|
import { fireMatchSummary } from '../notifications'
|
||||||
|
import { log } from '../logger'
|
||||||
|
|
||||||
const providers: Record<GameId, GameProvider> = {
|
const providers: Record<GameId, GameProvider> = {
|
||||||
dota2: new Dota2Provider()
|
dota2: new Dota2Provider()
|
||||||
@@ -25,14 +26,23 @@ async function onMatchEnd(
|
|||||||
payload: MatchEndPayload
|
payload: MatchEndPayload
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const provider = providers[gameId]
|
const provider = providers[gameId]
|
||||||
const challenges = getChallenges().filter(
|
const allChallenges = getChallenges().filter((c) => c.gameId === gameId)
|
||||||
(c) => c.gameId === gameId && c.enabled
|
const enabledChallenges = allChallenges.filter((c) => c.enabled)
|
||||||
|
log.info(
|
||||||
|
`[games] match_end gameId=${gameId} stats=${JSON.stringify(
|
||||||
|
payload.stats
|
||||||
|
)} challenges=${enabledChallenges.length}/${allChallenges.length} (enabled/total)`
|
||||||
)
|
)
|
||||||
const results: ChallengeResult[] = []
|
const results: ChallengeResult[] = []
|
||||||
for (const ch of challenges) {
|
for (const ch of enabledChallenges) {
|
||||||
const statValue = payload.stats[ch.stat] ?? 0
|
const statValue = payload.stats[ch.stat] ?? 0
|
||||||
const reps = Math.round(statValue * ch.multiplier)
|
const reps = Math.round(statValue * ch.multiplier)
|
||||||
if (reps <= 0) continue
|
if (reps <= 0) {
|
||||||
|
log.debug(
|
||||||
|
`[games] skip challenge "${ch.name}": ${ch.stat}=${statValue} × ${ch.multiplier} = ${reps}`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
results.push({
|
results.push({
|
||||||
challengeId: ch.id,
|
challengeId: ch.id,
|
||||||
name: ch.name,
|
name: ch.name,
|
||||||
@@ -44,7 +54,21 @@ async function onMatchEnd(
|
|||||||
stat: ch.stat
|
stat: ch.stat
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (results.length === 0) return
|
if (results.length === 0) {
|
||||||
|
log.warn(
|
||||||
|
`[games] match_end produced no reps (no enabled challenges matched stats). ` +
|
||||||
|
`Enabled challenges: ${enabledChallenges.length}, stats keys: ${Object.keys(
|
||||||
|
payload.stats
|
||||||
|
).join(',')}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info(
|
||||||
|
`[games] firing match summary: ${results.length} challenges, total reps ${results.reduce(
|
||||||
|
(s, r) => s + r.reps,
|
||||||
|
0
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
|
||||||
const summary: MatchSummary = {
|
const summary: MatchSummary = {
|
||||||
gameId,
|
gameId,
|
||||||
@@ -61,8 +85,9 @@ export async function startGamesRegistry(): Promise<void> {
|
|||||||
running = true
|
running = true
|
||||||
try {
|
try {
|
||||||
await startGsiServer()
|
await startGsiServer()
|
||||||
|
log.info('[games] GSI server started on port 4701')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GSI server failed to start:', err)
|
log.error('[games] GSI server failed to start', err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +104,7 @@ export async function startGamesRegistry(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await provider.reconcile?.()
|
await provider.reconcile?.()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('reconcile failed for', id, err)
|
log.error(`[games] reconcile failed for ${id}`, err)
|
||||||
}
|
}
|
||||||
if (!enabled[id]) continue
|
if (!enabled[id]) continue
|
||||||
await provider.start((e) => {
|
await provider.start((e) => {
|
||||||
|
|||||||
@@ -123,20 +123,19 @@ function writeBackup(path: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function atomicWrite(path: string, contents: string): void {
|
async function atomicWrite(path: string, contents: string): Promise<void> {
|
||||||
// Write to temp then rename (atomic on Windows for same directory). Retry a
|
// Write to temp then rename (atomic on Windows for same directory). Retry a
|
||||||
// few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes
|
// few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes
|
||||||
// hold a handle briefly during a Steam config rewrite).
|
// hold a handle briefly during a Steam config rewrite).
|
||||||
|
//
|
||||||
|
// Раньше тут был busy-loop sleep — Steam-конфиги пишутся редко, но из main
|
||||||
|
// process, и при попадании на занятый файл (Steam ещё держит handle) морозили
|
||||||
|
// весь UI на 250мс. Заменили на async setTimeout-sleep.
|
||||||
const tmp = path + '.exr.tmp'
|
const tmp = path + '.exr.tmp'
|
||||||
const delays = [0, 50, 200]
|
const delays = [0, 50, 200]
|
||||||
let lastErr: unknown
|
let lastErr: unknown
|
||||||
for (const delay of delays) {
|
for (const delay of delays) {
|
||||||
if (delay > 0) {
|
if (delay > 0) await new Promise<void>((r) => setTimeout(r, delay))
|
||||||
const until = Date.now() + delay
|
|
||||||
while (Date.now() < until) {
|
|
||||||
/* spin */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(tmp, contents, 'utf-8')
|
writeFileSync(tmp, contents, 'utf-8')
|
||||||
renameSync(tmp, path)
|
renameSync(tmp, path)
|
||||||
@@ -148,11 +147,11 @@ function atomicWrite(path: string, contents: string): void {
|
|||||||
throw lastErr
|
throw lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
function modifyLaunchOptions(
|
async function modifyLaunchOptions(
|
||||||
configPath: string,
|
configPath: string,
|
||||||
appId: string,
|
appId: string,
|
||||||
fn: (current: string) => string | null
|
fn: (current: string) => string | null
|
||||||
): boolean {
|
): Promise<boolean> {
|
||||||
let raw: string
|
let raw: string
|
||||||
try {
|
try {
|
||||||
raw = readFileSync(configPath, 'utf-8')
|
raw = readFileSync(configPath, 'utf-8')
|
||||||
@@ -188,7 +187,7 @@ function modifyLaunchOptions(
|
|||||||
|
|
||||||
writeBackup(configPath)
|
writeBackup(configPath)
|
||||||
try {
|
try {
|
||||||
atomicWrite(configPath, stringifyVdf(parsed))
|
await atomicWrite(configPath, stringifyVdf(parsed))
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -225,7 +224,7 @@ async function applyOptionToAllConfigs(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const paths = await getLocalConfigPaths()
|
const paths = await getLocalConfigPaths()
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
modifyLaunchOptions(p, appId, (current) => {
|
await modifyLaunchOptions(p, appId, (current) => {
|
||||||
if (current.includes(option)) return current
|
if (current.includes(option)) return current
|
||||||
return current.length > 0 ? `${current} ${option}` : option
|
return current.length > 0 ? `${current} ${option}` : option
|
||||||
})
|
})
|
||||||
@@ -238,7 +237,7 @@ async function removeOptionFromAllConfigs(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const paths = await getLocalConfigPaths()
|
const paths = await getLocalConfigPaths()
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
modifyLaunchOptions(p, appId, (current) => {
|
await modifyLaunchOptions(p, appId, (current) => {
|
||||||
if (!current.includes(option)) return current
|
if (!current.includes(option)) return current
|
||||||
return current
|
return current
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
|
|||||||
@@ -73,11 +73,26 @@ if (!gotLock) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
// Перехватываем первый before-quit, чтобы дождаться `stopGamesRegistry`
|
||||||
|
// (закрывает GSI HTTP server со всеми pending connections). Без этого
|
||||||
|
// следующий запуск получает EADDRINUSE на port 4701 (TIME_WAIT), и
|
||||||
|
// GSI молча не работает. После cleanup'а — реально quit.
|
||||||
|
let quitting = false
|
||||||
|
app.on('before-quit', (e) => {
|
||||||
|
if (quitting) return
|
||||||
|
e.preventDefault()
|
||||||
|
quitting = true
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
stopUpdater()
|
stopUpdater()
|
||||||
void stopGamesRegistry()
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await stopGamesRegistry()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[index] stopGamesRegistry threw:', err)
|
||||||
|
}
|
||||||
flushNow()
|
flushNow()
|
||||||
|
app.exit(0)
|
||||||
|
})()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
deleteExercise,
|
deleteExercise,
|
||||||
getHistory,
|
getHistory,
|
||||||
getState,
|
getState,
|
||||||
|
getStateForRenderer,
|
||||||
markDone,
|
markDone,
|
||||||
setGameEnabled,
|
setGameEnabled,
|
||||||
skip,
|
skip,
|
||||||
@@ -56,8 +57,13 @@ import {
|
|||||||
|
|
||||||
export function registerIpc(): void {
|
export function registerIpc(): void {
|
||||||
ipcMain.handle(IPC.getState, () => {
|
ipcMain.handle(IPC.getState, () => {
|
||||||
const state = getState()
|
// Без history (см. getStateForRenderer) и с актуальным значением
|
||||||
state.settings.startWithWindows = isAutostartEnabled()
|
// autostart из OS — мутацию делаем по копии, не по cache.
|
||||||
|
const state = getStateForRenderer()
|
||||||
|
state.settings = {
|
||||||
|
...state.settings,
|
||||||
|
startWithWindows: isAutostartEnabled()
|
||||||
|
}
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -176,6 +182,17 @@ export function registerIpc(): void {
|
|||||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.on(IPC.toggleMaximizeMain, (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!win) return
|
||||||
|
if (win.isMaximized()) win.unmaximize()
|
||||||
|
else win.maximize()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.isMaximizedMain, (event) => {
|
||||||
|
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.on(IPC.closeMain, () => {
|
ipcMain.on(IPC.closeMain, () => {
|
||||||
const main = getMainWindow()
|
const main = getMainWindow()
|
||||||
if (!main) return
|
if (!main) return
|
||||||
@@ -264,7 +281,7 @@ export function registerIpc(): void {
|
|||||||
// otherwise fabricate arbitrary match-end events at will.
|
// otherwise fabricate arbitrary match-end events at will.
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'dev:simulateMatchEnd',
|
IPC.devSimulateMatchEnd,
|
||||||
(_e, id: GameId, stats: Record<string, number>) => {
|
(_e, id: GameId, stats: Record<string, number>) => {
|
||||||
simulateMatchEnd(id, stats)
|
simulateMatchEnd(id, stats)
|
||||||
}
|
}
|
||||||
@@ -274,8 +291,13 @@ export function registerIpc(): void {
|
|||||||
// Auto-updater
|
// Auto-updater
|
||||||
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
|
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
|
||||||
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
||||||
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
|
// download/install — fire-and-forget. Прогресс и завершение приходят в
|
||||||
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
|
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
|
||||||
|
// только зря держал бы `busy=true` весь download (минуты на медленной сети).
|
||||||
|
ipcMain.on(IPC.updaterDownload, () => {
|
||||||
|
void downloadUpdate()
|
||||||
|
})
|
||||||
|
ipcMain.on(IPC.updaterInstall, () => quitAndInstall())
|
||||||
|
|
||||||
// History
|
// History
|
||||||
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
||||||
|
|||||||
125
src/main/logger.ts
Normal file
125
src/main/logger.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* eslint-disable no-console -- этот файл — единственное место где console.*
|
||||||
|
разрешён намеренно: дублирование лога в stderr для dev-режима. */
|
||||||
|
/**
|
||||||
|
* Минимальный logger для main process.
|
||||||
|
*
|
||||||
|
* Пишет в файл `%APPDATA%/Exercise Reminder/logs/latest.log` + дублирует
|
||||||
|
* в stderr через console.* (чтобы dev-режим оставался удобным).
|
||||||
|
*
|
||||||
|
* Ротация: при достижении 1MB latest.log переименовывается в prev.log
|
||||||
|
* (предыдущий prev.log удаляется). Две сессии истории — этого достаточно
|
||||||
|
* для воспроизведения «случилось вчера, а сегодня перезапустил». Никакой
|
||||||
|
* remote-телеметрии: лог локальный, пользователь сам может вложить его в
|
||||||
|
* issue если что-то сломалось.
|
||||||
|
*
|
||||||
|
* Уровни:
|
||||||
|
* - debug: подробный traceback, видим только если LAUDE_DEBUG=1
|
||||||
|
* - info: значимые события (startup, GSI matched, updater progress)
|
||||||
|
* - warn: recoverable issues (transient network, retry succeeded)
|
||||||
|
* - error: что-то реально сломалось (atomic write fail, IPC validation)
|
||||||
|
*/
|
||||||
|
import { app } from 'electron'
|
||||||
|
import {
|
||||||
|
appendFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
renameSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync
|
||||||
|
} from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const ROTATE_AT_BYTES = 1 * 1024 * 1024 // 1 MB
|
||||||
|
|
||||||
|
type Level = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
let logDir = ''
|
||||||
|
let logPath = ''
|
||||||
|
let prevPath = ''
|
||||||
|
|
||||||
|
function ensurePaths(): void {
|
||||||
|
if (logDir) return
|
||||||
|
try {
|
||||||
|
logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
logPath = join(logDir, 'latest.log')
|
||||||
|
prevPath = join(logDir, 'prev.log')
|
||||||
|
} catch {
|
||||||
|
// app.getPath не готов (очень ранний boot) — отложим, console продолжит.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateIfNeeded(): void {
|
||||||
|
if (!logPath) return
|
||||||
|
try {
|
||||||
|
if (!existsSync(logPath)) return
|
||||||
|
const size = statSync(logPath).size
|
||||||
|
if (size < ROTATE_AT_BYTES) return
|
||||||
|
if (existsSync(prevPath)) unlinkSync(prevPath)
|
||||||
|
renameSync(logPath, prevPath)
|
||||||
|
} catch {
|
||||||
|
// не критично — продолжим писать в latest.log с overflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ts(): string {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelTag(l: Level): string {
|
||||||
|
return l.toUpperCase().padEnd(5, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(level: Level, msg: string, extra?: unknown): void {
|
||||||
|
// Always dup to console for dev. structuredClone-style serialize:
|
||||||
|
const line = `[${ts()}] ${levelTag(level)} ${msg}${
|
||||||
|
extra !== undefined ? ' ' + safeStringify(extra) : ''
|
||||||
|
}\n`
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
console.error(line.trimEnd())
|
||||||
|
break
|
||||||
|
case 'warn':
|
||||||
|
console.warn(line.trimEnd())
|
||||||
|
break
|
||||||
|
case 'debug':
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
console.log(line.trimEnd())
|
||||||
|
}
|
||||||
|
ensurePaths()
|
||||||
|
rotateIfNeeded()
|
||||||
|
if (!logPath) return
|
||||||
|
try {
|
||||||
|
appendFileSync(logPath, line, 'utf-8')
|
||||||
|
} catch {
|
||||||
|
// Если AV держит файл — переживём, в console уже залогировали.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStringify(v: unknown): string {
|
||||||
|
if (v instanceof Error) {
|
||||||
|
return v.stack ?? `${v.name}: ${v.message}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(v)
|
||||||
|
} catch {
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG_ENABLED = process.env.LAUDE_DEBUG === '1'
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
debug: (msg: string, extra?: unknown): void => {
|
||||||
|
if (DEBUG_ENABLED) write('debug', msg, extra)
|
||||||
|
},
|
||||||
|
info: (msg: string, extra?: unknown): void => write('info', msg, extra),
|
||||||
|
warn: (msg: string, extra?: unknown): void => write('warn', msg, extra),
|
||||||
|
error: (msg: string, extra?: unknown): void => write('error', msg, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Путь к логам (для диагностики). Возвращает пустую строку до initLogger(). */
|
||||||
|
export function getLogDir(): string {
|
||||||
|
return logDir
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
import { IPC } from '@shared/ipc'
|
import { IPC } from '@shared/ipc'
|
||||||
import { getExercises, getState, updateExercise } from './store'
|
import { getExercises, getStateForRenderer, updateExercise } from './store'
|
||||||
|
|
||||||
export function broadcastState(): void {
|
export function broadcastState(): void {
|
||||||
const state = getState()
|
// Используем variant без `history` — иначе при 10k записей через IPC
|
||||||
|
// на каждый markDone летит 500KB JSON × M подписчиков. Renderer
|
||||||
|
// запрашивает историю отдельно через IPC.getHistory.
|
||||||
|
const state = getStateForRenderer()
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state)
|
if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
GameId,
|
GameId,
|
||||||
HistoryAction,
|
HistoryAction,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
|
PersistedState,
|
||||||
SAMPLE_EXERCISES,
|
SAMPLE_EXERCISES,
|
||||||
Settings
|
Settings
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
import { log } from './logger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep at most this many history entries (≈2.7 years at 10/day).
|
* Keep at most this many history entries (≈2.7 years at 10/day).
|
||||||
@@ -30,7 +32,7 @@ const HISTORY_MAX = 10_000
|
|||||||
const WRITE_DEBOUNCE_MS = 1500
|
const WRITE_DEBOUNCE_MS = 1500
|
||||||
const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM
|
const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM
|
||||||
|
|
||||||
let cache: AppState | null = null
|
let cache: PersistedState | null = null
|
||||||
let storePath = ''
|
let storePath = ''
|
||||||
let pendingWrite: NodeJS.Timeout | null = null
|
let pendingWrite: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ function getStorePath(): string {
|
|||||||
return storePath
|
return storePath
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeInitial(): AppState {
|
function makeInitial(): PersistedState {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
return {
|
return {
|
||||||
exercises: SAMPLE_EXERCISES.map((e) => ({
|
exercises: SAMPLE_EXERCISES.map((e) => ({
|
||||||
@@ -88,12 +90,11 @@ function quarantineCorrupt(p: string, reason: string): void {
|
|||||||
.replace(/Z$/, '')
|
.replace(/Z$/, '')
|
||||||
const dest = `${p}.corrupt-${stamp}`
|
const dest = `${p}.corrupt-${stamp}`
|
||||||
renameSync(p, dest)
|
renameSync(p, dest)
|
||||||
console.error(
|
log.error(
|
||||||
`[store] app-state.json was unreadable (${reason}); ` +
|
`[store] app-state.json was unreadable (${reason}); moved to ${dest} and starting fresh.`
|
||||||
`moved to ${dest} and starting fresh.`
|
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[store] failed to quarantine corrupt state file:', e)
|
log.error('[store] failed to quarantine corrupt state file', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +145,8 @@ function runMigrations(s: StoredState): StoredState {
|
|||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coerce a (possibly partial) migrated state into a fully-formed AppState. */
|
/** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */
|
||||||
function coerce(s: StoredState): AppState {
|
function coerce(s: StoredState): PersistedState {
|
||||||
return {
|
return {
|
||||||
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [],
|
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [],
|
||||||
settings: {
|
settings: {
|
||||||
@@ -162,11 +163,12 @@ function coerce(s: StoredState): AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(): AppState {
|
function load(): PersistedState {
|
||||||
const p = getStorePath()
|
const p = getStorePath()
|
||||||
if (!existsSync(p)) {
|
if (!existsSync(p)) {
|
||||||
const initial = makeInitial()
|
const initial = makeInitial()
|
||||||
atomicWrite(
|
// Cold path — sync write на инициализации (event-loop ещё не активен).
|
||||||
|
atomicWriteSync(
|
||||||
p,
|
p,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
|
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
|
||||||
@@ -180,7 +182,7 @@ function load(): AppState {
|
|||||||
try {
|
try {
|
||||||
raw = readFileSync(p, 'utf-8')
|
raw = readFileSync(p, 'utf-8')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[store] cannot read state file:', e)
|
log.error('[store] cannot read state file', e)
|
||||||
return makeInitial() // do not quarantine — we can't read it anyway
|
return makeInitial() // do not quarantine — we can't read it anyway
|
||||||
}
|
}
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
@@ -235,8 +237,16 @@ export function clearHistory(beforeTs?: number): number {
|
|||||||
/**
|
/**
|
||||||
* Atomically write to `path` via a sibling .tmp file + rename. Retries a few
|
* Atomically write to `path` via a sibling .tmp file + rename. Retries a few
|
||||||
* times on transient EBUSY/EPERM (AV/OneDrive holding the file).
|
* times on transient EBUSY/EPERM (AV/OneDrive holding the file).
|
||||||
|
*
|
||||||
|
* Async version (используется debounced scheduleWrite/flush) — раньше был
|
||||||
|
* busy-loop `while (Date.now() < until)`, который морозил весь main process
|
||||||
|
* на retry-delay (до 800мс). При активном AV это превращалось в видимое
|
||||||
|
* залипание UI. Сейчас sleep через setTimeout-promise.
|
||||||
|
*
|
||||||
|
* Для процесса-выхода используется `atomicWriteSync` — там event-loop уже
|
||||||
|
* не работает, async sleep не сработает.
|
||||||
*/
|
*/
|
||||||
function atomicWrite(path: string, contents: string): void {
|
async function atomicWrite(path: string, contents: string): Promise<void> {
|
||||||
const tmp = `${path}.tmp`
|
const tmp = `${path}.tmp`
|
||||||
let lastErr: unknown
|
let lastErr: unknown
|
||||||
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
|
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
|
||||||
@@ -246,7 +256,6 @@ function atomicWrite(path: string, contents: string): void {
|
|||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastErr = e
|
lastErr = e
|
||||||
// best-effort cleanup of the stale .tmp
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(tmp)) unlinkSync(tmp)
|
if (existsSync(tmp)) unlinkSync(tmp)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -254,40 +263,97 @@ function atomicWrite(path: string, contents: string): void {
|
|||||||
}
|
}
|
||||||
const delay = WRITE_RETRY_DELAYS[i]
|
const delay = WRITE_RETRY_DELAYS[i]
|
||||||
if (delay === undefined) break
|
if (delay === undefined) break
|
||||||
// Synchronous sleep — write path is short and called outside the hot loop.
|
await new Promise<void>((r) => setTimeout(r, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.error('[store] atomic write failed after retries', lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронный вариант для use-cases где event loop уже не работает
|
||||||
|
* (process exit в `before-quit`). При retry — короткий sync sleep, потому
|
||||||
|
* что иначе мы дропнем pending write при exit'е.
|
||||||
|
*/
|
||||||
|
function atomicWriteSync(path: string, contents: string): void {
|
||||||
|
const tmp = `${path}.tmp`
|
||||||
|
let lastErr: unknown
|
||||||
|
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
|
||||||
|
try {
|
||||||
|
writeFileSync(tmp, contents, 'utf-8')
|
||||||
|
renameSync(tmp, path)
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e
|
||||||
|
try {
|
||||||
|
if (existsSync(tmp)) unlinkSync(tmp)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const delay = WRITE_RETRY_DELAYS[i]
|
||||||
|
if (delay === undefined) break
|
||||||
|
// Event-loop остановлен, async sleep не вернётся — приходится spin.
|
||||||
const until = Date.now() + delay
|
const until = Date.now() + delay
|
||||||
while (Date.now() < until) {
|
while (Date.now() < until) {
|
||||||
/* spin */
|
/* spin */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error('[store] atomic write failed after retries:', lastErr)
|
log.error('[store] atomic sync write failed after retries', lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
function flush(): void {
|
async function flush(): Promise<void> {
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
// Persist the schema version alongside the state so future migrations know
|
// Persist the schema version alongside the state so future migrations know
|
||||||
// where to pick up from. The renderer never reads this key.
|
// where to pick up from. The renderer never reads this key.
|
||||||
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
|
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
|
||||||
atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
|
await atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushSync(): void {
|
||||||
|
if (!cache) return
|
||||||
|
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
|
||||||
|
atomicWriteSync(getStorePath(), JSON.stringify(payload, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleWrite(): void {
|
function scheduleWrite(): void {
|
||||||
if (pendingWrite) return
|
if (pendingWrite) return
|
||||||
pendingWrite = setTimeout(() => {
|
pendingWrite = setTimeout(() => {
|
||||||
pendingWrite = null
|
pendingWrite = null
|
||||||
flush()
|
void flush()
|
||||||
}, WRITE_DEBOUNCE_MS)
|
}, WRITE_DEBOUNCE_MS)
|
||||||
// Don't keep the event loop alive solely for a pending write — `before-quit`
|
// Don't keep the event loop alive solely for a pending write — `before-quit`
|
||||||
// calls `flushNow()` and we explicitly want the process to exit on schedule.
|
// calls `flushNow()` and we explicitly want the process to exit on schedule.
|
||||||
pendingWrite.unref?.()
|
pendingWrite.unref?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getState(): AppState {
|
/**
|
||||||
|
* Internal persisted state — единственный source of truth. Включает историю.
|
||||||
|
* Mutate напрямую (mutations внутри store.ts), затем scheduleWrite().
|
||||||
|
*/
|
||||||
|
export function getState(): PersistedState {
|
||||||
if (!cache) cache = load()
|
if (!cache) cache = load()
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State для отправки renderer'у. Копия БЕЗ `history` — историю renderer
|
||||||
|
* запрашивает отдельным IPC.getHistory. Раньше каждый markDone/snooze
|
||||||
|
* отправлял весь state через evtStateChanged, и при 10k entries в истории
|
||||||
|
* это 500KB JSON × N IPC mutations подряд → заметный лаг.
|
||||||
|
*
|
||||||
|
* Возвращаемая копия безопасна для мутации (ipc.ts накладывает на settings
|
||||||
|
* актуальное OS-значение startWithWindows) — мы НЕ мутируем cache.
|
||||||
|
*/
|
||||||
|
export function getStateForRenderer(): AppState {
|
||||||
|
const p = getState()
|
||||||
|
return {
|
||||||
|
exercises: p.exercises,
|
||||||
|
settings: p.settings,
|
||||||
|
challenges: p.challenges,
|
||||||
|
gamesEnabled: p.gamesEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getSettings(): Settings {
|
export function getSettings(): Settings {
|
||||||
return getState().settings
|
return getState().settings
|
||||||
}
|
}
|
||||||
@@ -389,7 +455,9 @@ export function flushNow(): void {
|
|||||||
clearTimeout(pendingWrite)
|
clearTimeout(pendingWrite)
|
||||||
pendingWrite = null
|
pendingWrite = null
|
||||||
}
|
}
|
||||||
flush()
|
// before-quit вызывает нас когда event-loop уже на пути к выходу — async
|
||||||
|
// promise не успеет resolved, поэтому sync.
|
||||||
|
flushSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChallenges(): Challenge[] {
|
export function getChallenges(): Challenge[] {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { IPC } from '@shared/ipc'
|
import { IPC } from '@shared/ipc'
|
||||||
import type { UpdaterStatus } from '@shared/types'
|
import type { UpdaterStatus } from '@shared/types'
|
||||||
|
import { log } from './logger'
|
||||||
|
|
||||||
let currentStatus: UpdaterStatus = { kind: 'idle' }
|
let currentStatus: UpdaterStatus = { kind: 'idle' }
|
||||||
let lastCheckedAt: number | undefined
|
let lastCheckedAt: number | undefined
|
||||||
@@ -98,7 +99,7 @@ export function initUpdater(): void {
|
|||||||
if (silentMode) {
|
if (silentMode) {
|
||||||
// Background check failed — keep previous status, don't show red banner.
|
// Background check failed — keep previous status, don't show red banner.
|
||||||
// Will retry on the next hourly tick.
|
// Will retry on the next hourly tick.
|
||||||
console.warn('[updater] silent check failed:', message)
|
log.warn('[updater] silent check failed', message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStatus({ kind: 'error', message })
|
setStatus({ kind: 'error', message })
|
||||||
@@ -148,7 +149,7 @@ export async function checkForUpdates(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
if (silentMode) {
|
if (silentMode) {
|
||||||
console.warn('[updater] silent check failed (sync):', message)
|
log.warn('[updater] silent check failed (sync)', message)
|
||||||
} else {
|
} else {
|
||||||
setStatus({ kind: 'error', message })
|
setStatus({ kind: 'error', message })
|
||||||
}
|
}
|
||||||
@@ -172,5 +173,12 @@ export async function downloadUpdate(): Promise<void> {
|
|||||||
|
|
||||||
export function quitAndInstall(): void {
|
export function quitAndInstall(): void {
|
||||||
if (!app.isPackaged) return
|
if (!app.isPackaged) return
|
||||||
autoUpdater.quitAndInstall()
|
// (isSilent=true, isForceRunAfter=true):
|
||||||
|
// - isSilent: NSIS работает без UI-диалогов установки → restart занимает
|
||||||
|
// ~1-2 сек вместо ~5-10 (без чёрного окна установщика на половину экрана).
|
||||||
|
// - isForceRunAfter: гарантируем что после установки приложение запустится
|
||||||
|
// автоматически, даже если в NSIS-конфиге runAfterFinish был выключен
|
||||||
|
// для этого сценария. Без этого пользователь нажал «Рестарт» — и остался
|
||||||
|
// без открытого приложения.
|
||||||
|
autoUpdater.quitAndInstall(true, true)
|
||||||
}
|
}
|
||||||
|
|||||||
408
src/main/validate.test.ts
Normal file
408
src/main/validate.test.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Тесты для IPC validation layer.
|
||||||
|
*
|
||||||
|
* Этот слой — security-boundary между renderer и main. Если он сломается,
|
||||||
|
* compromised renderer сможет писать в стор NaN, отрицательные, Infinity,
|
||||||
|
* сверхдлинные строки или undefined-enum'ы. Поэтому покрытие важно для:
|
||||||
|
*
|
||||||
|
* 1. Тип-проверок (строка/число/булево/массив)
|
||||||
|
* 2. Range-checks (reps ∈ [1,9999], minutes ∈ [1,1440] и т.д.)
|
||||||
|
* 3. Enum allowlist (theme/lang/notify-mode/stat)
|
||||||
|
* 4. Edge cases: NaN, Infinity, MAX_SAFE_INTEGER, 0, отрицательные, длина строк
|
||||||
|
* 5. Partial-patch semantics (отсутствие поля ≠ невалидное значение)
|
||||||
|
* 6. Сложный nested case: quietHours с HH:MM regex и dedup days
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
validateExerciseInput,
|
||||||
|
validateExercisePatch,
|
||||||
|
validateChallengeInput,
|
||||||
|
validateChallengePatch,
|
||||||
|
validateSettingsPatch,
|
||||||
|
validateId,
|
||||||
|
validateActualReps,
|
||||||
|
validateSnoozeMinutes
|
||||||
|
} from './validate'
|
||||||
|
|
||||||
|
const validExercise = {
|
||||||
|
name: 'Push-ups',
|
||||||
|
reps: 10,
|
||||||
|
intervalMinutes: 30,
|
||||||
|
icon: 'Dumbbell',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('validateExerciseInput', () => {
|
||||||
|
it('accepts a fully-formed valid input', () => {
|
||||||
|
expect(validateExerciseInput(validExercise)).toEqual(validExercise)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-objects', () => {
|
||||||
|
expect(validateExerciseInput(null)).toBeNull()
|
||||||
|
expect(validateExerciseInput(undefined)).toBeNull()
|
||||||
|
expect(validateExerciseInput('string')).toBeNull()
|
||||||
|
expect(validateExerciseInput(42)).toBeNull()
|
||||||
|
expect(validateExerciseInput([])).toBeNull() // arrays not allowed
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing required fields', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: undefined })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: undefined })).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: undefined })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range reps', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: 0 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: -1 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: 10_000 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: NaN })).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, reps: Infinity })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates reps with Math.trunc (5.7 → 5)', () => {
|
||||||
|
const r = validateExerciseInput({ ...validExercise, reps: 5.7 })
|
||||||
|
expect(r?.reps).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range intervalMinutes (> 24h)', () => {
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: 0 })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: 1441 })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: -1 })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: '' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects name longer than MAX_STR_LEN (200)', () => {
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, name: 'x'.repeat(201) })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts name exactly at MAX_STR_LEN', () => {
|
||||||
|
const r = validateExerciseInput({ ...validExercise, name: 'x'.repeat(200) })
|
||||||
|
expect(r?.name).toHaveLength(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults icon to Activity if missing', () => {
|
||||||
|
const { icon: _ignored, ...rest } = validExercise
|
||||||
|
void _ignored
|
||||||
|
expect(validateExerciseInput(rest)?.icon).toBe('Activity')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults enabled to true if missing', () => {
|
||||||
|
const { enabled: _ignored, ...rest } = validExercise
|
||||||
|
void _ignored
|
||||||
|
expect(validateExerciseInput(rest)?.enabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Дизайн validateExerciseInput: required-поля (name/reps/intervalMinutes)
|
||||||
|
// строгие — невалидное значение reject'ит весь input. Optional-поля
|
||||||
|
// (icon/enabled) lenient — невалидное молча подменяется дефолтом. Это
|
||||||
|
// фиксирует контракт: malicious renderer не сможет создать запись с
|
||||||
|
// reps=-1, но если он пришлёт `enabled: 'yes'`, получит просто enabled=true.
|
||||||
|
it('coerces invalid enabled to true (lenient default for optional fields)', () => {
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, enabled: 'yes' })?.enabled
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, enabled: 1 })?.enabled
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// А вот в patch optional-поля строгие — нет defaults, есть `if (v ===
|
||||||
|
// undefined) return null`. Это правильнее: если renderer пришёл с патчем,
|
||||||
|
// в котором есть поле, оно должно быть валидным.
|
||||||
|
it('strict patch: rejects invalid enabled in patch (unlike input)', () => {
|
||||||
|
expect(validateExercisePatch({ enabled: 'yes' })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ enabled: 1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-string name', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: 42 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: null })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateExercisePatch', () => {
|
||||||
|
it('accepts an empty patch (no-op update)', () => {
|
||||||
|
expect(validateExercisePatch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts partial patches', () => {
|
||||||
|
expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 })
|
||||||
|
expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' })
|
||||||
|
expect(validateExercisePatch({ enabled: false })).toEqual({ enabled: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects patch with a single invalid field', () => {
|
||||||
|
// Patch is all-or-nothing: one bad field rejects the whole patch.
|
||||||
|
expect(validateExercisePatch({ name: 'OK', reps: -1 })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ name: '', reps: 10 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-object', () => {
|
||||||
|
expect(validateExercisePatch(null)).toBeNull()
|
||||||
|
expect(validateExercisePatch([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts nextFireAt and lastDoneAt with valid ranges', () => {
|
||||||
|
expect(validateExercisePatch({ nextFireAt: 0 })).toEqual({ nextFireAt: 0 })
|
||||||
|
expect(validateExercisePatch({ lastDoneAt: 1_000_000_000_000 })).toEqual({
|
||||||
|
lastDoneAt: 1_000_000_000_000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative timestamps', () => {
|
||||||
|
expect(validateExercisePatch({ nextFireAt: -1 })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ lastDoneAt: -1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects NaN/Infinity timestamps', () => {
|
||||||
|
expect(validateExercisePatch({ nextFireAt: NaN })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ nextFireAt: Infinity })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateChallengeInput', () => {
|
||||||
|
const valid = {
|
||||||
|
name: 'Deaths → squats',
|
||||||
|
gameId: 'dota2',
|
||||||
|
stat: 'deaths' as const,
|
||||||
|
multiplier: 3,
|
||||||
|
exerciseName: 'Приседания',
|
||||||
|
icon: 'Activity',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
expect(validateChallengeInput(valid)).toEqual(valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown stat', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, stat: 'pizza' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid stats', () => {
|
||||||
|
const stats = ['deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min']
|
||||||
|
for (const stat of stats) {
|
||||||
|
expect(validateChallengeInput({ ...valid, stat })).not.toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative multiplier', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: -1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects multiplier > 1000', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: 1001 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts zero multiplier (legitimate "disable" semantics)', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts fractional multiplier (e.g. 0.5×)', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier).toBe(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateChallengePatch', () => {
|
||||||
|
it('accepts empty patch', () => {
|
||||||
|
expect(validateChallengePatch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown stat in patch', () => {
|
||||||
|
expect(validateChallengePatch({ stat: 'mana' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateSettingsPatch', () => {
|
||||||
|
it('accepts empty patch', () => {
|
||||||
|
expect(validateSettingsPatch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts each boolean toggle independently', () => {
|
||||||
|
expect(validateSettingsPatch({ globalEnabled: false })).toEqual({
|
||||||
|
globalEnabled: false
|
||||||
|
})
|
||||||
|
expect(validateSettingsPatch({ soundEnabled: true })).toEqual({
|
||||||
|
soundEnabled: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown theme', () => {
|
||||||
|
expect(validateSettingsPatch({ theme: 'sepia' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid themes', () => {
|
||||||
|
expect(validateSettingsPatch({ theme: 'light' })?.theme).toBe('light')
|
||||||
|
expect(validateSettingsPatch({ theme: 'dark' })?.theme).toBe('dark')
|
||||||
|
expect(validateSettingsPatch({ theme: 'system' })?.theme).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown language', () => {
|
||||||
|
expect(validateSettingsPatch({ language: 'fr' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown notification mode', () => {
|
||||||
|
expect(validateSettingsPatch({ notificationMode: 'sms' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range snoozeMinutes', () => {
|
||||||
|
expect(validateSettingsPatch({ snoozeMinutes: 0 })).toBeNull()
|
||||||
|
expect(validateSettingsPatch({ snoozeMinutes: 1441 })).toBeNull()
|
||||||
|
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('quietHours subobject', () => {
|
||||||
|
const baseQh = {
|
||||||
|
enabled: true,
|
||||||
|
from: '22:00',
|
||||||
|
to: '08:00',
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts a valid quietHours', () => {
|
||||||
|
expect(validateSettingsPatch({ quietHours: baseQh })?.quietHours).toEqual(
|
||||||
|
baseQh
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-object quietHours', () => {
|
||||||
|
expect(validateSettingsPatch({ quietHours: 'always' })).toBeNull()
|
||||||
|
expect(validateSettingsPatch({ quietHours: null })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed HH:MM', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, from: '8' } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts HH:MM with 1-digit hour (9:30)', () => {
|
||||||
|
// Regex is /^\d{1,2}:\d{2}$/ — допускаем «9:30», парсер сам разберётся.
|
||||||
|
const r = validateSettingsPatch({
|
||||||
|
quietHours: { ...baseQh, from: '9:30' }
|
||||||
|
})
|
||||||
|
expect(r?.quietHours?.from).toBe('9:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes days array', () => {
|
||||||
|
const r = validateSettingsPatch({
|
||||||
|
quietHours: { ...baseQh, days: [1, 2, 2, 3, 1] }
|
||||||
|
})
|
||||||
|
expect(r?.quietHours?.days).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range day (7)', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, days: [0, 7] } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative day', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, days: [-1] } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-array days', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, days: 'all' } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts empty days array (window effectively disabled)', () => {
|
||||||
|
const r = validateSettingsPatch({
|
||||||
|
quietHours: { ...baseQh, days: [] }
|
||||||
|
})
|
||||||
|
expect(r?.quietHours?.days).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateId', () => {
|
||||||
|
it('accepts reasonable id strings', () => {
|
||||||
|
expect(validateId('abc')).toBe('abc')
|
||||||
|
expect(validateId('uuid-v4-style-thing-123')).toBe('uuid-v4-style-thing-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-strings', () => {
|
||||||
|
expect(validateId(42)).toBeNull()
|
||||||
|
expect(validateId(null)).toBeNull()
|
||||||
|
expect(validateId(undefined)).toBeNull()
|
||||||
|
expect(validateId({})).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
expect(validateId('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects strings longer than 64 chars', () => {
|
||||||
|
expect(validateId('x'.repeat(65))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateActualReps', () => {
|
||||||
|
it('returns undefined for undefined/null (means: use planned reps)', () => {
|
||||||
|
expect(validateActualReps(undefined)).toBeUndefined()
|
||||||
|
expect(validateActualReps(null)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts zero (partial completion = "did 0 of 10")', () => {
|
||||||
|
expect(validateActualReps(0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts large values up to cap', () => {
|
||||||
|
expect(validateActualReps(100_000)).toBe(100_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative', () => {
|
||||||
|
expect(validateActualReps(-1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects values above cap', () => {
|
||||||
|
expect(validateActualReps(100_001)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects NaN/Infinity', () => {
|
||||||
|
expect(validateActualReps(NaN)).toBeUndefined()
|
||||||
|
expect(validateActualReps(Infinity)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateSnoozeMinutes', () => {
|
||||||
|
it('accepts valid minutes', () => {
|
||||||
|
expect(validateSnoozeMinutes(15)).toBe(15)
|
||||||
|
expect(validateSnoozeMinutes(1)).toBe(1)
|
||||||
|
expect(validateSnoozeMinutes(1440)).toBe(1440)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects 0 and above 24h', () => {
|
||||||
|
expect(validateSnoozeMinutes(0)).toBeNull()
|
||||||
|
expect(validateSnoozeMinutes(1441)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-numbers', () => {
|
||||||
|
expect(validateSnoozeMinutes('15')).toBeNull()
|
||||||
|
expect(validateSnoozeMinutes(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
|
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
|
||||||
|
import { IPC } from '../shared/ipc'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
@@ -90,8 +91,13 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
|
|||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 720,
|
height: 720,
|
||||||
minWidth: 900,
|
// Минимум подобран так, чтобы:
|
||||||
minHeight: 600,
|
// - срабатывал Tailwind `lg:` (≥1024px) → 4 hero-stat в один ряд, а не 2×2
|
||||||
|
// - сайдбар (256px) + контент (max-w-5xl, padding lg:px-10) помещались без
|
||||||
|
// горизонтального скролла heatmap'а и карточек упражнений
|
||||||
|
// - по вертикали оставался запас на header + stats + heatmap без обрезки
|
||||||
|
minWidth: 1100,
|
||||||
|
minHeight: 700,
|
||||||
show: false,
|
show: false,
|
||||||
frame: false,
|
frame: false,
|
||||||
backgroundColor: '#0f1117',
|
backgroundColor: '#0f1117',
|
||||||
@@ -100,7 +106,12 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
|
|||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: preloadPath(),
|
preload: preloadPath(),
|
||||||
sandbox: false,
|
// sandbox: true — preload использует только contextBridge + ipcRenderer
|
||||||
|
// (оба sandbox-safe), никаких Node-built-ins (fs/path/child_process).
|
||||||
|
// Sandbox изолирует renderer от Chromium GPU/IPC процессов на уровне
|
||||||
|
// OS-сэндбокса; даже RCE через зависимости renderer'а не получит
|
||||||
|
// полного Node-доступа из preload.
|
||||||
|
sandbox: true,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
}
|
}
|
||||||
@@ -110,6 +121,16 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
|
|||||||
if (showImmediately) win.show()
|
if (showImmediately) win.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Сообщаем рендереру об изменении max-состояния, чтобы он мог менять
|
||||||
|
// иконку (квадрат ↔ «двойной квадрат») в кастомном тайтлбаре.
|
||||||
|
const emitMaxState = (maximized: boolean): void => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.webContents.send(IPC.evtMaximizeChanged, maximized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
win.on('maximize', () => emitMaxState(true))
|
||||||
|
win.on('unmaximize', () => emitMaxState(false))
|
||||||
|
|
||||||
installSafeNavigation(win)
|
installSafeNavigation(win)
|
||||||
|
|
||||||
loadRoute(win, 'main')
|
loadRoute(win, 'main')
|
||||||
@@ -155,7 +176,7 @@ export function createReminderWindow(): BrowserWindow {
|
|||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: preloadPath(),
|
preload: preloadPath(),
|
||||||
sandbox: false,
|
sandbox: true, // см. createMainWindow — preload не использует Node.
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ const api = {
|
|||||||
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
|
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
|
||||||
|
|
||||||
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
|
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
|
||||||
|
toggleMaximizeMain: (): void => ipcRenderer.send(IPC.toggleMaximizeMain),
|
||||||
|
isMaximizedMain: (): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC.isMaximizedMain),
|
||||||
closeMain: (): void => ipcRenderer.send(IPC.closeMain),
|
closeMain: (): void => ipcRenderer.send(IPC.closeMain),
|
||||||
hideMain: (): void => ipcRenderer.send(IPC.hideMain),
|
hideMain: (): void => ipcRenderer.send(IPC.hideMain),
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ const api = {
|
|||||||
id: GameId,
|
id: GameId,
|
||||||
stats: Record<string, number>
|
stats: Record<string, number>
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats)
|
ipcRenderer.invoke(IPC.devSimulateMatchEnd, id, stats)
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|
||||||
@@ -102,8 +105,10 @@ const api = {
|
|||||||
ipcRenderer.invoke(IPC.updaterStatus),
|
ipcRenderer.invoke(IPC.updaterStatus),
|
||||||
updaterCheck: (): Promise<UpdaterStatus> =>
|
updaterCheck: (): Promise<UpdaterStatus> =>
|
||||||
ipcRenderer.invoke(IPC.updaterCheck),
|
ipcRenderer.invoke(IPC.updaterCheck),
|
||||||
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
|
// Fire-and-forget. Прогресс и завершение прилетают через onUpdaterStatus —
|
||||||
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
|
// renderer не должен `await`'ить, иначе busy-state висит весь download.
|
||||||
|
updaterDownload: (): void => ipcRenderer.send(IPC.updaterDownload),
|
||||||
|
updaterInstall: (): void => ipcRenderer.send(IPC.updaterInstall),
|
||||||
|
|
||||||
// History
|
// History
|
||||||
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
|
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
|
||||||
@@ -121,7 +126,9 @@ const api = {
|
|||||||
onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
|
onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
|
||||||
on(IPC.evtGamesChanged, h),
|
on(IPC.evtGamesChanged, h),
|
||||||
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
|
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
|
||||||
on(IPC.evtUpdaterStatus, h)
|
on(IPC.evtUpdaterStatus, h),
|
||||||
|
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
|
||||||
|
on(IPC.evtMaximizeChanged, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; script-src 'self'" />
|
<!--
|
||||||
|
CSP: всё локально, без внешних origins. Шрифты подгружаются через
|
||||||
|
@fontsource/* импорты в globals.css. style-src 'unsafe-inline' нужен
|
||||||
|
для Tailwind utility-классов и инлайн-стилей framer-motion. font-src
|
||||||
|
включает data: на случай если кто-то вставит base64 SVG-glyph.
|
||||||
|
-->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" />
|
||||||
<title>Exercise Reminder</title>
|
<title>Exercise Reminder</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Minus, X, Square, Menu } from 'lucide-react'
|
import { Minus, X, Square, Copy, Menu } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useT } from '../i18n'
|
import { useT } from '../i18n'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,8 +11,29 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
|||||||
const { t } = useT()
|
const { t } = useT()
|
||||||
const effectiveTitle = title ?? t('titlebar.app_title')
|
const effectiveTitle = title ?? t('titlebar.app_title')
|
||||||
|
|
||||||
|
// Локально отслеживаем maximize-state, чтобы свапать иконку (квадрат ↔
|
||||||
|
// «двойной квадрат», как в нативной винде). Стартовое значение спрашиваем
|
||||||
|
// у main; дальше подписываемся на evtMaximizeChanged.
|
||||||
|
const [maximized, setMaximized] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
void window.api.isMaximizedMain().then(setMaximized)
|
||||||
|
const unsub = window.api.onMaximizeChanged((v) => setMaximized(v))
|
||||||
|
return unsub
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Double-click по тайтлбару — стандартный Windows-жест для toggle maximize.
|
||||||
|
// Игнорируем клики по элементам с no-drag (кнопки/меню) — у них своя логика.
|
||||||
|
function onDoubleClick(e: React.MouseEvent<HTMLDivElement>): void {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest('.titlebar-nodrag')) return
|
||||||
|
window.api.toggleMaximizeMain()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
|
<div
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
|
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
|
||||||
{onMenuClick && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
@@ -28,7 +50,10 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
|||||||
{effectiveTitle}
|
{effectiveTitle}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
{/* no-drag навешен на сами кнопки, не на обёртку: иначе из-за
|
||||||
|
flex-1 basis-0 весь кластер (включая пустое место слева от кнопок)
|
||||||
|
становится no-drag, и окно нельзя ухватить рядом с кнопками. */}
|
||||||
|
<div className="flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
||||||
<WinBtn
|
<WinBtn
|
||||||
onClick={() => window.api.minimizeMain()}
|
onClick={() => window.api.minimizeMain()}
|
||||||
label={t('titlebar.minimize_aria')}
|
label={t('titlebar.minimize_aria')}
|
||||||
@@ -36,10 +61,18 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
|||||||
<Minus size={13} strokeWidth={2} />
|
<Minus size={13} strokeWidth={2} />
|
||||||
</WinBtn>
|
</WinBtn>
|
||||||
<WinBtn
|
<WinBtn
|
||||||
onClick={() => window.api.hideMain()}
|
onClick={() => window.api.toggleMaximizeMain()}
|
||||||
label={t('titlebar.tray_aria')}
|
label={
|
||||||
|
maximized
|
||||||
|
? t('titlebar.restore_aria')
|
||||||
|
: t('titlebar.maximize_aria')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{maximized ? (
|
||||||
|
<Copy size={11} strokeWidth={2} />
|
||||||
|
) : (
|
||||||
<Square size={11} strokeWidth={2} />
|
<Square size={11} strokeWidth={2} />
|
||||||
|
)}
|
||||||
</WinBtn>
|
</WinBtn>
|
||||||
<WinBtn
|
<WinBtn
|
||||||
onClick={() => window.api.closeMain()}
|
onClick={() => window.api.closeMain()}
|
||||||
@@ -69,7 +102,7 @@ function WinBtn({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={[
|
className={[
|
||||||
'w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
|
'titlebar-nodrag w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
|
||||||
danger
|
danger
|
||||||
? 'hover:bg-destructive hover:text-white'
|
? 'hover:bg-destructive hover:text-white'
|
||||||
: 'hover:bg-text/[0.08] hover:text-text'
|
: 'hover:bg-text/[0.08] hover:text-text'
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ function formatChecked(ts: number, t: TFn): string {
|
|||||||
|
|
||||||
export function UpdaterCard(): JSX.Element {
|
export function UpdaterCard(): JSX.Element {
|
||||||
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
||||||
|
// busy используется только для синхронного `check()` — для асинхронного
|
||||||
|
// download/install статус сам переключится через события (downloading →
|
||||||
|
// downloaded), отдельный busy-флаг будет только дублировать визуально.
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,16 +42,15 @@ export function UpdaterCard(): JSX.Element {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function download(): Promise<void> {
|
function download(): void {
|
||||||
setBusy(true)
|
// Fire-and-forget — UI моментально перейдёт в kind:'downloading' через
|
||||||
try {
|
// первое же event'ное обновление статуса. Никакого `await` — пользователь
|
||||||
await window.api.updaterDownload()
|
// должен иметь возможность уйти на Dashboard, продолжать упражнения,
|
||||||
} finally {
|
// пока обновление качается в фоне.
|
||||||
setBusy(false)
|
window.api.updaterDownload()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function install(): void {
|
function install(): void {
|
||||||
void window.api.updaterInstall()
|
window.api.updaterInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +182,10 @@ function Body({
|
|||||||
transition={{ duration: 0.3, ease: 'linear' }}
|
transition={{ duration: 0.3, ease: 'linear' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Подсказка: download идёт в фоне, не нужно сидеть на этом экране. */}
|
||||||
|
<div className="text-[12px] text-text/55 mt-3 font-medium">
|
||||||
|
{t('updater.downloading.hint')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const ru: Dict = {
|
|||||||
'sidebar.status_tracking': 'Активность отслеживается',
|
'sidebar.status_tracking': 'Активность отслеживается',
|
||||||
'titlebar.menu_aria': 'Меню',
|
'titlebar.menu_aria': 'Меню',
|
||||||
'titlebar.minimize_aria': 'Свернуть',
|
'titlebar.minimize_aria': 'Свернуть',
|
||||||
|
'titlebar.maximize_aria': 'Развернуть',
|
||||||
|
'titlebar.restore_aria': 'Восстановить размер',
|
||||||
'titlebar.tray_aria': 'В трей',
|
'titlebar.tray_aria': 'В трей',
|
||||||
'titlebar.close_aria': 'Закрыть',
|
'titlebar.close_aria': 'Закрыть',
|
||||||
'titlebar.app_title': 'Exercise Reminder',
|
'titlebar.app_title': 'Exercise Reminder',
|
||||||
@@ -192,8 +194,9 @@ export const ru: Dict = {
|
|||||||
'updater.available.title': 'Доступна v{v}',
|
'updater.available.title': 'Доступна v{v}',
|
||||||
'updater.downloading.title': 'Загружаем обновление',
|
'updater.downloading.title': 'Загружаем обновление',
|
||||||
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
|
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
|
||||||
|
'updater.downloading.hint': 'Можно закрыть это окно — скачивание продолжится в фоне.',
|
||||||
'updater.downloaded.title': 'Готово · v{v}',
|
'updater.downloaded.title': 'Готово · v{v}',
|
||||||
'updater.downloaded.subtitle': 'Перезапусти для применения',
|
'updater.downloaded.subtitle': 'Нажми «Рестарт» — приложение моментально откроется в новой версии.',
|
||||||
'updater.error.title': 'Ошибка проверки',
|
'updater.error.title': 'Ошибка проверки',
|
||||||
'updater.idle.title': 'Проверить обновления',
|
'updater.idle.title': 'Проверить обновления',
|
||||||
'updater.idle.subtitle': 'Авто-проверка раз в час',
|
'updater.idle.subtitle': 'Авто-проверка раз в час',
|
||||||
@@ -265,6 +268,8 @@ export const en: Dict = {
|
|||||||
'sidebar.status_tracking': 'Activity tracking is on',
|
'sidebar.status_tracking': 'Activity tracking is on',
|
||||||
'titlebar.menu_aria': 'Menu',
|
'titlebar.menu_aria': 'Menu',
|
||||||
'titlebar.minimize_aria': 'Minimize',
|
'titlebar.minimize_aria': 'Minimize',
|
||||||
|
'titlebar.maximize_aria': 'Maximize',
|
||||||
|
'titlebar.restore_aria': 'Restore size',
|
||||||
'titlebar.tray_aria': 'To tray',
|
'titlebar.tray_aria': 'To tray',
|
||||||
'titlebar.close_aria': 'Close',
|
'titlebar.close_aria': 'Close',
|
||||||
'titlebar.app_title': 'Exercise Reminder',
|
'titlebar.app_title': 'Exercise Reminder',
|
||||||
@@ -436,8 +441,9 @@ export const en: Dict = {
|
|||||||
'updater.available.title': 'v{v} available',
|
'updater.available.title': 'v{v} available',
|
||||||
'updater.downloading.title': 'Downloading update',
|
'updater.downloading.title': 'Downloading update',
|
||||||
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
|
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
|
||||||
|
'updater.downloading.hint': 'You can close this window — download continues in the background.',
|
||||||
'updater.downloaded.title': 'Ready · v{v}',
|
'updater.downloaded.title': 'Ready · v{v}',
|
||||||
'updater.downloaded.subtitle': 'Restart to apply',
|
'updater.downloaded.subtitle': 'Click Restart — the app will reopen instantly in the new version.',
|
||||||
'updater.error.title': 'Check failed',
|
'updater.error.title': 'Check failed',
|
||||||
'updater.idle.title': 'Check for updates',
|
'updater.idle.title': 'Check for updates',
|
||||||
'updater.idle.subtitle': 'Auto-check every hour',
|
'updater.idle.subtitle': 'Auto-check every hour',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { translate, translateN } from './index'
|
import { translate, translateN } from './index'
|
||||||
|
import { ru, en } from './dict'
|
||||||
|
|
||||||
describe('translate', () => {
|
describe('translate', () => {
|
||||||
it('returns the matching string by key', () => {
|
it('returns the matching string by key', () => {
|
||||||
@@ -30,6 +31,50 @@ describe('translate', () => {
|
|||||||
// @ts-expect-error testing fallback
|
// @ts-expect-error testing fallback
|
||||||
expect(translate('fr', 'btn.save')).toBe('Сохранить')
|
expect(translate('fr', 'btn.save')).toBe('Сохранить')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Регрессия: до v0.5.2 интерполяция шла через regex, и если
|
||||||
|
// var-значение содержало regex-метасимволы ($1, .*, и т.д.), они
|
||||||
|
// интерпретировались как backreferences. Сейчас split/join.
|
||||||
|
it('substitutes regex metacharacters literally (no regex injection)', () => {
|
||||||
|
expect(
|
||||||
|
translate('ru', 'btn.snooze_min', { n: '$1.*' as unknown as number })
|
||||||
|
).toBe('Отложить $1.* мин')
|
||||||
|
expect(
|
||||||
|
translate('en', 'btn.snooze_min', {
|
||||||
|
n: '$$$&\\1' as unknown as number
|
||||||
|
})
|
||||||
|
).toBe('Snooze $$$&\\1m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves unsubstituted placeholders intact', () => {
|
||||||
|
// {n} остаётся как есть, если var не передан — это сигнал «забыл vars».
|
||||||
|
expect(translate('ru', 'btn.snooze_min')).toContain('{n}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dictionary parity', () => {
|
||||||
|
// EN не имеет CLDR-категории `few` — только `one`/`many`. Поэтому RU-ключи
|
||||||
|
// вида `*_few` легитимно отсутствуют в EN, исключаем их из парити-чека.
|
||||||
|
const isRuFewOnly = (k: string): boolean => k.endsWith('_few')
|
||||||
|
|
||||||
|
it('every key in ru (except *_few) exists in en', () => {
|
||||||
|
const missing = Object.keys(ru).filter(
|
||||||
|
(k) => !isRuFewOnly(k) && !(k in en)
|
||||||
|
)
|
||||||
|
expect(missing, `missing in en: ${missing.join(', ')}`).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every key in en exists in ru', () => {
|
||||||
|
const missing = Object.keys(en).filter((k) => !(k in ru))
|
||||||
|
expect(missing, `missing in ru: ${missing.join(', ')}`).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weekday.short.0..6 exist in both languages', () => {
|
||||||
|
for (const i of [0, 1, 2, 3, 4, 5, 6]) {
|
||||||
|
expect(ru[`weekday.short.${i}`]).toBeTruthy()
|
||||||
|
expect(en[`weekday.short.${i}`]).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('translateN (plural)', () => {
|
describe('translateN (plural)', () => {
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ describe('formatCountdown', () => {
|
|||||||
expect(formatCountdown(999)).toBe('0с')
|
expect(formatCountdown(999)).toBe('0с')
|
||||||
expect(formatCountdown(500)).toBe('0с')
|
expect(formatCountdown(500)).toBe('0с')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Guard added in v0.5.2 — electron-updater и scheduler могут передать
|
||||||
|
// NaN/Infinity на ранних событиях. Должны вернуть «сейчас», не «NaNс».
|
||||||
|
it('returns "сейчас" for NaN and Infinity (defensive guard)', () => {
|
||||||
|
expect(formatCountdown(NaN)).toBe('сейчас')
|
||||||
|
expect(formatCountdown(Infinity)).toBe('сейчас')
|
||||||
|
expect(formatCountdown(-Infinity)).toBe('сейчас')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('english locale', () => {
|
||||||
|
it('renders sub-minute with "s"', () => {
|
||||||
|
expect(formatCountdown(45_000, 'en')).toBe('45s')
|
||||||
|
})
|
||||||
|
it('renders minutes+seconds with "m"/"s"', () => {
|
||||||
|
expect(formatCountdown(65_000, 'en')).toBe('1m 05s')
|
||||||
|
})
|
||||||
|
it('renders hours+minutes with "h"/"m"', () => {
|
||||||
|
expect(formatCountdown(3_660_000, 'en')).toBe('1h 01m')
|
||||||
|
})
|
||||||
|
it('returns "now" for zero', () => {
|
||||||
|
expect(formatCountdown(0, 'en')).toBe('now')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatInterval', () => {
|
describe('formatInterval', () => {
|
||||||
@@ -53,4 +76,10 @@ describe('formatInterval', () => {
|
|||||||
expect(formatInterval(90)).toBe('1 ч 30 мин')
|
expect(formatInterval(90)).toBe('1 ч 30 мин')
|
||||||
expect(formatInterval(125)).toBe('2 ч 5 мин')
|
expect(formatInterval(125)).toBe('2 ч 5 мин')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('english locale', () => {
|
||||||
|
expect(formatInterval(30, 'en')).toBe('30 min')
|
||||||
|
expect(formatInterval(60, 'en')).toBe('1 h')
|
||||||
|
expect(formatInterval(90, 'en')).toBe('1 h 30 min')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||||
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history'
|
import {
|
||||||
|
currentStreak,
|
||||||
|
dailyReps,
|
||||||
|
dayKey,
|
||||||
|
dailyRepsRange,
|
||||||
|
plannedRepsToday
|
||||||
|
} from './history'
|
||||||
|
|
||||||
const MS_DAY = 24 * 60 * 60 * 1000
|
const MS_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
@@ -117,4 +123,77 @@ describe('dailyRepsRange', () => {
|
|||||||
expect(range.at(-1)?.reps).toBe(10) // today
|
expect(range.at(-1)?.reps).toBe(10) // today
|
||||||
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// DST regression: до v0.5.2 dailyRepsRange использовал `ts - i*MS_DAY`.
|
||||||
|
// На границе DST (например в EU last Sunday October — 25 час) арифметика
|
||||||
|
// ms-vs-календарь расходилась, и dayKey() выдавал дубликат/пропуск дня.
|
||||||
|
// Сейчас shiftDays() через setDate(). Простой инвариант: количество
|
||||||
|
// уникальных day-keys всегда == days, и все keys строго возрастают.
|
||||||
|
it('produces unique day keys without gaps (DST-safe)', () => {
|
||||||
|
const range = dailyRepsRange([], [], 90)
|
||||||
|
const keys = range.map((r) => r.key)
|
||||||
|
expect(new Set(keys).size).toBe(90)
|
||||||
|
for (let i = 1; i < keys.length; i++) {
|
||||||
|
expect(keys[i] > keys[i - 1]).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last entry is today', () => {
|
||||||
|
const range = dailyRepsRange([], [], 7)
|
||||||
|
expect(range.at(-1)?.key).toBe(dayKey(Date.now()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plannedRepsToday', () => {
|
||||||
|
it('returns 0 when no exercises enabled', () => {
|
||||||
|
const exs = [{ ...ex('a', 10), enabled: false }]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 for empty list', () => {
|
||||||
|
expect(plannedRepsToday([])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('multiplies reps by approximate fires per day', () => {
|
||||||
|
// 60-min interval × 24 = 24 fires/day × 10 reps = 240
|
||||||
|
const exs = [{ ...ex('a', 10), intervalMinutes: 60 }]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums across multiple enabled exercises', () => {
|
||||||
|
const exs = [
|
||||||
|
{ ...ex('a', 10), intervalMinutes: 60 }, // 24 × 10 = 240
|
||||||
|
{ ...ex('b', 5), intervalMinutes: 30 } // 48 × 5 = 240
|
||||||
|
]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(480)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('floor of (1440/interval), minimum 1 fire/day for huge intervals', () => {
|
||||||
|
// 1440-min interval = 1 fire/day; 2000-min interval should still be ≥ 1.
|
||||||
|
const exs = [{ ...ex('a', 7), intervalMinutes: 2000 }]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('currentStreak edge cases', () => {
|
||||||
|
const today = Date.now()
|
||||||
|
|
||||||
|
it('ignores future-dated entries (clock skew, partial restore)', () => {
|
||||||
|
const tomorrow = today + 24 * 60 * 60 * 1000
|
||||||
|
// future entry shouldn't anchor the streak.
|
||||||
|
expect(currentStreak([entry('a', tomorrow)])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles entries spread across the same day with mixed actions', () => {
|
||||||
|
const e = (
|
||||||
|
action: 'done' | 'skip' | 'snooze',
|
||||||
|
ts: number
|
||||||
|
): HistoryEntry => entry('a', ts, action)
|
||||||
|
const hist = [
|
||||||
|
e('skip', today),
|
||||||
|
e('done', today), // done is enough — streak counts the day
|
||||||
|
e('snooze', today)
|
||||||
|
]
|
||||||
|
expect(currentStreak(hist)).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
26
src/renderer/src/lib/icon-choices.test.ts
Normal file
26
src/renderer/src/lib/icon-choices.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ICON_CHOICES } from './icon-choices'
|
||||||
|
import { SAMPLE_EXERCISES } from '@shared/types'
|
||||||
|
|
||||||
|
describe('ICON_CHOICES', () => {
|
||||||
|
// Если иконка SAMPLE_EXERCISES не входит в whitelist, при первом запуске
|
||||||
|
// приложения иконка молча заменится на fallback-Activity. Лучше ловить
|
||||||
|
// расхождение в CI.
|
||||||
|
it('contains every icon used by SAMPLE_EXERCISES', () => {
|
||||||
|
const allowed = new Set<string>(ICON_CHOICES)
|
||||||
|
for (const ex of SAMPLE_EXERCISES) {
|
||||||
|
expect(
|
||||||
|
allowed.has(ex.icon),
|
||||||
|
`icon "${ex.icon}" for sample "${ex.name}" is not in ICON_CHOICES`
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no duplicates', () => {
|
||||||
|
expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is non-empty', () => {
|
||||||
|
expect(ICON_CHOICES.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
src/renderer/src/lib/icon-choices.ts
Normal file
27
src/renderer/src/lib/icon-choices.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Whitelist of allowed Lucide-icon names. Wrapped in a separate .ts file
|
||||||
|
* (без JSX), чтобы его можно было импортировать из node-tests и из shared/
|
||||||
|
* без подтягивания JSX-зависимости icon.tsx.
|
||||||
|
*/
|
||||||
|
export const ICON_CHOICES = [
|
||||||
|
'Activity',
|
||||||
|
'Dumbbell',
|
||||||
|
'StretchHorizontal',
|
||||||
|
'PersonStanding',
|
||||||
|
'Heart',
|
||||||
|
'Footprints',
|
||||||
|
'Hand',
|
||||||
|
'Eye',
|
||||||
|
'Brain',
|
||||||
|
'Bike',
|
||||||
|
'Waves',
|
||||||
|
'Wind',
|
||||||
|
'Sun',
|
||||||
|
'Coffee',
|
||||||
|
'Apple',
|
||||||
|
'GlassWater',
|
||||||
|
'BookOpen',
|
||||||
|
'Sparkles'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type IconName = (typeof ICON_CHOICES)[number]
|
||||||
@@ -1,30 +1,51 @@
|
|||||||
import * as Lucide from 'lucide-react'
|
// Explicit-named imports — НЕ wildcard. Wildcard `* as Lucide` ломает
|
||||||
|
// tree-shaking: в bundle попадает вся библиотека (~500KB minified, 1500+
|
||||||
|
// иконок). Сейчас в bundle только 18 ICON_CHOICES.
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Dumbbell,
|
||||||
|
StretchHorizontal,
|
||||||
|
PersonStanding,
|
||||||
|
Heart,
|
||||||
|
Footprints,
|
||||||
|
Hand,
|
||||||
|
Eye,
|
||||||
|
Brain,
|
||||||
|
Bike,
|
||||||
|
Waves,
|
||||||
|
Wind,
|
||||||
|
Sun,
|
||||||
|
Coffee,
|
||||||
|
Apple,
|
||||||
|
GlassWater,
|
||||||
|
BookOpen,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react'
|
||||||
import type { LucideProps } from 'lucide-react'
|
import type { LucideProps } from 'lucide-react'
|
||||||
|
import { ICON_CHOICES, type IconName } from './icon-choices'
|
||||||
|
|
||||||
export const ICON_CHOICES = [
|
export { ICON_CHOICES, type IconName }
|
||||||
'Activity',
|
|
||||||
'Dumbbell',
|
|
||||||
'StretchHorizontal',
|
|
||||||
'PersonStanding',
|
|
||||||
'Heart',
|
|
||||||
'Footprints',
|
|
||||||
'Hand',
|
|
||||||
'Eye',
|
|
||||||
'Brain',
|
|
||||||
'Bike',
|
|
||||||
'Waves',
|
|
||||||
'Wind',
|
|
||||||
'Sun',
|
|
||||||
'Coffee',
|
|
||||||
'Apple',
|
|
||||||
'GlassWater',
|
|
||||||
'BookOpen',
|
|
||||||
'Sparkles'
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type IconName = (typeof ICON_CHOICES)[number]
|
const ICON_MAP: Record<IconName, React.ComponentType<LucideProps>> = {
|
||||||
|
Activity,
|
||||||
const ICON_SET = new Set<string>(ICON_CHOICES)
|
Dumbbell,
|
||||||
|
StretchHorizontal,
|
||||||
|
PersonStanding,
|
||||||
|
Heart,
|
||||||
|
Footprints,
|
||||||
|
Hand,
|
||||||
|
Eye,
|
||||||
|
Brain,
|
||||||
|
Bike,
|
||||||
|
Waves,
|
||||||
|
Wind,
|
||||||
|
Sun,
|
||||||
|
Coffee,
|
||||||
|
Apple,
|
||||||
|
GlassWater,
|
||||||
|
BookOpen,
|
||||||
|
Sparkles
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a Lucide icon by name. Restricted to the curated ICON_CHOICES set —
|
* Render a Lucide icon by name. Restricted to the curated ICON_CHOICES set —
|
||||||
@@ -36,15 +57,12 @@ export function Icon({
|
|||||||
name,
|
name,
|
||||||
...props
|
...props
|
||||||
}: { name: string } & LucideProps): JSX.Element {
|
}: { name: string } & LucideProps): JSX.Element {
|
||||||
if (!ICON_SET.has(name)) {
|
const Cmp = ICON_MAP[name as IconName]
|
||||||
|
if (!Cmp) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn(`[Icon] unknown icon name "${name}" — falling back`)
|
console.warn(`[Icon] unknown icon name "${name}" — falling back`)
|
||||||
}
|
}
|
||||||
return <Lucide.Activity {...props} />
|
return <Activity {...props} />
|
||||||
}
|
}
|
||||||
const Cmp = (
|
|
||||||
Lucide as unknown as Record<string, React.ComponentType<LucideProps>>
|
|
||||||
)[name]
|
|
||||||
if (!Cmp) return <Lucide.Activity {...props} />
|
|
||||||
return <Cmp {...props} />
|
return <Cmp {...props} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,11 +264,18 @@ function ChallengeEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
min="0.5"
|
min="0.5"
|
||||||
|
max="1000"
|
||||||
value={draft.multiplier}
|
value={draft.multiplier}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
multiplier: Math.max(0.5, Number(e.target.value) || 1)
|
// Клампим к диапазону [0.5, 1000] — совпадает с validate.ts
|
||||||
|
// (multiplier ∈ [0, 1000]). Без max=1000 пользователь мог
|
||||||
|
// ввести 9999 и save молча отклонялся IPC-валидатором.
|
||||||
|
multiplier: Math.max(
|
||||||
|
0.5,
|
||||||
|
Math.min(1000, Number(e.target.value) || 1)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="ios-input font-mono-num"
|
className="ios-input font-mono-num"
|
||||||
|
|||||||
@@ -25,5 +25,15 @@ export function ThemeProvider({
|
|||||||
else document.documentElement.classList.remove('dark')
|
else document.documentElement.classList.remove('dark')
|
||||||
}, [settings?.theme, osTheme])
|
}, [settings?.theme, osTheme])
|
||||||
|
|
||||||
|
// Синхронизируем <html lang> с языком приложения. Без этого screen-readers
|
||||||
|
// продолжают читать английский текст как кириллицу (или ломаются) при
|
||||||
|
// переключении на EN, и наоборот — это a11y-баг.
|
||||||
|
useEffect(() => {
|
||||||
|
const lang = settings?.language ?? 'ru'
|
||||||
|
if (document.documentElement.lang !== lang) {
|
||||||
|
document.documentElement.lang = lang
|
||||||
|
}
|
||||||
|
}, [settings?.language])
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/* Self-hosted шрифты — раньше тянулись с fonts.googleapis.com через <link>
|
||||||
|
в index.html. Минусы: внешняя зависимость (без интернета шрифты не
|
||||||
|
загружаются), CSP вынужден разрешать style-src https://fonts.googleapis.com
|
||||||
|
и font-src https://fonts.gstatic.com. Сейчас локальные .woff2 в bundle. */
|
||||||
|
@import '@fontsource/plus-jakarta-sans/400.css';
|
||||||
|
@import '@fontsource/plus-jakarta-sans/500.css';
|
||||||
|
@import '@fontsource/plus-jakarta-sans/600.css';
|
||||||
|
@import '@fontsource/plus-jakarta-sans/700.css';
|
||||||
|
@import '@fontsource/plus-jakarta-sans/800.css';
|
||||||
|
@import '@fontsource/bricolage-grotesque/500.css';
|
||||||
|
@import '@fontsource/bricolage-grotesque/600.css';
|
||||||
|
@import '@fontsource/bricolage-grotesque/700.css';
|
||||||
|
@import '@fontsource/bricolage-grotesque/800.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/500.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/600.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/700.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const IPC = {
|
|||||||
resumeAll: 'app:resumeAll',
|
resumeAll: 'app:resumeAll',
|
||||||
quit: 'app:quit',
|
quit: 'app:quit',
|
||||||
minimizeMain: 'window:minimize',
|
minimizeMain: 'window:minimize',
|
||||||
|
toggleMaximizeMain: 'window:toggleMaximize',
|
||||||
|
isMaximizedMain: 'window:isMaximized',
|
||||||
closeMain: 'window:close',
|
closeMain: 'window:close',
|
||||||
hideMain: 'window:hide',
|
hideMain: 'window:hide',
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@ export const IPC = {
|
|||||||
markChallengeDone: 'challenge:markDone',
|
markChallengeDone: 'challenge:markDone',
|
||||||
closeMatchSummary: 'matchSummary:close',
|
closeMatchSummary: 'matchSummary:close',
|
||||||
|
|
||||||
|
// Dev-only IPC (handler ungated в prod, см. ipc.ts). Держим в enum чтобы
|
||||||
|
// main/preload/renderer не разошлись в hardcoded-строках.
|
||||||
|
devSimulateMatchEnd: 'dev:simulateMatchEnd',
|
||||||
|
|
||||||
// Auto-updater
|
// Auto-updater
|
||||||
updaterStatus: 'updater:status',
|
updaterStatus: 'updater:status',
|
||||||
updaterCheck: 'updater:check',
|
updaterCheck: 'updater:check',
|
||||||
@@ -54,5 +60,6 @@ export const IPC = {
|
|||||||
evtThemeChanged: 'evt:themeChanged',
|
evtThemeChanged: 'evt:themeChanged',
|
||||||
evtAccentChanged: 'evt:accentChanged',
|
evtAccentChanged: 'evt:accentChanged',
|
||||||
evtGamesChanged: 'evt:gamesChanged',
|
evtGamesChanged: 'evt:gamesChanged',
|
||||||
evtUpdaterStatus: 'evt:updaterStatus'
|
evtUpdaterStatus: 'evt:updaterStatus',
|
||||||
|
evtMaximizeChanged: 'evt:maximizeChanged'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ describe('SAMPLE_EXERCISES', () => {
|
|||||||
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
|
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
// NB: тест «sample icons ⊆ ICON_CHOICES» лежит в
|
||||||
|
// src/renderer/src/lib/icon-choices.test.ts — он тянет renderer-сторону
|
||||||
|
// (ICON_CHOICES), а node-tsconfig сюда не пускает renderer-импорты.
|
||||||
|
|
||||||
describe('STAT_LABELS', () => {
|
describe('STAT_LABELS', () => {
|
||||||
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {
|
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {
|
||||||
|
|||||||
@@ -39,11 +39,22 @@ export type Settings = {
|
|||||||
quietHours: QuietHours
|
quietHours: QuietHours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State, видимое renderer'у (через IPC.getState и evtStateChanged).
|
||||||
|
* `history` намеренно НЕ включена — она достигает 10k записей × ~50 байт =
|
||||||
|
* 500KB JSON, и шлать её на каждый markDone/snooze/etc слишком дорого.
|
||||||
|
* Renderer запрашивает историю отдельно через `getHistory()` IPC (с опц.
|
||||||
|
* `sinceMs` для инкрементальной подгрузки).
|
||||||
|
*/
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
exercises: Exercise[]
|
exercises: Exercise[]
|
||||||
settings: Settings
|
settings: Settings
|
||||||
challenges: Challenge[]
|
challenges: Challenge[]
|
||||||
gamesEnabled: Partial<Record<GameId, boolean>>
|
gamesEnabled: Partial<Record<GameId, boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted shape — расширяет AppState историей (живёт только в main). */
|
||||||
|
export type PersistedState = AppState & {
|
||||||
history?: HistoryEntry[]
|
history?: HistoryEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user