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:
125
src/main/logger.ts
Normal file
125
src/main/logger.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user