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>
This commit is contained in:
@@ -97,15 +97,83 @@ function quarantineCorrupt(p: string, reason: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidParsed(v: unknown): v is Partial<AppState> {
|
||||
function isValidParsed(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current persisted-state schema version. Bump this and add a migration to
|
||||
* MIGRATIONS whenever the on-disk shape changes in a non-additive way.
|
||||
*
|
||||
* Additive changes (new optional fields, new entries in `gamesEnabled`) do
|
||||
* NOT need a version bump — DEFAULT_SETTINGS spread + the `?? []` guards in
|
||||
* `coerce()` handle them gracefully.
|
||||
*/
|
||||
const CURRENT_SCHEMA_VERSION = 1
|
||||
|
||||
type StoredState = Record<string, unknown> & { __schemaVersion?: number }
|
||||
|
||||
/**
|
||||
* Migrations are applied in order until the stored version matches CURRENT.
|
||||
* Each fn returns the next-version state. The receiver may freely mutate.
|
||||
*
|
||||
* Note: the v0→v1 migration is a no-op — v1 is the inaugural schema. The
|
||||
* machinery exists so future structural changes (e.g. splitting
|
||||
* `quietHours.days` into a per-window record) have a single explicit place
|
||||
* to live.
|
||||
*/
|
||||
const MIGRATIONS: Record<number, (s: StoredState) => StoredState> = {
|
||||
0: (s) => s
|
||||
}
|
||||
|
||||
function runMigrations(s: StoredState): StoredState {
|
||||
let version = typeof s.__schemaVersion === 'number' ? s.__schemaVersion : 0
|
||||
let cursor = s
|
||||
while (version < CURRENT_SCHEMA_VERSION) {
|
||||
const fn = MIGRATIONS[version]
|
||||
if (!fn) {
|
||||
console.warn(
|
||||
`[store] no migration from v${version}; skipping ahead and hoping for the best.`
|
||||
)
|
||||
break
|
||||
}
|
||||
cursor = fn(cursor)
|
||||
version += 1
|
||||
}
|
||||
cursor.__schemaVersion = CURRENT_SCHEMA_VERSION
|
||||
return cursor
|
||||
}
|
||||
|
||||
/** Coerce a (possibly partial) migrated state into a fully-formed AppState. */
|
||||
function coerce(s: StoredState): AppState {
|
||||
return {
|
||||
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [],
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(isValidParsed(s.settings) ? (s.settings as Partial<Settings>) : {})
|
||||
},
|
||||
challenges: Array.isArray(s.challenges)
|
||||
? (s.challenges as Challenge[])
|
||||
: [],
|
||||
gamesEnabled: isValidParsed(s.gamesEnabled)
|
||||
? (s.gamesEnabled as Partial<Record<GameId, boolean>>)
|
||||
: {},
|
||||
history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : []
|
||||
}
|
||||
}
|
||||
|
||||
function load(): AppState {
|
||||
const p = getStorePath()
|
||||
if (!existsSync(p)) {
|
||||
const initial = makeInitial()
|
||||
atomicWrite(p, JSON.stringify(initial, null, 2))
|
||||
atomicWrite(
|
||||
p,
|
||||
JSON.stringify(
|
||||
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
return initial
|
||||
}
|
||||
let raw: string
|
||||
@@ -126,16 +194,7 @@ function load(): AppState {
|
||||
quarantineCorrupt(p, `expected object, got ${typeof parsed}`)
|
||||
return makeInitial()
|
||||
}
|
||||
return {
|
||||
exercises: Array.isArray(parsed.exercises) ? parsed.exercises : [],
|
||||
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
|
||||
challenges: Array.isArray(parsed.challenges) ? parsed.challenges : [],
|
||||
gamesEnabled:
|
||||
typeof parsed.gamesEnabled === 'object' && parsed.gamesEnabled !== null
|
||||
? parsed.gamesEnabled
|
||||
: {},
|
||||
history: Array.isArray(parsed.history) ? parsed.history : []
|
||||
}
|
||||
return coerce(runMigrations(parsed))
|
||||
}
|
||||
|
||||
function appendHistory(
|
||||
@@ -207,7 +266,10 @@ function atomicWrite(path: string, contents: string): void {
|
||||
|
||||
function flush(): void {
|
||||
if (!cache) return
|
||||
atomicWrite(getStorePath(), JSON.stringify(cache, null, 2))
|
||||
// Persist the schema version alongside the state so future migrations know
|
||||
// where to pick up from. The renderer never reads this key.
|
||||
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
|
||||
atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
|
||||
}
|
||||
|
||||
function scheduleWrite(): void {
|
||||
|
||||
Reference in New Issue
Block a user