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>
105 lines
2.8 KiB
TypeScript
105 lines
2.8 KiB
TypeScript
import { Tray, Menu, nativeImage, app } from 'electron'
|
|
import { join } from 'node:path'
|
|
import { showMainWindow } from './windows'
|
|
import { isPaused, setPaused, forceCheck } from './scheduler'
|
|
import { snoozeAll } from './state-actions'
|
|
import { getSettings } from './store'
|
|
import type { Language } from '@shared/types'
|
|
|
|
let tray: Tray | null = null
|
|
|
|
/**
|
|
* Minimal tray-side localisation. The renderer's full i18n dict lives in
|
|
* `src/renderer/src/i18n/dict.ts` and isn't reachable from the main process
|
|
* tsconfig, so we keep the 5 strings the tray actually uses here.
|
|
*/
|
|
const TRAY_STRINGS: Record<Language, Record<string, string>> = {
|
|
ru: {
|
|
open: 'Открыть',
|
|
pause: 'Пауза напоминаний',
|
|
resume: 'Возобновить напоминания',
|
|
snooze15: 'Отложить все на 15 мин',
|
|
quit: 'Выход'
|
|
},
|
|
en: {
|
|
open: 'Open',
|
|
pause: 'Pause reminders',
|
|
resume: 'Resume reminders',
|
|
snooze15: 'Snooze all 15 min',
|
|
quit: 'Quit'
|
|
}
|
|
}
|
|
|
|
function trayLabel(key: string): string {
|
|
// getSettings reads from cache; if the store hasn't loaded yet (very early
|
|
// boot) it lazily reads from disk. Defaults to 'ru' if anything goes wrong.
|
|
let lang: Language = 'ru'
|
|
try {
|
|
lang = getSettings().language ?? 'ru'
|
|
} catch {
|
|
/* keep default */
|
|
}
|
|
return TRAY_STRINGS[lang]?.[key] ?? TRAY_STRINGS.ru[key] ?? key
|
|
}
|
|
|
|
function resolveTrayIcon(): Electron.NativeImage {
|
|
// Try resources/, fallback to a transparent 16x16 if missing during dev.
|
|
const candidates = [
|
|
join(process.resourcesPath, 'tray.png'),
|
|
join(__dirname, '../../resources/tray.png'),
|
|
join(app.getAppPath(), 'resources/tray.png')
|
|
]
|
|
for (const p of candidates) {
|
|
const img = nativeImage.createFromPath(p)
|
|
if (!img.isEmpty()) return img
|
|
}
|
|
return nativeImage.createEmpty()
|
|
}
|
|
|
|
export function createTray(): Tray {
|
|
if (tray) return tray
|
|
const icon = resolveTrayIcon()
|
|
tray = new Tray(icon)
|
|
tray.setToolTip('Exercise Reminder')
|
|
refreshMenu()
|
|
tray.on('click', () => showMainWindow())
|
|
tray.on('double-click', () => showMainWindow())
|
|
return tray
|
|
}
|
|
|
|
export function refreshMenu(): void {
|
|
if (!tray) return
|
|
const paused = isPaused()
|
|
const menu = Menu.buildFromTemplate([
|
|
{ label: trayLabel('open'), click: () => showMainWindow() },
|
|
{ type: 'separator' },
|
|
{
|
|
label: paused ? trayLabel('resume') : trayLabel('pause'),
|
|
click: () => {
|
|
setPaused(!paused)
|
|
refreshMenu()
|
|
if (!paused) forceCheck()
|
|
}
|
|
},
|
|
{
|
|
label: trayLabel('snooze15'),
|
|
click: () => snoozeAll(15)
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: trayLabel('quit'),
|
|
click: () => {
|
|
app.quit()
|
|
}
|
|
}
|
|
])
|
|
tray.setContextMenu(menu)
|
|
}
|
|
|
|
export function destroyTray(): void {
|
|
if (tray) {
|
|
tray.destroy()
|
|
tray = null
|
|
}
|
|
}
|