Three independent code reviews + a security audit produced ~200 findings.
This commit lands the high-impact subset. Tests pass (53), typecheck
clean, eslint clean (3 minor exhaustive-deps warnings left).
REPO HYGIENE
- Add .editorconfig, .prettierrc.json, .prettierignore.
- Add ESLint flat config (.eslintrc.cjs) — correctness-focused, no style
rules (Prettier owns formatting).
- Add `format` / `format:check` / `lint` npm scripts.
- Add CHANGELOG.md (Keep a Changelog format, back-filled to 0.1.x).
- Reformat all source via Prettier so future diffs stay small.
DATA SAFETY (src/main/store.ts)
- Atomic write (tmp + rename) with retry on transient EBUSY/EPERM —
was non-atomic writeFileSync, vulnerable to truncation on power loss.
- On corrupt JSON, rename to `app-state.json.corrupt-<ts>` instead of
silently overwriting the user's exercises/history with defaults.
- Validate parsed shape before merging — reject arrays/scalars where
objects expected; per-field array checks.
- Strip `id` from incoming patches in updateExercise/updateChallenge —
a runtime caller (IPC) could otherwise smuggle id changes through.
- clearHistory now refuses an unbounded wipe (no beforeTs => no-op);
callers must pass an explicit boundary.
- unref() the debounce timer so it doesn't keep the event loop alive.
SECURITY (src/main/*)
- gsi-server: hard 256 KB body cap (was unbounded — local OOM vector),
reject any Origin/Sec-Fetch-Site header (blocks browser CSRF from
visited pages), require application/json Content-Type, generic 400
on parse error (no error string echo to client), closeAllConnections
+ async close on stop.
- dota2: validate auth.token from payload with timingSafeEqual against
the per-install token — was unauthenticated, any local process could
forge match-end events. Narrow object shape before spread-merge to
avoid throws on hostile payloads like {player:"x"}. Reset latest /
prevState after match_end so the next match starts clean.
- ipc: gate `dev:simulateMatchEnd` registration behind `!app.isPackaged`
so it does not exist in shipped builds.
- preload: gate the matching `simulateMatchEnd` export behind
`import.meta.env.MODE !== 'production'` so the bundler dead-code-
eliminates it from the production preload bundle.
- windows: shell.openExternal allowlist (http/https/mailto only) — was
forwarding any URL, including file:/javascript:/custom URI handlers
(some Windows handlers have been RCE vectors). will-navigate blocks
navigation to anywhere except file:// or the dev URL.
CORRECTNESS (src/main/* + src/shared/*)
- shared/types.ts isQuietAt: fix wrap-around + day-of-week filter.
With from=22:00 to=07:00 days=[Mon..Fri], the window started THE
PREVIOUS DAY when we're in the AM half — old code checked today's
day-of-week and got the wrong answer Sat 02:00 and Mon 01:00. Now
the filter is evaluated against the window's START day. Also reject
malformed HH:MM strings instead of producing NaN.
- scheduler: call broadcastState() after firing exercises so the
renderer's Dashboard/Exercises pages don't show stale nextFireAt
until the next state-changing IPC. Guard powerMonitor listeners
against double-registration on dev hot-reload.
- dota2: fix `launchOptionStatus = steamRunning ? 'queued' : 'queued'`
tautology — both branches now correctly read 'queued'.
- steam-launch-options: replace `require('node:fs')` inside atomicWrite
with the top-level import; retry on transient EBUSY/EPERM.
CORRECTNESS (src/renderer/*)
- lib/history.ts: replace `today.getTime() - i * MS_DAY` arithmetic
with `setDate(date - i)` calendar arithmetic in dailyRepsRange and
currentStreak — DST transitions shift epoch math by ±1h and cause
dayKey() to emit duplicate or missing days at the boundary.
- lib/icon.tsx: restrict name lookup to ICON_CHOICES set — an arbitrary
string from a corrupted state file could otherwise resolve to
unrelated Lucide exports and crash the renderer.
- lib/format.ts: guard formatCountdown against NaN/Infinity.
- i18n/index.ts: replace regex-based interpolation with split/join so
variable values containing regex metacharacters interpolate
literally; warn in dev on missing keys; clamp pluralRu(-N) via abs.
- ReminderApp: keyboard shortcuts moved INTO ExerciseReminder so Enter
respects the stepper's `adjusted` flag (was always passing planned
reps). Stepper capped at 5× planned. Don't hijack Space when a
button is focused. `key={exercise.id+nextFireAt}` forces a fresh
component for back-to-back reminders so stepper state resets. Match
summary view gets Esc-to-close. Functional setMode in onMarkDone
avoids races against stale `mode.done`.
- UpdaterCard: guard against NaN/Infinity in download-progress events
(electron-updater fires early events with undefined fields).
- Games: gate DevPanel behind `import.meta.env.DEV` in addition to the
main-side IPC gate, and narrow the `simulateMatchEnd` access.
- Add aria-labels for the +/- stepper buttons (i18n keys added).
TESTS
- +2 quiet-hours tests covering wrap-around + day-filter combo and
malformed HH:MM fallback. Total 53 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
131 lines
2.8 KiB
TypeScript
131 lines
2.8 KiB
TypeScript
// Minimal Valve KeyValues (VDF) text parser.
|
|
// Handles nested objects and quoted string values. Sufficient for libraryfolders.vdf.
|
|
|
|
export type VdfNode = { [key: string]: string | VdfNode }
|
|
|
|
class Cursor {
|
|
constructor(
|
|
public src: string,
|
|
public pos: number = 0
|
|
) {}
|
|
peek(): string {
|
|
return this.src[this.pos] ?? ''
|
|
}
|
|
next(): string {
|
|
return this.src[this.pos++] ?? ''
|
|
}
|
|
eof(): boolean {
|
|
return this.pos >= this.src.length
|
|
}
|
|
}
|
|
|
|
function skipWhitespaceAndComments(c: Cursor): void {
|
|
for (;;) {
|
|
while (!c.eof() && /\s/.test(c.peek())) c.next()
|
|
if (c.peek() === '/' && c.src[c.pos + 1] === '/') {
|
|
while (!c.eof() && c.next() !== '\n') {
|
|
/* skip line */
|
|
}
|
|
continue
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
function readToken(c: Cursor): string {
|
|
skipWhitespaceAndComments(c)
|
|
if (c.eof()) return ''
|
|
if (c.peek() === '"') {
|
|
c.next()
|
|
let out = ''
|
|
while (!c.eof()) {
|
|
const ch = c.next()
|
|
if (ch === '\\') {
|
|
const next = c.next()
|
|
if (next === 'n') out += '\n'
|
|
else if (next === 't') out += '\t'
|
|
else out += next
|
|
continue
|
|
}
|
|
if (ch === '"') return out
|
|
out += ch
|
|
}
|
|
return out
|
|
}
|
|
if (c.peek() === '{' || c.peek() === '}') return c.next()
|
|
let out = ''
|
|
while (
|
|
!c.eof() &&
|
|
!/\s/.test(c.peek()) &&
|
|
c.peek() !== '{' &&
|
|
c.peek() !== '}'
|
|
) {
|
|
out += c.next()
|
|
}
|
|
return out
|
|
}
|
|
|
|
function parseObject(c: Cursor): VdfNode {
|
|
const node: VdfNode = {}
|
|
for (;;) {
|
|
skipWhitespaceAndComments(c)
|
|
if (c.eof()) return node
|
|
if (c.peek() === '}') {
|
|
c.next()
|
|
return node
|
|
}
|
|
const key = readToken(c)
|
|
if (!key) return node
|
|
skipWhitespaceAndComments(c)
|
|
if (c.peek() === '{') {
|
|
c.next()
|
|
node[key] = parseObject(c)
|
|
} else {
|
|
node[key] = readToken(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function parseVdf(src: string): VdfNode {
|
|
const c = new Cursor(src)
|
|
const root: VdfNode = {}
|
|
for (;;) {
|
|
skipWhitespaceAndComments(c)
|
|
if (c.eof()) break
|
|
const key = readToken(c)
|
|
if (!key) break
|
|
skipWhitespaceAndComments(c)
|
|
if (c.peek() === '{') {
|
|
c.next()
|
|
root[key] = parseObject(c)
|
|
} else {
|
|
root[key] = readToken(c)
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
function escapeVdfString(s: string): string {
|
|
return s
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\t/g, '\\t')
|
|
}
|
|
|
|
export function stringifyVdf(node: VdfNode, indent: number = 0): string {
|
|
const pad = '\t'.repeat(indent)
|
|
let out = ''
|
|
for (const key of Object.keys(node)) {
|
|
const value = node[key]
|
|
if (typeof value === 'string') {
|
|
out += `${pad}"${escapeVdfString(key)}"\t\t"${escapeVdfString(value)}"\n`
|
|
} else {
|
|
out += `${pad}"${escapeVdfString(key)}"\n${pad}{\n`
|
|
out += stringifyVdf(value, indent + 1)
|
|
out += `${pad}}\n`
|
|
}
|
|
}
|
|
return out
|
|
}
|