chore: sprint D — sandbox, self-hosted fonts, logger с ротацией

#6  sandbox: true на обоих BrowserWindow (раньше false). Preload
    использует только contextBridge + ipcRenderer (оба sandbox-safe),
    никаких Node-built-ins. OS-уровневый sandbox изолирует renderer
    от GPU/IPC процессов; даже RCE в зависимости renderer'а не
    получит Node-доступа через preload.

#17 self-host шрифтов через @fontsource/* пакеты. Раньше тянулись
    с fonts.googleapis.com — внешняя CSP-зависимость + отсутствие
    интернета = шрифты не загружались. Теперь .woff/.woff2 в bundle
    (22 файла × 15-30KB = ~500KB).
    Подкрутили CSP: убрали https://fonts.* origins, добавили
    connect-src 'self', base-uri 'self', frame-ancestors 'none'.

#22 src/main/logger.ts — структурный лог с уровнями
    (debug/info/warn/error) и ротацией. Пишет в
    %APPDATA%/Exercise Reminder/logs/latest.log (≤1MB) и
    дублирует в console. При 1MB latest.log → prev.log
    (предыдущий prev.log удаляется). LAUDE_DEBUG=1 включает
    debug-уровень.

    Подключён в hot paths: store (corrupt/atomic write fails),
    updater (silent check errors), gsi-server (bad requests,
    handler throws), games/registry (GSI start, reconcile, match_end
    summary), games/dota2 (rejected token, POST_GAME detection).

    Особенно полезно для диагностики «челленджи не срабатывают»:
    лог покажет (а) пришёл ли вообще GSI payload (token verify),
    (б) детектировался ли POST_GAME, (в) сколько challenges были
    enabled и которые из них дали 0 reps.

    Logger — единственный файл с `eslint-disable no-console` (он
    намеренно дублирует в stderr).
This commit is contained in:
AnRil
2026-05-22 01:24:30 +07:00
parent e7ccca98e7
commit 34fb03b265
11 changed files with 254 additions and 27 deletions

34
package-lock.json generated
View File

@@ -1,13 +1,16 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.1", "version": "0.5.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "laude", "name": "laude",
"version": "0.5.1", "version": "0.5.4",
"dependencies": { "dependencies": {
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"framer-motion": "^11.11.17", "framer-motion": "^11.11.17",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
@@ -1264,6 +1267,33 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@fontsource/bricolage-grotesque": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.2.10.tgz",
"integrity": "sha512-V2xS+1P7C8IrSypXLUx/bLtX/LsTlYtV2k2CsU+S/0t8qepZ2hvKSlyJIx7Ub/iY8Bbnj+IjAuUF9nvFz+BbIg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/plus-jakarta-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
"integrity": "sha512-P5qE49fqdeD+7DXH1KBxmMPlB17LTz1zvBhFH0tFzfnYTKVJVyb0pR6plh0ZGXxcB+Oayb54FZZw3V42/DawTw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",

View File

@@ -25,6 +25,9 @@
"gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1" "gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1"
}, },
"dependencies": { "dependencies": {
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"framer-motion": "^11.11.17", "framer-motion": "^11.11.17",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",

View File

@@ -18,6 +18,7 @@ import {
isSteamRunning isSteamRunning
} from './steam-launch-options' } from './steam-launch-options'
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types' import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
import { log } from '../logger'
const APP_ID = '570' const APP_ID = '570'
const INSTALL_DIR = 'dota 2 beta' const INSTALL_DIR = 'dota 2 beta'
@@ -198,6 +199,8 @@ export class Dota2Provider implements GameProvider {
this.latest = undefined this.latest = undefined
} }
private rejectedTokenLogged = false
private handle(g: DotaGsi): void { private handle(g: DotaGsi): void {
// Verify the per-install token. Dota always sends auth.token; anything // Verify the per-install token. Dota always sends auth.token; anything
// without it (or with the wrong one) is some other process on localhost // without it (or with the wrong one) is some other process on localhost
@@ -207,6 +210,15 @@ export class Dota2Provider implements GameProvider {
typeof incoming !== 'string' || typeof incoming !== 'string' ||
!safeEqualStrings(incoming, this.token) !safeEqualStrings(incoming, this.token)
) { ) {
// Логируем только ОДИН раз за процесс — Dota шлёт payload каждые
// ~100ms во время матча, иначе zass'мём latest.log.
if (!this.rejectedTokenLogged) {
this.rejectedTokenLogged = true
log.warn(
'[dota2] GSI payload with invalid/missing token rejected. ' +
'Если приложение переустанавливалось — заново подключи Dota 2 в Games.'
)
}
return return
} }
@@ -235,8 +247,12 @@ export class Dota2Provider implements GameProvider {
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') { if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open. // De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
const now = Date.now() const now = Date.now()
if (now - this.lastMatchEndAt < 30_000) return if (now - this.lastMatchEndAt < 30_000) {
log.debug('[dota2] suppressed duplicate POST_GAME within 30s window')
return
}
this.lastMatchEndAt = now this.lastMatchEndAt = now
log.info('[dota2] POST_GAME detected, emitting match_end event')
const p = this.latest?.player ?? {} const p = this.latest?.player ?? {}
const m = this.latest?.map ?? {} const m = this.latest?.map ?? {}

View File

@@ -4,6 +4,7 @@ import {
type Server, type Server,
type ServerResponse type ServerResponse
} from 'node:http' } from 'node:http'
import { log } from '../logger'
export type GsiHandler = ( export type GsiHandler = (
payload: unknown, payload: unknown,
@@ -87,7 +88,7 @@ async function onRequest(
payload = text.length > 0 ? JSON.parse(text) : {} payload = text.length > 0 ? JSON.parse(text) : {}
} catch (err) { } catch (err) {
// Log the real reason locally; do not echo it to the client. // Log the real reason locally; do not echo it to the client.
console.warn('[gsi] bad request:', err instanceof Error ? err.message : err) log.warn('[gsi] bad request', err instanceof Error ? err.message : err)
res.statusCode = 400 res.statusCode = 400
res.end() res.end()
return return
@@ -99,7 +100,7 @@ async function onRequest(
res.setHeader('Content-Type', 'text/plain') res.setHeader('Content-Type', 'text/plain')
res.end('ok') res.end('ok')
} catch (err) { } catch (err) {
console.error('[gsi] handler threw:', err) log.error('[gsi] handler threw', err)
res.statusCode = 500 res.statusCode = 500
res.end() res.end()
} }

View File

@@ -13,6 +13,7 @@ import type {
import { STAT_LABELS } from '@shared/types' import { STAT_LABELS } from '@shared/types'
import { getChallenges, getGamesEnabled } from '../store' import { getChallenges, getGamesEnabled } from '../store'
import { fireMatchSummary } from '../notifications' import { fireMatchSummary } from '../notifications'
import { log } from '../logger'
const providers: Record<GameId, GameProvider> = { const providers: Record<GameId, GameProvider> = {
dota2: new Dota2Provider() dota2: new Dota2Provider()
@@ -25,14 +26,23 @@ async function onMatchEnd(
payload: MatchEndPayload payload: MatchEndPayload
): Promise<void> { ): Promise<void> {
const provider = providers[gameId] const provider = providers[gameId]
const challenges = getChallenges().filter( const allChallenges = getChallenges().filter((c) => c.gameId === gameId)
(c) => c.gameId === gameId && c.enabled const enabledChallenges = allChallenges.filter((c) => c.enabled)
log.info(
`[games] match_end gameId=${gameId} stats=${JSON.stringify(
payload.stats
)} challenges=${enabledChallenges.length}/${allChallenges.length} (enabled/total)`
) )
const results: ChallengeResult[] = [] const results: ChallengeResult[] = []
for (const ch of challenges) { for (const ch of enabledChallenges) {
const statValue = payload.stats[ch.stat] ?? 0 const statValue = payload.stats[ch.stat] ?? 0
const reps = Math.round(statValue * ch.multiplier) const reps = Math.round(statValue * ch.multiplier)
if (reps <= 0) continue if (reps <= 0) {
log.debug(
`[games] skip challenge "${ch.name}": ${ch.stat}=${statValue} × ${ch.multiplier} = ${reps}`
)
continue
}
results.push({ results.push({
challengeId: ch.id, challengeId: ch.id,
name: ch.name, name: ch.name,
@@ -44,7 +54,21 @@ async function onMatchEnd(
stat: ch.stat stat: ch.stat
}) })
} }
if (results.length === 0) return if (results.length === 0) {
log.warn(
`[games] match_end produced no reps (no enabled challenges matched stats). ` +
`Enabled challenges: ${enabledChallenges.length}, stats keys: ${Object.keys(
payload.stats
).join(',')}`
)
return
}
log.info(
`[games] firing match summary: ${results.length} challenges, total reps ${results.reduce(
(s, r) => s + r.reps,
0
)}`
)
const summary: MatchSummary = { const summary: MatchSummary = {
gameId, gameId,
@@ -61,8 +85,9 @@ export async function startGamesRegistry(): Promise<void> {
running = true running = true
try { try {
await startGsiServer() await startGsiServer()
log.info('[games] GSI server started on port 4701')
} catch (err) { } catch (err) {
console.error('GSI server failed to start:', err) log.error('[games] GSI server failed to start', err)
return return
} }
@@ -79,7 +104,7 @@ export async function startGamesRegistry(): Promise<void> {
try { try {
await provider.reconcile?.() await provider.reconcile?.()
} catch (err) { } catch (err) {
console.error('reconcile failed for', id, err) log.error(`[games] reconcile failed for ${id}`, err)
} }
if (!enabled[id]) continue if (!enabled[id]) continue
await provider.start((e) => { await provider.start((e) => {

125
src/main/logger.ts Normal file
View File

@@ -0,0 +1,125 @@
/* eslint-disable no-console -- этот файл — единственное место где console.*
разрешён намеренно: дублирование лога в stderr для dev-режима. */
/**
* Минимальный logger для main process.
*
* Пишет в файл `%APPDATA%/Exercise Reminder/logs/latest.log` + дублирует
* в stderr через console.* (чтобы dev-режим оставался удобным).
*
* Ротация: при достижении 1MB latest.log переименовывается в prev.log
* (предыдущий prev.log удаляется). Две сессии истории — этого достаточно
* для воспроизведения «случилось вчера, а сегодня перезапустил». Никакой
* remote-телеметрии: лог локальный, пользователь сам может вложить его в
* issue если что-то сломалось.
*
* Уровни:
* - debug: подробный traceback, видим только если LAUDE_DEBUG=1
* - info: значимые события (startup, GSI matched, updater progress)
* - warn: recoverable issues (transient network, retry succeeded)
* - error: что-то реально сломалось (atomic write fail, IPC validation)
*/
import { app } from 'electron'
import {
appendFileSync,
existsSync,
mkdirSync,
renameSync,
statSync,
unlinkSync
} from 'node:fs'
import { join } from 'node:path'
const ROTATE_AT_BYTES = 1 * 1024 * 1024 // 1 MB
type Level = 'debug' | 'info' | 'warn' | 'error'
let logDir = ''
let logPath = ''
let prevPath = ''
function ensurePaths(): void {
if (logDir) return
try {
logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
logPath = join(logDir, 'latest.log')
prevPath = join(logDir, 'prev.log')
} catch {
// app.getPath не готов (очень ранний boot) — отложим, console продолжит.
}
}
function rotateIfNeeded(): void {
if (!logPath) return
try {
if (!existsSync(logPath)) return
const size = statSync(logPath).size
if (size < ROTATE_AT_BYTES) return
if (existsSync(prevPath)) unlinkSync(prevPath)
renameSync(logPath, prevPath)
} catch {
// не критично — продолжим писать в latest.log с overflow
}
}
function ts(): string {
return new Date().toISOString()
}
function levelTag(l: Level): string {
return l.toUpperCase().padEnd(5, ' ')
}
function write(level: Level, msg: string, extra?: unknown): void {
// Always dup to console for dev. structuredClone-style serialize:
const line = `[${ts()}] ${levelTag(level)} ${msg}${
extra !== undefined ? ' ' + safeStringify(extra) : ''
}\n`
switch (level) {
case 'error':
console.error(line.trimEnd())
break
case 'warn':
console.warn(line.trimEnd())
break
case 'debug':
case 'info':
default:
console.log(line.trimEnd())
}
ensurePaths()
rotateIfNeeded()
if (!logPath) return
try {
appendFileSync(logPath, line, 'utf-8')
} catch {
// Если AV держит файл — переживём, в console уже залогировали.
}
}
function safeStringify(v: unknown): string {
if (v instanceof Error) {
return v.stack ?? `${v.name}: ${v.message}`
}
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
const DEBUG_ENABLED = process.env.LAUDE_DEBUG === '1'
export const log = {
debug: (msg: string, extra?: unknown): void => {
if (DEBUG_ENABLED) write('debug', msg, extra)
},
info: (msg: string, extra?: unknown): void => write('info', msg, extra),
warn: (msg: string, extra?: unknown): void => write('warn', msg, extra),
error: (msg: string, extra?: unknown): void => write('error', msg, extra)
}
/** Путь к логам (для диагностики). Возвращает пустую строку до initLogger(). */
export function getLogDir(): string {
return logDir
}

View File

@@ -21,6 +21,7 @@ import {
SAMPLE_EXERCISES, SAMPLE_EXERCISES,
Settings Settings
} from '@shared/types' } from '@shared/types'
import { log } from './logger'
/** /**
* Keep at most this many history entries (≈2.7 years at 10/day). * Keep at most this many history entries (≈2.7 years at 10/day).
@@ -89,12 +90,11 @@ function quarantineCorrupt(p: string, reason: string): void {
.replace(/Z$/, '') .replace(/Z$/, '')
const dest = `${p}.corrupt-${stamp}` const dest = `${p}.corrupt-${stamp}`
renameSync(p, dest) renameSync(p, dest)
console.error( log.error(
`[store] app-state.json was unreadable (${reason}); ` + `[store] app-state.json was unreadable (${reason}); moved to ${dest} and starting fresh.`
`moved to ${dest} and starting fresh.`
) )
} catch (e) { } catch (e) {
console.error('[store] failed to quarantine corrupt state file:', e) log.error('[store] failed to quarantine corrupt state file', e)
} }
} }
@@ -182,7 +182,7 @@ function load(): PersistedState {
try { try {
raw = readFileSync(p, 'utf-8') raw = readFileSync(p, 'utf-8')
} catch (e) { } catch (e) {
console.error('[store] cannot read state file:', e) log.error('[store] cannot read state file', e)
return makeInitial() // do not quarantine — we can't read it anyway return makeInitial() // do not quarantine — we can't read it anyway
} }
let parsed: unknown let parsed: unknown
@@ -266,7 +266,7 @@ async function atomicWrite(path: string, contents: string): Promise<void> {
await new Promise<void>((r) => setTimeout(r, delay)) await new Promise<void>((r) => setTimeout(r, delay))
} }
} }
console.error('[store] atomic write failed after retries:', lastErr) log.error('[store] atomic write failed after retries', lastErr)
} }
/** /**
@@ -298,7 +298,7 @@ function atomicWriteSync(path: string, contents: string): void {
} }
} }
} }
console.error('[store] atomic sync write failed after retries:', lastErr) log.error('[store] atomic sync write failed after retries', lastErr)
} }
async function flush(): Promise<void> { async function flush(): Promise<void> {

View File

@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { UpdaterStatus } from '@shared/types' import type { UpdaterStatus } from '@shared/types'
import { log } from './logger'
let currentStatus: UpdaterStatus = { kind: 'idle' } let currentStatus: UpdaterStatus = { kind: 'idle' }
let lastCheckedAt: number | undefined let lastCheckedAt: number | undefined
@@ -98,7 +99,7 @@ export function initUpdater(): void {
if (silentMode) { if (silentMode) {
// Background check failed — keep previous status, don't show red banner. // Background check failed — keep previous status, don't show red banner.
// Will retry on the next hourly tick. // Will retry on the next hourly tick.
console.warn('[updater] silent check failed:', message) log.warn('[updater] silent check failed', message)
return return
} }
setStatus({ kind: 'error', message }) setStatus({ kind: 'error', message })
@@ -148,7 +149,7 @@ export async function checkForUpdates(
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
if (silentMode) { if (silentMode) {
console.warn('[updater] silent check failed (sync):', message) log.warn('[updater] silent check failed (sync)', message)
} else { } else {
setStatus({ kind: 'error', message }) setStatus({ kind: 'error', message })
} }

View File

@@ -106,7 +106,12 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
webPreferences: { webPreferences: {
preload: preloadPath(), preload: preloadPath(),
sandbox: false, // sandbox: true — preload использует только contextBridge + ipcRenderer
// (оба sandbox-safe), никаких Node-built-ins (fs/path/child_process).
// Sandbox изолирует renderer от Chromium GPU/IPC процессов на уровне
// OS-сэндбокса; даже RCE через зависимости renderer'а не получит
// полного Node-доступа из preload.
sandbox: true,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
} }
@@ -171,7 +176,7 @@ export function createReminderWindow(): BrowserWindow {
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
webPreferences: { webPreferences: {
preload: preloadPath(), preload: preloadPath(),
sandbox: false, sandbox: true, // см. createMainWindow — preload не использует Node.
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
} }

View File

@@ -3,11 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; script-src 'self'" /> <!--
CSP: всё локально, без внешних origins. Шрифты подгружаются через
@fontsource/* импорты в globals.css. style-src 'unsafe-inline' нужен
для Tailwind utility-классов и инлайн-стилей framer-motion. font-src
включает data: на случай если кто-то вставит base64 SVG-glyph.
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" />
<title>Exercise Reminder</title> <title>Exercise Reminder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,3 +1,21 @@
/* Self-hosted шрифты — раньше тянулись с fonts.googleapis.com через <link>
в index.html. Минусы: внешняя зависимость (без интернета шрифты не
загружаются), CSP вынужден разрешать style-src https://fonts.googleapis.com
и font-src https://fonts.gstatic.com. Сейчас локальные .woff2 в bundle. */
@import '@fontsource/plus-jakarta-sans/400.css';
@import '@fontsource/plus-jakarta-sans/500.css';
@import '@fontsource/plus-jakarta-sans/600.css';
@import '@fontsource/plus-jakarta-sans/700.css';
@import '@fontsource/plus-jakarta-sans/800.css';
@import '@fontsource/bricolage-grotesque/500.css';
@import '@fontsource/bricolage-grotesque/600.css';
@import '@fontsource/bricolage-grotesque/700.css';
@import '@fontsource/bricolage-grotesque/800.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/600.css';
@import '@fontsource/jetbrains-mono/700.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;