ec6735f3f402505ff93131ebc0b4ac0b06f67faa
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f0dc5b2cc3 |
feat: a11y + Error Boundary + IPC validation + schema migrations
Second pass through the audit punch-list. ESLint and Prettier now clean
(0 errors, 0 warnings), typecheck clean, 53 tests pass.
ACCESSIBILITY (Modal)
- Full focus trap: Tab/Shift-Tab cycle within the dialog and never
escape to the underlying page.
- Focus restoration: closing returns focus to the trigger button.
- First focusable child is focused on open (skipping the X button).
- aria-labelledby links the dialog to its <h2> via useId().
- Close button's hardcoded "Закрыть" replaced with i18n key.
ERROR RECOVERY
- Add ErrorBoundary component (class — only way) with localized
fallback and a "try again" reset button. Stack trace shown only in
dev. Wrapped around the whole App + a nested boundary around the
routed pages so a crash in one route doesn't blank the chrome.
- Module-level guard on subscribeToBackend so React 18 StrictMode's
dev-mode double-mount doesn't subscribe twice.
- Loading placeholder is now blank (was hardcoded Russian "Загрузка…"
that English users would see during initial hydration).
TRAY i18n
- 5 tray strings now follow the current settings.language. Falls back
to Russian when the store isn't loaded yet or the lang is unknown.
- refreshMenu() called on settings.language change and on
pauseAll/resumeAll so the pause label stays in sync with state.
IPC VALIDATION (src/main/validate.ts)
- Hand-rolled validators for every renderer-supplied payload:
exercise input/patch, challenge input/patch, settings patch, id,
actualReps, snoozeMinutes. Range-check numeric fields
(intervalMinutes ∈ [1, 1440], reps ∈ [1, 9999], multiplier ∈ [0,
1000], snooze ∈ [1, 1440]), cap string lengths at 200, restrict
enums (theme/lang/notify-mode/stat) to known values, validate
quietHours.from/to with HH:MM regex and dedup quietHours.days.
- Every ipcMain.handle for mutations now runs the validator first and
returns null on rejection instead of pushing junk into the store.
A compromised renderer can no longer corrupt persisted state via
out-of-range numbers or wrong-type fields.
SCHEMA MIGRATIONS (src/main/store.ts)
- Add __schemaVersion field to persisted state with CURRENT = 1.
- MIGRATIONS map: { 0: (s) => s } as a no-op seed; future structural
changes (e.g. quietHours shape revision) get a single explicit slot.
- runMigrations() applies migrations in order; coerce() normalises the
result into a fully-formed AppState. Both first-write and every
flush() persist the version field.
EXHAUSTIVE-DEPS WARNINGS
- Dashboard: memoise `exercises` so downstream useMemos don't fire on
every parent render; gate the history fetch on exercises change
instead of any state change.
- HistoryHeatmap: wrap `weeks` in useMemo so monthLabels' deps are
stable.
LINT POLISH
- updater.ts: refactor a Prettier-vs-no-extra-semi conflict by
extracting the cast into a local binding.
- Remove dead import of `Challenge` from ipc.ts (now imported via
validators).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f3367e09de |
chore+fix: repo hygiene, code-review fixes, audit cleanup
Three independent code reviews + a security audit produced ~200 findings.
This commit lands the high-impact subset. Tests pass (53), typecheck
clean, eslint clean (3 minor exhaustive-deps warnings left).
REPO HYGIENE
- Add .editorconfig, .prettierrc.json, .prettierignore.
- Add ESLint flat config (.eslintrc.cjs) — correctness-focused, no style
rules (Prettier owns formatting).
- Add `format` / `format:check` / `lint` npm scripts.
- Add CHANGELOG.md (Keep a Changelog format, back-filled to 0.1.x).
- Reformat all source via Prettier so future diffs stay small.
DATA SAFETY (src/main/store.ts)
- Atomic write (tmp + rename) with retry on transient EBUSY/EPERM —
was non-atomic writeFileSync, vulnerable to truncation on power loss.
- On corrupt JSON, rename to `app-state.json.corrupt-<ts>` instead of
silently overwriting the user's exercises/history with defaults.
- Validate parsed shape before merging — reject arrays/scalars where
objects expected; per-field array checks.
- Strip `id` from incoming patches in updateExercise/updateChallenge —
a runtime caller (IPC) could otherwise smuggle id changes through.
- clearHistory now refuses an unbounded wipe (no beforeTs => no-op);
callers must pass an explicit boundary.
- unref() the debounce timer so it doesn't keep the event loop alive.
SECURITY (src/main/*)
- gsi-server: hard 256 KB body cap (was unbounded — local OOM vector),
reject any Origin/Sec-Fetch-Site header (blocks browser CSRF from
visited pages), require application/json Content-Type, generic 400
on parse error (no error string echo to client), closeAllConnections
+ async close on stop.
- dota2: validate auth.token from payload with timingSafeEqual against
the per-install token — was unauthenticated, any local process could
forge match-end events. Narrow object shape before spread-merge to
avoid throws on hostile payloads like {player:"x"}. Reset latest /
prevState after match_end so the next match starts clean.
- ipc: gate `dev:simulateMatchEnd` registration behind `!app.isPackaged`
so it does not exist in shipped builds.
- preload: gate the matching `simulateMatchEnd` export behind
`import.meta.env.MODE !== 'production'` so the bundler dead-code-
eliminates it from the production preload bundle.
- windows: shell.openExternal allowlist (http/https/mailto only) — was
forwarding any URL, including file:/javascript:/custom URI handlers
(some Windows handlers have been RCE vectors). will-navigate blocks
navigation to anywhere except file:// or the dev URL.
CORRECTNESS (src/main/* + src/shared/*)
- shared/types.ts isQuietAt: fix wrap-around + day-of-week filter.
With from=22:00 to=07:00 days=[Mon..Fri], the window started THE
PREVIOUS DAY when we're in the AM half — old code checked today's
day-of-week and got the wrong answer Sat 02:00 and Mon 01:00. Now
the filter is evaluated against the window's START day. Also reject
malformed HH:MM strings instead of producing NaN.
- scheduler: call broadcastState() after firing exercises so the
renderer's Dashboard/Exercises pages don't show stale nextFireAt
until the next state-changing IPC. Guard powerMonitor listeners
against double-registration on dev hot-reload.
- dota2: fix `launchOptionStatus = steamRunning ? 'queued' : 'queued'`
tautology — both branches now correctly read 'queued'.
- steam-launch-options: replace `require('node:fs')` inside atomicWrite
with the top-level import; retry on transient EBUSY/EPERM.
CORRECTNESS (src/renderer/*)
- lib/history.ts: replace `today.getTime() - i * MS_DAY` arithmetic
with `setDate(date - i)` calendar arithmetic in dailyRepsRange and
currentStreak — DST transitions shift epoch math by ±1h and cause
dayKey() to emit duplicate or missing days at the boundary.
- lib/icon.tsx: restrict name lookup to ICON_CHOICES set — an arbitrary
string from a corrupted state file could otherwise resolve to
unrelated Lucide exports and crash the renderer.
- lib/format.ts: guard formatCountdown against NaN/Infinity.
- i18n/index.ts: replace regex-based interpolation with split/join so
variable values containing regex metacharacters interpolate
literally; warn in dev on missing keys; clamp pluralRu(-N) via abs.
- ReminderApp: keyboard shortcuts moved INTO ExerciseReminder so Enter
respects the stepper's `adjusted` flag (was always passing planned
reps). Stepper capped at 5× planned. Don't hijack Space when a
button is focused. `key={exercise.id+nextFireAt}` forces a fresh
component for back-to-back reminders so stepper state resets. Match
summary view gets Esc-to-close. Functional setMode in onMarkDone
avoids races against stale `mode.done`.
- UpdaterCard: guard against NaN/Infinity in download-progress events
(electron-updater fires early events with undefined fields).
- Games: gate DevPanel behind `import.meta.env.DEV` in addition to the
main-side IPC gate, and narrow the `simulateMatchEnd` access.
- Add aria-labels for the +/- stepper buttons (i18n keys added).
TESTS
- +2 quiet-hours tests covering wrap-around + day-filter combo and
malformed HH:MM fallback. Total 53 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f861af5db1 |
feat(updater): fixed-URL auto-update channel + silent retries
The auto-update system used a per-version publish URL
(releases/download/v${version}), so each installed build only ever
checked its own release page for new versions. To deliver an update we
had to manually copy the new manifest into every old release — easy to
forget, and any half-uploaded state showed users red "check failed"
banners.
Architectural fix:
- New rolling 'update-channel' Gitea release. publish.url is now a
fixed path (.../releases/download/update-channel) that never moves.
- release.ps1 uploads each new build to three places:
1. vX.Y.Z (historical archive + changelog)
2. update-channel (what every client polls)
3. -BridgeTags (transition: also fill in old releases so users
still on those versions can find the new build)
- upload-release-assets.ps1 gains -AssetVersion to upload version-X.Y.Z
artifacts into a non-version tag (channel/bridge).
Resilience fixes for the updater itself:
- Hourly checks and the boot check now run in SILENT mode: network
errors don't promote to a red error state, they're logged and
retried on the next tick. Only user-initiated "Check now" surfaces
errors. This prevents the cascade of "Ошибка проверки" cards on
flaky networks or partial uploads.
- Boot check retries up to 3 times (30s/2m/5m backoff) before giving
up until the hourly tick.
- Track lastCheckedAt; "Up to date" subtitle now shows "checked Nm ago".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c9d4fc237e |
feat(v0.5.0): history + streak + heatmap, quiet hours, partial reps, README
== История и стрики (#1) == - HistoryEntry { ts, exerciseId, action: done|skip|snooze, actualReps? } персистится в app-state.json, лимит 10k записей (~3 года), trim oldest 10% - markDone/snooze/skip пишут в историю; markDone принимает optional actualReps - IPC: getHistory(sinceMs?), clearHistory(beforeTs?) + preload bindings - Renderer helpers (src/renderer/src/lib/history.ts): * dayKey(ts) — YYYY-MM-DD local * dailyReps(entries, exs, dayKey) — суммирует actualReps || planned * dailyRepsRange(entries, exs, days) — для heatmap, заполняет gaps нулями * currentStreak(entries) — consecutive days, today или yesterday (grace) - Dashboard теперь 4 hero-карточки: Today (повторов за день) / Streak (дней подряд) / Next / Tracking - Новый компонент HistoryHeatmap — GitHub-style 12-недельный календарь с 5 интенсивностями, локализованными подписями дней/месяцев == Тихие часы (#2) == - shared/types.ts: QuietHours { enabled, from, to, days[] } + isQuietAt() helper с правильной обработкой wrap-around окон (22:00→08:00) - DEFAULT_SETTINGS.quietHours = disabled, 22:00→08:00, все дни - main/scheduler.ts: проверка isQuietAt перед fire; deferred fires поднимаются после окончания окна - Settings UI: новая секция "Тихие часы" с toggle, time-pickers, day-of-week pills == Сделал частично (#3) == - ReminderApp: stepper [−][число][+] вокруг счётчика повторов - При adjusted (actualReps !== exercise.reps) число подсвечивается accent и появляется подпись "Засчитаем X из Y" - markDone передаёт actualReps только если юзер реально изменил — иначе undefined чтобы история фиксировала планируемое значение чисто == README.md (#4) == - Описание, фичи, скриншоты (TODO-плейсхолдер), установка, dev-команды, архитектура, тесты, stack, ссылка на RELEASING.md - Бэйджи version / tests / platform == i18n == - ~14 новых ключей × 2 языка: dashboard.stat.today_done, streak, settings.quiet.* (3 row'а), reminder.partial == Тесты — 51 (было 33) == - shared/quiet-hours.test.ts (5): disabled, same-day, wrap-around, day filtering, zero-length - renderer/lib/history.test.ts (13): dayKey, dailyReps (planned vs actual vs ignore non-done), currentStreak (empty, today gap, consecutive, yesterday grace, multi-entry same day), dailyRepsRange Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
973339ca62 |
feat(i18n): bilingual UI (Russian + English) + language selector
Все UI-строки приложения переведены и переключаются на лету через
Settings → Язык интерфейса.
== i18n архитектура ==
- src/renderer/src/i18n/dict.ts — плоский словарь ru/en с ~190 ключами,
поддержка интерполяции {var} и плюрализации
- src/renderer/src/i18n/index.ts — useT() React hook + чистые
translate/translateN функции (для ReminderApp вне store context)
- Settings.language: 'ru' | 'en', default 'ru'
- Изменение языка применяется немедленно через Zustand reactive update
== Что переведено ==
- Sidebar nav + slogan + status
- Titlebar window controls (aria-labels)
- Dashboard: hero, 3 stat-карточки (Активных / До следующего /
Трекинг матчей), Paused banner, empty state
- Exercises: hero, секции (активные / выключенные), row meta, empty
- Challenges: hero, formula subtitle, warning, row format
«{stat} × {mult} → {exercise}», empty
- Games: hero, status badges (Live/Ready/Queued/Installed/Not found),
queued/no_user banners, dev panel
- Settings: все секции + новый Language selector
- UpdaterCard: все состояния (checking/available/downloading/
downloaded/error/idle) с интерполяцией версии и MB/s
- ReminderApp: kicker «Время тренировки», reps подпись, snooze label
с динамическими минутами, кнопки done/skip
- Match summary: победа/поражение, плюрализация «N челлендж/-а/-ей»
vs «N challenge/-s»
- Format helpers (formatCountdown, formatInterval) — теперь принимают
Language параметр
== Локалезависимая дата ==
Dashboard hero показывает today в текущей локали:
ru-RU → "воскресенье, 17 мая"
en-US → "Sunday, May 17"
== STAT_LABELS bilingual ==
- shared/types.ts: STAT_LABELS_EN + statLabel(stat, lang) helper
- ChallengeResult получил поле stat?: GameStat (для resolve на стороне
renderer'а с актуальным языком, вместо baked-in label)
- main/games/registry.ts кладёт stat в результат
== Тесты ==
- src/renderer/src/i18n/i18n.test.ts: 10 кейсов
* translate: lookup, fallback, interpolation, multi-var, lang fallback
* translateN: ru plural rules (1/21/101 → one; 2-4 → few; 0/5-20 → many)
и en (1 → one, else → many)
- Всего 33 теста зелёные
== Известное ограничение ==
SAMPLE_EXERCISES (5-6 русских "Приседания / Отжимания / ...") остаются
русскими — это seed данных на первый запуск. Английский юзер сразу
переключит язык и сможет переименовать вручную. Делать seed-per-locale
оверкилл — слишком много кода ради малого.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
70eb4717ec |
release(v0.3.7): auto-check каждый час вместо 6
6 часов было выбрано произвольно как "вежливо для сервера". На практике слишком долго для backgound-приложения: новый релиз доезжает до пользователя только через полдня. Меняем на 1 час — все сравнимые приложения (Discord 30 мин, Slack 30 мин, VS Code 1 ч) используют похожие интервалы. Стартовая проверка (5 сек после запуска) остаётся. Нагрузка минимальна: запрос на latest.yml = 362 байта. UI текст «Авто-проверка раз в 6 часов» → «раз в час». Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
f141d6f0d8 |
chore(updater): remove token-bake — repo is public now
После того как репозиторий стал публичным, токен в Authorization
header больше не нужен. Убираю __UPDATE_TOKEN__ define из
electron.vite.config.ts и весь связанный код в updater.ts.
Преимущества:
- Никаких секретов в распространяемом .exe
- Билд не требует UPDATE_TOKEN env переменной
- Любой может склонировать и собрать без доп. конфига
== Действие пользователя ==
Если v0.3.5 .exe был скачан кем-то с публичного репо (был доступен
~30 минут до того как мы это поняли) — токен из него можно
извлечь и использовать для записи в репо. Рекомендую ротировать:
1. Gitea → Settings → Applications → удалить старый токен
2. Создать новый, скопировать
3. PowerShell:
[Environment]::SetEnvironmentVariable('GITEA_TOKEN', '<новый>', 'User')
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
43ebc8d74c |
fix(updater): запекаем токен в build для приватного Gitea repo
Корень проблемы 404: репо приватный, Gitea требует Authorization
header для release assets даже на browser_download_url. Без токена
запрос возвращает 404 (не 401), поэтому electron-updater сообщал
"Cannot find channel latest.yml update info".
Решение — embed read-only токена в build:
- electron.vite.config.ts: vite `define` для __UPDATE_TOKEN__
читает process.env.UPDATE_TOKEN на этапе сборки
- src/main/updater.ts: если __UPDATE_TOKEN__ непустой, выставляет
autoUpdater.requestHeaders = { Authorization: 'token ...' }
- Декларация declare const локально в модуле, vite заменяет литерал
Сборка теперь требует:
$env:UPDATE_TOKEN = '<gitea-token>'; npm run dist
Если переменная не задана — токен пустой, auto-update тихо отключается
(статус 'unsupported' не показывается, просто запросы будут падать).
Альтернатива на будущее (без токена в .exe):
1. Сделать репо публичным в Gitea Settings
2. Или сделать только Releases публичными если в этой версии Gitea
есть такая опция
Тогда токен в коде не нужен.
== Важно для существующих пользователей ==
Установленные v0.2.x / v0.3.0-0.3.4 не могут получить апдейт
автоматически — у них в бинаре старый updater без токена и они
продолжат получать 404. Им нужно скачать v0.3.5 .exe вручную
и переустановить. После 0.3.5 auto-update заработает.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
92e15e69a3 |
feat: auto-update, тесты и CI/CD
Полная автоматизация релизного цикла. == Auto-update (electron-updater) == - src/main/updater.ts — обёртка над autoUpdater с дискриминированным UpdaterStatus union и broadcast через IPC. autoDownload=false, пользователь сам жмёт «Скачать». allowDowngrade=false. Проверка каждые 6 часов, первая через 5с после старта. - В dev-режиме (app.isPackaged=false) статус сразу становится 'unsupported' с пояснением — никаких exceptions из updater'а. - build.publish в package.json: provider=generic, url указывает на Gitea release assets конкретной версии. - src/main/ipc.ts: 4 новых канала — status/check/download/install. - src/preload: API window.api.updater* + onUpdaterStatus. - src/renderer/src/components/UpdaterCard.tsx: HUD-карточка в Settings с состояниями idle/checking/available/downloading/downloaded/error, прогресс-бар с скоростью в МБ/с. == Тесты (vitest) == - vitest.config.ts с алиасами @shared / @renderer - 23 теста, все зелёные: * format.test.ts — formatCountdown, formatInterval (8 cases) * vdf.test.ts — parseVdf / stringifyVdf / round-trip (11 cases) * types.test.ts — DEFAULT_SETTINGS, SAMPLE_EXERCISES sanity (4) - npm scripts: test (watch), test:run (CI) == CI/CD (Gitea Actions) == - .gitea/workflows/ci.yml — на push/PR: typecheck + тесты + smoke-сборка - .gitea/workflows/release.yml — на тег v*.*.*: сборка NSIS + Gitea release == Локальный релизный скрипт == - scripts/release.ps1 — один скрипт от бампа версии до публикации через Gitea API (params: -Bump patch/minor/major, -Version, -DryRun) - npm run release — обёртка - RELEASING.md — полная инструкция Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
688a86b611 | Initial commit |