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, SAMPLE_EXERCISES, Settings } from '@shared/types' /** * 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: AppState | null = null let storePath = '' let pendingWrite: NodeJS.Timeout | null = null 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(): AppState { const now = Date.now() return { exercises: SAMPLE_EXERCISES.map((e) => ({ ...e, id: randomUUID(), nextFireAt: now + e.intervalMinutes * 60_000 })), 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) console.error( `[store] app-state.json was unreadable (${reason}); ` + `moved to ${dest} and starting fresh.` ) } catch (e) { console.error('[store] failed to quarantine corrupt state file:', e) } } function isValidParsed(v: unknown): v is Partial { return typeof v === 'object' && v !== null && !Array.isArray(v) } function load(): AppState { const p = getStorePath() if (!existsSync(p)) { const initial = makeInitial() atomicWrite(p, JSON.stringify(initial, null, 2)) return initial } let raw: string try { raw = readFileSync(p, 'utf-8') } catch (e) { console.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 { 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 : [] } } function appendHistory( exerciseId: string, action: HistoryAction, actualReps?: number ): void { const state = getState() if (!state.history) state.history = [] const entry: HistoryEntry = { ts: Date.now(), exerciseId, action } if (actualReps !== undefined) entry.actualReps = actualReps state.history.push(entry) if (state.history.length > HISTORY_MAX) { state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9)) } // Caller schedules the write; appendHistory itself is internal. } 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). */ function atomicWrite(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 // best-effort cleanup of the stale .tmp try { if (existsSync(tmp)) unlinkSync(tmp) } catch { /* ignore */ } const delay = WRITE_RETRY_DELAYS[i] if (delay === undefined) break // Synchronous sleep — write path is short and called outside the hot loop. const until = Date.now() + delay while (Date.now() < until) { /* spin */ } } } console.error('[store] atomic write failed after retries:', lastErr) } function flush(): void { if (!cache) return atomicWrite(getStorePath(), JSON.stringify(cache, null, 2)) } function scheduleWrite(): void { if (pendingWrite) return pendingWrite = setTimeout(() => { pendingWrite = null 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?.() } export function getState(): AppState { if (!cache) cache = load() return cache } 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) 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') 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') scheduleWrite() return ex } export function flushNow(): void { if (pendingWrite) { clearTimeout(pendingWrite) pendingWrite = null } flush() } 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() }