317 lines
8.9 KiB
TypeScript
317 lines
8.9 KiB
TypeScript
/**
|
|
* 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<string, unknown> {
|
|
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<T extends string>(
|
|
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<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> | 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<Omit<Exercise, 'id'>> | null {
|
|
if (!isObj(raw)) return null
|
|
const out: Partial<Omit<Exercise, 'id'>> = {}
|
|
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<Challenge, 'id'> | 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<Omit<Challenge, 'id'>> | null {
|
|
if (!isObj(raw)) return null
|
|
const out: Partial<Omit<Challenge, 'id'>> = {}
|
|
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<Settings> | null {
|
|
if (!isObj(raw)) return null
|
|
const out: Partial<Settings> = {}
|
|
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
|
|
}
|