Files
laude/src/main/games/registry.ts
AnRil 34fb03b265 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).
2026-05-22 01:24:30 +07:00

180 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BrowserWindow } from 'electron'
import type { GameProvider, MatchEndPayload } from './provider'
import { Dota2Provider } from './dota2'
import { startGsiServer, stopGsiServer } from './gsi-server'
import { onLaunchOptionsApplied } from './steam-launch-options'
import { IPC } from '@shared/ipc'
import type {
ChallengeResult,
GameId,
GameStatus,
MatchSummary
} from '@shared/types'
import { STAT_LABELS } from '@shared/types'
import { getChallenges, getGamesEnabled } from '../store'
import { fireMatchSummary } from '../notifications'
import { log } from '../logger'
const providers: Record<GameId, GameProvider> = {
dota2: new Dota2Provider()
}
let running = false
async function onMatchEnd(
gameId: GameId,
payload: MatchEndPayload
): Promise<void> {
const provider = providers[gameId]
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 enabledChallenges) {
const statValue = payload.stats[ch.stat] ?? 0
const reps = Math.round(statValue * ch.multiplier)
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,
icon: ch.icon,
exerciseName: ch.exerciseName,
reps,
statValue,
statLabel: STAT_LABELS[ch.stat],
stat: ch.stat
})
}
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,
gameName: provider.displayName,
durationMs: payload.durationMs,
won: payload.won,
results
}
fireMatchSummary(summary)
}
export async function startGamesRegistry(): Promise<void> {
if (running) return
running = true
try {
await startGsiServer()
log.info('[games] GSI server started on port 4701')
} catch (err) {
log.error('[games] GSI server failed to start', err)
return
}
onLaunchOptionsApplied(() => {
// When Steam closed and we flushed queued ops, refresh statuses.
void listGamesStatus().then((games) => broadcastGames(games))
})
const enabled = getGamesEnabled()
for (const id of Object.keys(providers) as GameId[]) {
const provider = providers[id]
// Reconcile launch options on boot (handles cases where Steam
// overwrote our flag, or user removed it manually).
try {
await provider.reconcile?.()
} catch (err) {
log.error(`[games] reconcile failed for ${id}`, err)
}
if (!enabled[id]) continue
await provider.start((e) => {
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
})
}
}
export async function stopGamesRegistry(): Promise<void> {
if (!running) return
running = false
for (const id of Object.keys(providers) as GameId[]) {
await providers[id].stop()
}
stopGsiServer()
}
export async function listGamesStatus(): Promise<GameStatus[]> {
const enabled = getGamesEnabled()
const out: GameStatus[] = []
for (const id of Object.keys(providers) as GameId[]) {
const s = await providers[id].detect()
out.push({ ...s, enabled: !!enabled[id] })
}
return out
}
export async function installGame(id: GameId): Promise<GameStatus> {
const provider = providers[id]
await provider.install()
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
}
export async function uninstallGame(id: GameId): Promise<GameStatus> {
const provider = providers[id]
await provider.uninstall()
await provider.stop()
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
}
export async function toggleGame(id: GameId, enabled: boolean): Promise<void> {
const provider = providers[id]
if (enabled) {
await provider.start((e) => {
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
})
} else {
await provider.stop()
}
}
export function broadcastGames(games: GameStatus[]): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtGamesChanged, games)
}
}
// Simulate a match-end for debugging (called from IPC in dev).
export function simulateMatchEnd(
id: GameId,
stats: Partial<Record<string, number>>
): void {
void onMatchEnd(id, {
durationMs: (stats.duration_min ?? 35) * 60_000,
won: stats.won === 1,
stats: stats as Partial<Record<import('@shared/types').GameStat, number>>
})
}
export function getProviders(): Record<GameId, GameProvider> {
return providers
}