diff --git a/package-lock.json b/package-lock.json index 520a54e..b8d79e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "laude", - "version": "0.5.1", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "laude", - "version": "0.5.1", + "version": "0.5.4", "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", "framer-motion": "^11.11.17", "lucide-react": "^0.460.0", @@ -1264,6 +1267,33 @@ "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", diff --git a/package.json b/package.json index 041f343..df7d6e5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1" }, "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", "framer-motion": "^11.11.17", "lucide-react": "^0.460.0", diff --git a/src/main/games/dota2.ts b/src/main/games/dota2.ts index 7f7f8d7..5617ed7 100644 --- a/src/main/games/dota2.ts +++ b/src/main/games/dota2.ts @@ -18,6 +18,7 @@ import { isSteamRunning } from './steam-launch-options' import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types' +import { log } from '../logger' const APP_ID = '570' const INSTALL_DIR = 'dota 2 beta' @@ -198,6 +199,8 @@ export class Dota2Provider implements GameProvider { this.latest = undefined } + private rejectedTokenLogged = false + private handle(g: DotaGsi): void { // Verify the per-install token. Dota always sends auth.token; anything // 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' || !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 } @@ -235,8 +247,12 @@ export class Dota2Provider implements GameProvider { if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') { // De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open. 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 + log.info('[dota2] POST_GAME detected, emitting match_end event') const p = this.latest?.player ?? {} const m = this.latest?.map ?? {} diff --git a/src/main/games/gsi-server.ts b/src/main/games/gsi-server.ts index df12286..699332b 100644 --- a/src/main/games/gsi-server.ts +++ b/src/main/games/gsi-server.ts @@ -4,6 +4,7 @@ import { type Server, type ServerResponse } from 'node:http' +import { log } from '../logger' export type GsiHandler = ( payload: unknown, @@ -87,7 +88,7 @@ async function onRequest( payload = text.length > 0 ? JSON.parse(text) : {} } catch (err) { // 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.end() return @@ -99,7 +100,7 @@ async function onRequest( res.setHeader('Content-Type', 'text/plain') res.end('ok') } catch (err) { - console.error('[gsi] handler threw:', err) + log.error('[gsi] handler threw', err) res.statusCode = 500 res.end() } diff --git a/src/main/games/registry.ts b/src/main/games/registry.ts index 9328c80..6c624e3 100644 --- a/src/main/games/registry.ts +++ b/src/main/games/registry.ts @@ -13,6 +13,7 @@ import type { import { STAT_LABELS } from '@shared/types' import { getChallenges, getGamesEnabled } from '../store' import { fireMatchSummary } from '../notifications' +import { log } from '../logger' const providers: Record = { dota2: new Dota2Provider() @@ -25,14 +26,23 @@ async function onMatchEnd( payload: MatchEndPayload ): Promise { const provider = providers[gameId] - const challenges = getChallenges().filter( - (c) => c.gameId === gameId && c.enabled + const allChallenges = getChallenges().filter((c) => c.gameId === gameId) + 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[] = [] - for (const ch of challenges) { + for (const ch of enabledChallenges) { const statValue = payload.stats[ch.stat] ?? 0 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({ challengeId: ch.id, name: ch.name, @@ -44,7 +54,21 @@ async function onMatchEnd( 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 = { gameId, @@ -61,8 +85,9 @@ export async function startGamesRegistry(): Promise { running = true try { await startGsiServer() + log.info('[games] GSI server started on port 4701') } catch (err) { - console.error('GSI server failed to start:', err) + log.error('[games] GSI server failed to start', err) return } @@ -79,7 +104,7 @@ export async function startGamesRegistry(): Promise { try { await provider.reconcile?.() } catch (err) { - console.error('reconcile failed for', id, err) + log.error(`[games] reconcile failed for ${id}`, err) } if (!enabled[id]) continue await provider.start((e) => { diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 0000000..6f7b2be --- /dev/null +++ b/src/main/logger.ts @@ -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 +} diff --git a/src/main/store.ts b/src/main/store.ts index 7bb791f..3f37ff4 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -21,6 +21,7 @@ import { SAMPLE_EXERCISES, Settings } from '@shared/types' +import { log } from './logger' /** * 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$/, '') const dest = `${p}.corrupt-${stamp}` renameSync(p, dest) - console.error( - `[store] app-state.json was unreadable (${reason}); ` + - `moved to ${dest} and starting fresh.` + log.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) + log.error('[store] failed to quarantine corrupt state file', e) } } @@ -182,7 +182,7 @@ function load(): PersistedState { try { raw = readFileSync(p, 'utf-8') } 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 } let parsed: unknown @@ -266,7 +266,7 @@ async function atomicWrite(path: string, contents: string): Promise { await new Promise((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 { diff --git a/src/main/updater.ts b/src/main/updater.ts index 057d979..be26c30 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron' import { autoUpdater } from 'electron-updater' import { IPC } from '@shared/ipc' import type { UpdaterStatus } from '@shared/types' +import { log } from './logger' let currentStatus: UpdaterStatus = { kind: 'idle' } let lastCheckedAt: number | undefined @@ -98,7 +99,7 @@ export function initUpdater(): void { if (silentMode) { // Background check failed — keep previous status, don't show red banner. // Will retry on the next hourly tick. - console.warn('[updater] silent check failed:', message) + log.warn('[updater] silent check failed', message) return } setStatus({ kind: 'error', message }) @@ -148,7 +149,7 @@ export async function checkForUpdates( } catch (err) { const message = err instanceof Error ? err.message : String(err) if (silentMode) { - console.warn('[updater] silent check failed (sync):', message) + log.warn('[updater] silent check failed (sync)', message) } else { setStatus({ kind: 'error', message }) } diff --git a/src/main/windows.ts b/src/main/windows.ts index 69e7775..d2c69eb 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -106,7 +106,12 @@ export function createMainWindow(showImmediately = true): BrowserWindow { ...(icon ? { icon } : {}), webPreferences: { 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, nodeIntegration: false } @@ -171,7 +176,7 @@ export function createReminderWindow(): BrowserWindow { ...(icon ? { icon } : {}), webPreferences: { preload: preloadPath(), - sandbox: false, + sandbox: true, // см. createMainWindow — preload не использует Node. contextIsolation: true, nodeIntegration: false } diff --git a/src/renderer/index.html b/src/renderer/index.html index 3ba3d25..79ffb89 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,11 +3,14 @@ - + + Exercise Reminder - - -
diff --git a/src/renderer/src/styles/globals.css b/src/renderer/src/styles/globals.css index 03a0ba4..b53818b 100644 --- a/src/renderer/src/styles/globals.css +++ b/src/renderer/src/styles/globals.css @@ -1,3 +1,21 @@ +/* Self-hosted шрифты — раньше тянулись с fonts.googleapis.com через + в 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 components; @tailwind utilities;