/** * Hand-rolled runtime validators for IPC payloads. * * TypeScript types are erased at compile time — a compromised or buggy * renderer can still send arbitrary JSON across the IPC boundary. These * helpers enforce shape, type and range BEFORE the data hits the store. * * Philosophy: be lenient with unknown fields (drop them silently), strict * about known fields (reject the call if a known field is the wrong type * or out of range). Never throw to the renderer; return a sanitised value * or `null` and the caller decides what to do. */ import type { Challenge, Exercise, GameStat, Settings, Theme, Language, NotificationMode } from '@shared/types' const MAX_STR_LEN = 200 const VALID_THEMES: Theme[] = ['system', 'light', 'dark'] const VALID_LANGS: Language[] = ['ru', 'en'] const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both'] const VALID_STATS: GameStat[] = [ 'deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min' ] const HHMM_RE = /^\d{1,2}:\d{2}$/ function isObj(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } function safeStr(v: unknown, max = MAX_STR_LEN): string | undefined { if (typeof v !== 'string') return undefined if (v.length === 0 || v.length > max) return undefined return v } function intInRange(v: unknown, min: number, max: number): number | undefined { if (typeof v !== 'number' || !Number.isFinite(v)) return undefined const n = Math.trunc(v) if (n < min || n > max) return undefined return n } function numInRange(v: unknown, min: number, max: number): number | undefined { if (typeof v !== 'number' || !Number.isFinite(v)) return undefined if (v < min || v > max) return undefined return v } function bool(v: unknown): boolean | undefined { return typeof v === 'boolean' ? v : undefined } function oneOf( v: unknown, allowed: readonly T[] ): T | undefined { return typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : undefined } // ----------------------------------------------------------------------- // Exercise validators // ----------------------------------------------------------------------- export function validateExerciseInput( raw: unknown ): Omit | null { if (!isObj(raw)) return null const name = safeStr(raw.name) const reps = intInRange(raw.reps, 1, 9999) const intervalMinutes = intInRange(raw.intervalMinutes, 1, 24 * 60) const icon = safeStr(raw.icon, 64) ?? 'Activity' const enabled = bool(raw.enabled) ?? true if ( name === undefined || reps === undefined || intervalMinutes === undefined ) { return null } return { name, reps, intervalMinutes, icon, enabled } } export function validateExercisePatch( raw: unknown ): Partial> | null { if (!isObj(raw)) return null const out: Partial> = {} if ('name' in raw) { const v = safeStr(raw.name) if (v === undefined) return null out.name = v } if ('reps' in raw) { const v = intInRange(raw.reps, 1, 9999) if (v === undefined) return null out.reps = v } if ('intervalMinutes' in raw) { const v = intInRange(raw.intervalMinutes, 1, 24 * 60) if (v === undefined) return null out.intervalMinutes = v } if ('icon' in raw) { const v = safeStr(raw.icon, 64) if (v === undefined) return null out.icon = v } if ('enabled' in raw) { const v = bool(raw.enabled) if (v === undefined) return null out.enabled = v } // Allow scheduler-controlled fields to be patched (used by store.markDone // through this same boundary), but range-check them. if ('nextFireAt' in raw) { const v = numInRange(raw.nextFireAt, 0, Number.MAX_SAFE_INTEGER) if (v === undefined) return null out.nextFireAt = v } if ('lastDoneAt' in raw) { const v = numInRange(raw.lastDoneAt, 0, Number.MAX_SAFE_INTEGER) if (v === undefined) return null out.lastDoneAt = v } return out } // ----------------------------------------------------------------------- // Challenge validators // ----------------------------------------------------------------------- export function validateChallengeInput( raw: unknown ): Omit | null { if (!isObj(raw)) return null const name = safeStr(raw.name) const gameId = safeStr(raw.gameId, 32) const stat = oneOf(raw.stat, VALID_STATS) const multiplier = numInRange(raw.multiplier, 0, 1000) const exerciseName = safeStr(raw.exerciseName) const icon = safeStr(raw.icon, 64) ?? 'Activity' const enabled = bool(raw.enabled) ?? true if ( name === undefined || gameId === undefined || stat === undefined || multiplier === undefined || exerciseName === undefined ) { return null } return { name, gameId: gameId as Challenge['gameId'], stat, multiplier, exerciseName, icon, enabled } } export function validateChallengePatch( raw: unknown ): Partial> | null { if (!isObj(raw)) return null const out: Partial> = {} if ('name' in raw) { const v = safeStr(raw.name) if (v === undefined) return null out.name = v } if ('exerciseName' in raw) { const v = safeStr(raw.exerciseName) if (v === undefined) return null out.exerciseName = v } if ('stat' in raw) { const v = oneOf(raw.stat, VALID_STATS) if (v === undefined) return null out.stat = v } if ('multiplier' in raw) { const v = numInRange(raw.multiplier, 0, 1000) if (v === undefined) return null out.multiplier = v } if ('icon' in raw) { const v = safeStr(raw.icon, 64) if (v === undefined) return null out.icon = v } if ('enabled' in raw) { const v = bool(raw.enabled) if (v === undefined) return null out.enabled = v } return out } // ----------------------------------------------------------------------- // Settings validators // ----------------------------------------------------------------------- export function validateSettingsPatch(raw: unknown): Partial | null { if (!isObj(raw)) return null const out: Partial = {} if ('globalEnabled' in raw) { const v = bool(raw.globalEnabled) if (v === undefined) return null out.globalEnabled = v } if ('startWithWindows' in raw) { const v = bool(raw.startWithWindows) if (v === undefined) return null out.startWithWindows = v } if ('startMinimized' in raw) { const v = bool(raw.startMinimized) if (v === undefined) return null out.startMinimized = v } if ('minimizeToTray' in raw) { const v = bool(raw.minimizeToTray) if (v === undefined) return null out.minimizeToTray = v } if ('soundEnabled' in raw) { const v = bool(raw.soundEnabled) if (v === undefined) return null out.soundEnabled = v } if ('voicePromptsEnabled' in raw) { const v = bool(raw.voicePromptsEnabled) if (v === undefined) return null out.voicePromptsEnabled = v } if ('notificationMode' in raw) { const v = oneOf(raw.notificationMode, VALID_NOTIFY) if (v === undefined) return null out.notificationMode = v } if ('theme' in raw) { const v = oneOf(raw.theme, VALID_THEMES) if (v === undefined) return null out.theme = v } if ('language' in raw) { const v = oneOf(raw.language, VALID_LANGS) if (v === undefined) return null out.language = v } if ('snoozeMinutes' in raw) { const v = intInRange(raw.snoozeMinutes, 1, 24 * 60) if (v === undefined) return null out.snoozeMinutes = v } if ('quietHours' in raw) { const qh = raw.quietHours if (!isObj(qh)) return null const enabled = bool(qh.enabled) const from = safeStr(qh.from, 8) const to = safeStr(qh.to, 8) if ( enabled === undefined || from === undefined || to === undefined || !HHMM_RE.test(from) || !HHMM_RE.test(to) ) { return null } if (!Array.isArray(qh.days)) return null const days: number[] = [] for (const d of qh.days) { const n = intInRange(d, 0, 6) if (n === undefined) return null if (!days.includes(n)) days.push(n) } out.quietHours = { enabled, from, to, days } } return out } // ----------------------------------------------------------------------- // Misc tiny validators // ----------------------------------------------------------------------- export function validateId(raw: unknown): string | null { // UUIDs from store.ts via randomUUID(); accept any reasonable string id. const v = safeStr(raw, 64) return v ?? null } export function validateActualReps(raw: unknown): number | undefined { if (raw === undefined || raw === null) return undefined return intInRange(raw, 0, 100000) ?? undefined } export function validateSnoozeMinutes(raw: unknown): number | null { return intInRange(raw, 1, 24 * 60) ?? null }