import { app } from 'electron' import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { AppState, Challenge, DEFAULT_SETTINGS, Exercise, GameId, HistoryAction, HistoryEntry, HistorySource, Meal, nextMealOccurrence, PersistedState, SAMPLE_EXERCISES, SAMPLE_MEALS, Settings } from '@shared/types' import { log } from './logger' import { validateChallengeInput, validateExerciseInput, validateId, validateMealInput, validateSettingsPatch } from './validate' /** * Keep at most this many history entries (≈2.7 years at 10/day). * When the cap is hit, drop oldest 10% so we don't trim on every write. */ const HISTORY_MAX = 10_000 const WRITE_DEBOUNCE_MS = 1500 const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM let cache: PersistedState | null = null let storePath = '' let pendingWrite: NodeJS.Timeout | null = null export function getStorePath(): string { if (!storePath) { const dir = app.getPath('userData') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) storePath = join(dir, 'app-state.json') } return storePath } function makeInitial(): PersistedState { const now = Date.now() return { exercises: SAMPLE_EXERCISES.map((e) => ({ ...e, id: randomUUID(), nextFireAt: now + e.intervalMinutes * 60_000 })), meals: SAMPLE_MEALS.map((m) => ({ ...m, id: randomUUID(), nextFireAt: nextMealOccurrence(m.time, m.days, now) })), settings: { ...DEFAULT_SETTINGS }, challenges: [ { id: randomUUID(), name: 'За смерти в Dota — приседания', gameId: 'dota2', stat: 'deaths', multiplier: 3, exerciseName: 'Приседания', icon: 'Activity', enabled: true }, { id: randomUUID(), name: 'За убийства — отжимания', gameId: 'dota2', stat: 'kills', multiplier: 1, exerciseName: 'Отжимания', icon: 'Dumbbell', enabled: false } ], gamesEnabled: {}, history: [] } } /** Quarantine a corrupt state file so the user can recover it manually. */ function quarantineCorrupt(p: string, reason: string): void { try { const stamp = new Date() .toISOString() .replace(/[:.]/g, '-') .replace(/Z$/, '') const dest = `${p}.corrupt-${stamp}` renameSync(p, dest) log.error( `[store] app-state.json was unreadable (${reason}); moved to ${dest} and starting fresh.` ) } catch (e) { log.error('[store] failed to quarantine corrupt state file', e) } } function isValidParsed(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } function finiteMs(v: unknown): number | undefined { return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? v : undefined } function intInRange(v: unknown, min: number, max: number): number | undefined { if (typeof v !== 'number' || !Number.isFinite(v)) return undefined const n = Math.trunc(v) return n >= min && n <= max ? n : undefined } function safeStr(v: unknown, max = 200): string | undefined { if (typeof v !== 'string') return undefined if (v.length === 0 || v.length > max) return undefined return v } const SETTINGS_KEYS: (keyof Settings)[] = [ 'globalEnabled', 'notificationMode', 'soundEnabled', 'voicePromptsEnabled', 'meetingAutoPause', 'startWithWindows', 'minimizeToTray', 'startMinimized', 'theme', 'language', 'snoozeMinutes', 'quietHours', 'lastSeenVersion' ] const GAME_IDS: GameId[] = ['dota2'] const HISTORY_ACTIONS: HistoryAction[] = ['done', 'skip', 'snooze'] const HISTORY_SOURCES: HistorySource[] = ['reminder', 'meal', 'match'] function sanitizeSettings(raw: unknown): Settings { const out: Settings = { ...DEFAULT_SETTINGS } if (!isValidParsed(raw)) return out for (const key of SETTINGS_KEYS) { if (!(key in raw)) continue const patch = validateSettingsPatch({ [key]: raw[key] }) if (patch) Object.assign(out, patch) } return out } function sanitizeExercise(raw: unknown, now = Date.now()): Exercise | null { if (!isValidParsed(raw)) return null const id = validateId(raw.id) const base = validateExerciseInput(raw) if (!id || !base) return null const exercise: Exercise = { ...base, id, nextFireAt: finiteMs(raw.nextFireAt) ?? now + base.intervalMinutes * 60_000 } const lastDoneAt = finiteMs(raw.lastDoneAt) if (lastDoneAt !== undefined) exercise.lastDoneAt = lastDoneAt return exercise } function sanitizeMeal(raw: unknown, now = Date.now()): Meal | null { if (!isValidParsed(raw)) return null const id = validateId(raw.id) const base = validateMealInput(raw) if (!id || !base) return null const meal: Meal = { ...base, id, nextFireAt: finiteMs(raw.nextFireAt) ?? nextMealOccurrence(base.time, base.days, now) } const lastDoneAt = finiteMs(raw.lastDoneAt) if (lastDoneAt !== undefined) meal.lastDoneAt = lastDoneAt return meal } function sanitizeChallenge(raw: unknown): Challenge | null { if (!isValidParsed(raw)) return null const id = validateId(raw.id) const base = validateChallengeInput(raw) if (!id || !base) return null return { ...base, id } } function sanitizeGamesEnabled(raw: unknown): Partial> { const out: Partial> = {} if (!isValidParsed(raw)) return out for (const id of GAME_IDS) { if (typeof raw[id] === 'boolean') out[id] = raw[id] } return out } function sanitizeHistoryEntry(raw: unknown): HistoryEntry | null { if (!isValidParsed(raw)) return null const ts = finiteMs(raw.ts) const exerciseId = validateId(raw.exerciseId) const action = typeof raw.action === 'string' && HISTORY_ACTIONS.includes(raw.action as HistoryAction) ? (raw.action as HistoryAction) : undefined if (ts === undefined || !exerciseId || action === undefined) return null const entry: HistoryEntry = { ts, exerciseId, action } const actualReps = intInRange(raw.actualReps, 0, 100_000) if (actualReps !== undefined) entry.actualReps = actualReps const reps = intInRange(raw.reps, 0, 100_000) if (reps !== undefined) entry.reps = reps const name = safeStr(raw.name) if (name !== undefined) entry.name = name if ( typeof raw.source === 'string' && HISTORY_SOURCES.includes(raw.source as HistorySource) ) { entry.source = raw.source as HistorySource } return entry } /** * 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 & { __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 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 PersistedState. */ function coerce(s: StoredState): PersistedState { const now = Date.now() return { exercises: Array.isArray(s.exercises) ? s.exercises.flatMap((raw) => { const exercise = sanitizeExercise(raw, now) return exercise ? [exercise] : [] }) : [], // Additive: старые state'ы без `meals` получают пустой список (см. философию // миграций — additive-поля не требуют bump'а схемы). meals: Array.isArray(s.meals) ? s.meals.flatMap((raw) => { const meal = sanitizeMeal(raw, now) return meal ? [meal] : [] }) : [], settings: sanitizeSettings(s.settings), challenges: Array.isArray(s.challenges) ? s.challenges.flatMap((raw) => { const challenge = sanitizeChallenge(raw) return challenge ? [challenge] : [] }) : [], gamesEnabled: sanitizeGamesEnabled(s.gamesEnabled), history: Array.isArray(s.history) ? s.history.flatMap((raw) => { const entry = sanitizeHistoryEntry(raw) return entry ? [entry] : [] }) : [] } } function load(): PersistedState { const p = getStorePath() if (!existsSync(p)) { const initial = makeInitial() // Cold path — sync write на инициализации (event-loop ещё не активен). atomicWriteSync( p, JSON.stringify( { __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial }, null, 2 ) ) return initial } let raw: string try { raw = readFileSync(p, 'utf-8') } catch (e) { log.error('[store] cannot read state file', e) return makeInitial() // do not quarantine — we can't read it anyway } let parsed: unknown try { parsed = JSON.parse(raw) } catch (e) { quarantineCorrupt(p, `JSON parse error: ${String(e)}`) return makeInitial() } if (!isValidParsed(parsed)) { quarantineCorrupt(p, `expected object, got ${typeof parsed}`) return makeInitial() } return coerce(runMigrations(parsed)) } type AppendOpts = { actualReps?: number /** Planned reps snapshot — иначе после удаления упражнения теряем reps. */ reps?: number /** Snapshot названия — для будущего log-view (необязательно). */ name?: string /** 'reminder' (default) или 'match'. */ source?: import('@shared/types').HistorySource } function appendHistory( exerciseId: string, action: HistoryAction, opts: AppendOpts = {} ): void { const state = getState() if (!state.history) state.history = [] const entry: HistoryEntry = { ts: Date.now(), exerciseId, action } if (opts.actualReps !== undefined) entry.actualReps = opts.actualReps if (opts.reps !== undefined) entry.reps = opts.reps if (opts.name !== undefined) entry.name = opts.name if (opts.source !== undefined) entry.source = opts.source state.history.push(entry) if (state.history.length > HISTORY_MAX) { state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9)) } } export function getHistory(sinceMs?: number): HistoryEntry[] { const all = getState().history ?? [] if (sinceMs == null) return all return all.filter((e) => e.ts >= sinceMs) } export function clearHistory(beforeTs?: number): number { const state = getState() const before = state.history?.length ?? 0 if (beforeTs == null) { // Refuse a full wipe via IPC — callers must pass an explicit boundary. // (Settings UI passes 0 to wipe everything; that's an opt-in.) return 0 } state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs) scheduleWrite() return before - (state.history?.length ?? 0) } /** * Atomically write to `path` via a sibling .tmp file + rename. Retries a few * times on transient EBUSY/EPERM (AV/OneDrive holding the file). * * Async version (используется debounced scheduleWrite/flush) — раньше был * busy-loop `while (Date.now() < until)`, который морозил весь main process * на retry-delay (до 800мс). При активном AV это превращалось в видимое * залипание UI. Сейчас sleep через setTimeout-promise. * * Для процесса-выхода используется `atomicWriteSync` — там event-loop уже * не работает, async sleep не сработает. */ async function atomicWrite(path: string, contents: string): Promise { const tmp = `${path}.tmp` let lastErr: unknown for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) { try { writeFileSync(tmp, contents, 'utf-8') renameSync(tmp, path) return } catch (e) { lastErr = e try { if (existsSync(tmp)) unlinkSync(tmp) } catch { /* ignore */ } const delay = WRITE_RETRY_DELAYS[i] if (delay === undefined) break await new Promise((r) => setTimeout(r, delay)) } } log.error('[store] atomic write failed after retries', lastErr) } /** * Синхронный вариант для use-cases где event loop уже не работает * (process exit в `before-quit`). При retry — короткий sync sleep, потому * что иначе мы дропнем pending write при exit'е. */ function atomicWriteSync(path: string, contents: string): void { const tmp = `${path}.tmp` let lastErr: unknown for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) { try { writeFileSync(tmp, contents, 'utf-8') renameSync(tmp, path) return } catch (e) { lastErr = e try { if (existsSync(tmp)) unlinkSync(tmp) } catch { /* ignore */ } const delay = WRITE_RETRY_DELAYS[i] if (delay === undefined) break // Event-loop остановлен (exit-path), async sleep не вернётся — нужен // блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит // в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс // без сжигания CPU — в отличие от старого busy-loop. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay) } } log.error('[store] atomic sync write failed after retries', lastErr) } async function flush(): Promise { if (!cache) return // 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 } await atomicWrite(getStorePath(), JSON.stringify(payload, null, 2)) } function flushSync(): void { if (!cache) return const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache } atomicWriteSync(getStorePath(), JSON.stringify(payload, null, 2)) } function scheduleWrite(): void { if (pendingWrite) return pendingWrite = setTimeout(() => { pendingWrite = null void flush() }, WRITE_DEBOUNCE_MS) // Don't keep the event loop alive solely for a pending write — `before-quit` // calls `flushNow()` and we explicitly want the process to exit on schedule. pendingWrite.unref?.() } /** * Internal persisted state — единственный source of truth. Включает историю. * Mutate напрямую (mutations внутри store.ts), затем scheduleWrite(). */ export function getState(): PersistedState { if (!cache) cache = load() return cache } /** * State для отправки renderer'у. Копия БЕЗ `history` — историю renderer * запрашивает отдельным IPC.getHistory. Раньше каждый markDone/snooze * отправлял весь state через evtStateChanged, и при 10k entries в истории * это 500KB JSON × N IPC mutations подряд → заметный лаг. * * Возвращаемая копия безопасна для мутации (ipc.ts накладывает на settings * актуальное OS-значение startWithWindows) — мы НЕ мутируем cache. */ export function getStateForRenderer(): AppState { const p = getState() return { exercises: p.exercises, meals: p.meals, settings: p.settings, challenges: p.challenges, gamesEnabled: p.gamesEnabled } } export function getSettings(): Settings { return getState().settings } export function getExercises(): Exercise[] { return getState().exercises } export function updateSettings(patch: Partial): Settings { const state = getState() state.settings = { ...state.settings, ...patch } scheduleWrite() return state.settings } export function addExercise( input: Omit ): Exercise { const state = getState() const exercise: Exercise = { ...input, id: randomUUID(), nextFireAt: Date.now() + input.intervalMinutes * 60_000 } state.exercises.push(exercise) scheduleWrite() return exercise } export function updateExercise( id: string, patch: Partial> ): Exercise | undefined { const state = getState() const idx = state.exercises.findIndex((e) => e.id === id) if (idx === -1) return undefined const prev = state.exercises[idx] // Drop `id` from the patch even though the type forbids it — runtime callers // (IPC) can still smuggle it through. We never let the id change. const { id: _ignoredId, ...safePatch } = patch as Partial const merged: Exercise = { ...prev, ...safePatch } // If interval changed, reschedule from now. if ( patch.intervalMinutes !== undefined && patch.intervalMinutes !== prev.intervalMinutes ) { merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000 } state.exercises[idx] = merged scheduleWrite() return merged } export function deleteExercise(id: string): boolean { const state = getState() const before = state.exercises.length state.exercises = state.exercises.filter((e) => e.id !== id) const changed = state.exercises.length !== before if (changed) scheduleWrite() return changed } export function markDone( id: string, actualReps?: number ): Exercise | undefined { const state = getState() const ex = state.exercises.find((e) => e.id === id) if (!ex) return undefined ex.lastDoneAt = Date.now() ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 appendHistory(id, 'done', { actualReps, reps: ex.reps, name: ex.name, source: 'reminder' }) scheduleWrite() return ex } export function snooze(id: string, minutes: number): Exercise | undefined { const state = getState() const ex = state.exercises.find((e) => e.id === id) if (!ex) return undefined ex.nextFireAt = Date.now() + minutes * 60_000 appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name }) scheduleWrite() return ex } export function skip(id: string): Exercise | undefined { const state = getState() const ex = state.exercises.find((e) => e.id === id) if (!ex) return undefined ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 appendHistory(id, 'skip', { reps: ex.reps, name: ex.name }) scheduleWrite() return ex } // ------------------------------------------------------------------------- // Meals (приёмы пищи — по времени суток) // ------------------------------------------------------------------------- export function getMeals(): Meal[] { return getState().meals } export function addMeal( input: Omit ): Meal { const state = getState() const meal: Meal = { ...input, id: randomUUID(), nextFireAt: nextMealOccurrence(input.time, input.days, Date.now()) } state.meals.push(meal) scheduleWrite() return meal } export function updateMeal( id: string, patch: Partial> ): Meal | undefined { const state = getState() const idx = state.meals.findIndex((m) => m.id === id) if (idx === -1) return undefined const merged: Meal = { ...state.meals[idx], ...patch } // Если поменялось время/дни/вкл — и nextFireAt не задан явно — пересчитать // следующее срабатывание (toggle-on тоже сюда попадает). if ( (patch.time !== undefined || patch.days !== undefined || patch.enabled !== undefined) && patch.nextFireAt === undefined ) { merged.nextFireAt = nextMealOccurrence(merged.time, merged.days, Date.now()) } state.meals[idx] = merged scheduleWrite() return merged } export function deleteMeal(id: string): boolean { const state = getState() const before = state.meals.length state.meals = state.meals.filter((m) => m.id !== id) const ok = state.meals.length < before if (ok) scheduleWrite() return ok } export function markMealDone(id: string): Meal | undefined { const state = getState() const meal = state.meals.find((m) => m.id === id) if (!meal) return undefined meal.lastDoneAt = Date.now() // nextFireAt обычно уже перенесён планировщиком в момент срабатывания; // подстраховка на случай ручного вызова — гарантируем будущее время. if (meal.nextFireAt <= Date.now()) { meal.nextFireAt = nextMealOccurrence(meal.time, meal.days, Date.now()) } appendHistory(`meal:${id}`, 'done', { reps: 1, name: meal.name, source: 'meal' }) scheduleWrite() return meal } /** * Записать выполнение челленджа из match summary в историю. Не привязано * к конкретному Exercise (челлендж может ссылаться на упражнение, которое * пользователь даже не создал). Используем синтетический id 'challenge:'. */ export function markChallengeDone(challengeId: string, reps: number): void { const state = getState() const ch = state.challenges.find((c) => c.id === challengeId) appendHistory(`challenge:${challengeId}`, 'done', { actualReps: reps, reps, name: ch?.exerciseName ?? ch?.name, source: 'match' }) scheduleWrite() } export function flushNow(): void { if (pendingWrite) { clearTimeout(pendingWrite) pendingWrite = null } // before-quit вызывает нас когда event-loop уже на пути к выходу — async // promise не успеет resolved, поэтому sync. flushSync() } export function getChallenges(): Challenge[] { return getState().challenges } export function addChallenge(input: Omit): Challenge { const state = getState() const challenge: Challenge = { ...input, id: randomUUID() } state.challenges.push(challenge) scheduleWrite() return challenge } export function updateChallenge( id: string, patch: Partial> ): Challenge | undefined { const state = getState() const idx = state.challenges.findIndex((c) => c.id === id) if (idx === -1) return undefined // Same id-strip as updateExercise. const { id: _ignoredId, ...safePatch } = patch as Partial state.challenges[idx] = { ...state.challenges[idx], ...safePatch } scheduleWrite() return state.challenges[idx] } export function deleteChallenge(id: string): boolean { const state = getState() const before = state.challenges.length state.challenges = state.challenges.filter((c) => c.id !== id) const changed = state.challenges.length !== before if (changed) scheduleWrite() return changed } export function getGamesEnabled(): Partial> { return getState().gamesEnabled } export function setGameEnabled(id: GameId, enabled: boolean): void { const state = getState() state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled } scheduleWrite() } // ----- Export / Import ----- /** * Полный snapshot persisted-state (включая историю и schema-version). * Используется для backup'а или переноса на другую машину. */ export function exportState(): string { const state = getState() return JSON.stringify( { __schemaVersion: CURRENT_SCHEMA_VERSION, __exportedAt: new Date().toISOString(), __appVersion: app.getVersion(), ...state }, null, 2 ) } /** * Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при * успехе. Идёт через тот же coerce + runMigrations что и load(): валидные * записи сохраняются, повреждённые записи/поля отбрасываются. * * НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты * settings) — простое replace. Перед импортом UI должен спросить * подтверждение. */ export function importState(raw: string): boolean { let parsed: unknown try { parsed = JSON.parse(raw) } catch (e) { log.warn('[store] import: invalid JSON', e) return false } if (!isValidParsed(parsed)) { log.warn('[store] import: expected object') return false } try { const migrated = runMigrations(parsed) const coerced = coerce(migrated) cache = coerced flushSync() return true } catch (e) { log.error('[store] import: coerce/migrate failed', e) return false } }