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:
129
src/main/ipc.ts
129
src/main/ipc.ts
@@ -7,7 +7,7 @@ import {
|
||||
shell
|
||||
} from 'electron'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
|
||||
import type { Exercise, GameId, Settings } from '@shared/types'
|
||||
import {
|
||||
addChallenge,
|
||||
addExercise,
|
||||
@@ -28,6 +28,7 @@ import { broadcastState } from './state-actions'
|
||||
import { setAutostart, isAutostartEnabled } from './autostart'
|
||||
import { setPaused, forceCheck } from './scheduler'
|
||||
import { hideReminderWindow, getMainWindow } from './windows'
|
||||
import { refreshMenu } from './tray'
|
||||
import {
|
||||
broadcastGames,
|
||||
installGame,
|
||||
@@ -42,6 +43,16 @@ import {
|
||||
getUpdaterStatus,
|
||||
quitAndInstall
|
||||
} from './updater'
|
||||
import {
|
||||
validateActualReps,
|
||||
validateChallengeInput,
|
||||
validateChallengePatch,
|
||||
validateExerciseInput,
|
||||
validateExercisePatch,
|
||||
validateId,
|
||||
validateSettingsPatch,
|
||||
validateSnoozeMinutes
|
||||
} from './validate'
|
||||
|
||||
export function registerIpc(): void {
|
||||
ipcMain.handle(IPC.getState, () => {
|
||||
@@ -50,60 +61,78 @@ export function registerIpc(): void {
|
||||
return state
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.addExercise,
|
||||
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
|
||||
const ex = addExercise(input)
|
||||
broadcastState()
|
||||
return ex
|
||||
}
|
||||
)
|
||||
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
|
||||
const safe = validateExerciseInput(input)
|
||||
if (!safe) return null
|
||||
const ex = addExercise(safe)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.updateExercise,
|
||||
(_e, id: string, patch: Partial<Exercise>) => {
|
||||
(_e, idRaw: unknown, patchRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
const patch = validateExercisePatch(patchRaw)
|
||||
if (!id || !patch) return null
|
||||
const ex = updateExercise(id, patch)
|
||||
broadcastState()
|
||||
return ex
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.deleteExercise, (_e, id: string) => {
|
||||
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return false
|
||||
const ok = deleteExercise(id)
|
||||
broadcastState()
|
||||
return ok
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => {
|
||||
const patch: Partial<Exercise> = { enabled }
|
||||
if (enabled) {
|
||||
const ex = getState().exercises.find((e) => e.id === id)
|
||||
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
ipcMain.handle(
|
||||
IPC.toggleExercise,
|
||||
(_e, idRaw: unknown, enabledRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id || typeof enabledRaw !== 'boolean') return null
|
||||
const patch: Partial<Exercise> = { enabled: enabledRaw }
|
||||
if (enabledRaw) {
|
||||
const ex = getState().exercises.find((e) => e.id === id)
|
||||
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
}
|
||||
const ex = updateExercise(id, patch)
|
||||
broadcastState()
|
||||
return ex
|
||||
}
|
||||
const ex = updateExercise(id, patch)
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const ex = markDone(id, validateActualReps(repsRaw))
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.markDone, (_e, id: string, actualReps?: number) => {
|
||||
const ex = markDone(id, actualReps)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
||||
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
const minutes = validateSnoozeMinutes(minRaw)
|
||||
if (!id || minutes === null) return null
|
||||
const ex = snooze(id, minutes)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.skip, (_e, id: string) => {
|
||||
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const ex = skip(id)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => {
|
||||
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => {
|
||||
const patch = validateSettingsPatch(patchRaw)
|
||||
if (!patch) return null
|
||||
if (patch.startWithWindows !== undefined) {
|
||||
setAutostart(patch.startWithWindows)
|
||||
}
|
||||
@@ -113,9 +142,21 @@ export function registerIpc(): void {
|
||||
}
|
||||
const settings = updateSettings(merged)
|
||||
broadcastState()
|
||||
// Language change reflects in the tray menu next time it's opened.
|
||||
if (patch.language !== undefined) refreshMenu()
|
||||
return settings
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.pauseAll, () => {
|
||||
setPaused(true)
|
||||
refreshMenu()
|
||||
})
|
||||
ipcMain.handle(IPC.resumeAll, () => {
|
||||
setPaused(false)
|
||||
forceCheck()
|
||||
refreshMenu()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.getAccentColor, () => {
|
||||
try {
|
||||
return '#' + systemPreferences.getAccentColor()
|
||||
@@ -128,12 +169,6 @@ export function registerIpc(): void {
|
||||
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.pauseAll, () => setPaused(true))
|
||||
ipcMain.handle(IPC.resumeAll, () => {
|
||||
setPaused(false)
|
||||
forceCheck()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.quit, () => app.quit())
|
||||
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
||||
|
||||
@@ -186,29 +221,41 @@ export function registerIpc(): void {
|
||||
})
|
||||
|
||||
// Challenges
|
||||
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => {
|
||||
const c = addChallenge(input)
|
||||
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => {
|
||||
const safe = validateChallengeInput(input)
|
||||
if (!safe) return null
|
||||
const c = addChallenge(safe)
|
||||
broadcastState()
|
||||
return c
|
||||
})
|
||||
ipcMain.handle(
|
||||
IPC.updateChallenge,
|
||||
(_e, id: string, patch: Partial<Challenge>) => {
|
||||
(_e, idRaw: unknown, patchRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
const patch = validateChallengePatch(patchRaw)
|
||||
if (!id || !patch) return null
|
||||
const c = updateChallenge(id, patch)
|
||||
broadcastState()
|
||||
return c
|
||||
}
|
||||
)
|
||||
ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => {
|
||||
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return false
|
||||
const ok = deleteChallenge(id)
|
||||
broadcastState()
|
||||
return ok
|
||||
})
|
||||
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => {
|
||||
const c = updateChallenge(id, { enabled })
|
||||
broadcastState()
|
||||
return c
|
||||
})
|
||||
ipcMain.handle(
|
||||
IPC.toggleChallenge,
|
||||
(_e, idRaw: unknown, enabledRaw: unknown) => {
|
||||
const id = validateId(idRaw)
|
||||
if (!id || typeof enabledRaw !== 'boolean') return null
|
||||
const c = updateChallenge(id, { enabled: enabledRaw })
|
||||
broadcastState()
|
||||
return c
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user