Files
laude/src/main/games/dota2.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

294 lines
8.9 KiB
TypeScript

import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { app } from 'electron'
import type { GameProvider, ProviderEventHandler } from './provider'
import { findGameInstall } from './steam'
import { registerGsiRoute, getGsiBaseUrl, getGsiPort } from './gsi-server'
import {
ensureLaunchOption,
ensureLaunchOptionRemoved,
isLaunchOptionPresent,
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'
const CFG_NAME = 'gamestate_integration_exercise_reminder.cfg'
const ROUTE = '/dota2'
const LAUNCH_OPTION = '-gamestateintegration'
type DotaGsi = {
provider?: { name?: string }
auth?: { token?: string }
map?: {
game_state?: string
win_team?: 'radiant' | 'dire' | 'none'
game_time?: number
clock_time?: number
}
player?: {
kills?: number
deaths?: number
assists?: number
last_hits?: number
denies?: number
team_name?: 'radiant' | 'dire'
steamid?: string
}
}
/**
* Constant-time string equality. Avoids early-exit timing oracles that could
* leak the token byte-by-byte to a local attacker who can measure response
* latency on the loopback HTTP server. (Practical risk is tiny; correctness
* matters anyway.)
*/
function safeEqualStrings(a: string, b: string): boolean {
const A = Buffer.from(a, 'utf-8')
const B = Buffer.from(b, 'utf-8')
if (A.length !== B.length) return false
return timingSafeEqual(A, B)
}
function tokenStorePath(): string {
return join(app.getPath('userData'), 'dota2-gsi-token.txt')
}
function getOrCreateToken(): string {
const p = tokenStorePath()
if (existsSync(p)) {
const v = readFileSync(p, 'utf-8').trim()
if (v) return v
}
const token = randomBytes(16).toString('hex')
writeFileSync(p, token, 'utf-8')
return token
}
function cfgDir(installPath: string): string {
return join(installPath, 'game', 'dota', 'cfg', 'gamestate_integration')
}
function cfgPath(installPath: string): string {
return join(cfgDir(installPath), CFG_NAME)
}
function buildCfg(token: string): string {
const uri = `${getGsiBaseUrl()}${ROUTE}`
return `"Exercise Reminder Integration"
{
"uri" "${uri}"
"timeout" "5.0"
"buffer" "0.1"
"throttle" "0.5"
"heartbeat" "10.0"
"auth"
{
"token" "${token}"
}
"data"
{
"provider" "1"
"map" "1"
"player" "1"
"hero" "1"
"abilities" "0"
"items" "0"
"events" "0"
"buildings" "0"
"league" "0"
"draft" "0"
"wearables" "0"
}
}
`
}
export class Dota2Provider implements GameProvider {
readonly id: GameId = 'dota2'
readonly displayName = 'Dota 2'
private installPath: string | undefined
private unregister: (() => void) | undefined
private emit: ProviderEventHandler | undefined
private prevState: string | undefined
private latest: DotaGsi | undefined
private lastMatchEndAt = 0
private token: string = getOrCreateToken()
async detect(): Promise<GameStatus> {
const path = await findGameInstall(APP_ID, INSTALL_DIR)
this.installPath = path
const integrationActive = !!path && existsSync(cfgPath(path))
let launchOptionStatus: LaunchOptionStatus = 'not_needed'
let steamRunning: boolean | undefined
if (integrationActive) {
const present = await isLaunchOptionPresent(APP_ID, LAUNCH_OPTION)
if (present) launchOptionStatus = 'applied'
else {
steamRunning = await isSteamRunning()
// Either Steam is open (we can't write while it runs -> 'queued') or
// closed (apply on next ensureLaunchOption call -> still queued until
// the watcher tick actually writes). 'queued' is correct for both.
launchOptionStatus = 'queued'
}
}
return {
id: this.id,
name: this.displayName,
installed: !!path,
installPath: path,
integrationActive,
launchOption: LAUNCH_OPTION,
launchOptionStatus,
steamRunning,
enabled: false
}
}
async install(): Promise<void> {
if (!this.installPath) {
const status = await this.detect()
if (!status.installPath)
throw new Error('Dota 2 не найдена в Steam-библиотеках')
}
const dir = cfgDir(this.installPath!)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(cfgPath(this.installPath!), buildCfg(this.token), 'utf-8')
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
}
async uninstall(): Promise<void> {
await ensureLaunchOptionRemoved(APP_ID, LAUNCH_OPTION)
if (!this.installPath) return
const p = cfgPath(this.installPath)
if (existsSync(p)) unlinkSync(p)
}
async reconcile(): Promise<void> {
if (!this.installPath) await this.detect()
if (!this.installPath || !existsSync(cfgPath(this.installPath))) return
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
}
async start(emit: ProviderEventHandler): Promise<void> {
this.emit = emit
// Defensive double-register guard: free any previous registration first.
this.unregister?.()
this.unregister = registerGsiRoute(ROUTE, (payload) => {
// Runtime shape check — payload comes from a network socket.
if (typeof payload !== 'object' || payload === null) return
this.handle(payload as DotaGsi)
})
}
async stop(): Promise<void> {
this.unregister?.()
this.unregister = undefined
this.emit = undefined
this.prevState = undefined
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
// trying to fake a match-end event.
const incoming = g.auth?.token
if (
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
}
// Narrow the shape before spread-merging. A payload like `{player:"x"}`
// would otherwise let `{...this.latest?.player, ...g.player}` throw.
const playerObj =
typeof g.player === 'object' && g.player !== null ? g.player : undefined
const mapObj =
typeof g.map === 'object' && g.map !== null ? g.map : undefined
if (playerObj || mapObj) {
this.latest = {
...this.latest,
...g,
player: { ...this.latest?.player, ...playerObj },
map: { ...this.latest?.map, ...mapObj }
}
}
const state = mapObj?.game_state ?? this.latest?.map?.game_state
if (!state) return
const prev = this.prevState
this.prevState = state
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) {
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 ?? {}
const playerTeam = p.team_name
const winner = m.win_team
const won =
winner === 'radiant' || winner === 'dire'
? playerTeam === winner
: undefined
const durationMs = (m.game_time ?? m.clock_time ?? 0) * 1000
this.emit?.({
type: 'match_end',
payload: {
durationMs,
won,
stats: {
deaths: p.deaths ?? 0,
kills: p.kills ?? 0,
assists: p.assists ?? 0,
last_hits: p.last_hits ?? 0,
denies: p.denies ?? 0,
duration_min: Math.floor(durationMs / 60_000)
}
}
})
// Reset stale state so the NEXT match starts from a clean slate even if
// the user re-enters the same lobby or Dota's GSI restarts mid-session.
this.latest = undefined
this.prevState = undefined
}
}
}
export function dota2DebugInfo(): { port: number; route: string } {
return { port: getGsiPort(), route: ROUTE }
}