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 { 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 { 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 { await ensureLaunchOptionRemoved(APP_ID, LAUNCH_OPTION) if (!this.installPath) return const p = cfgPath(this.installPath) if (existsSync(p)) unlinkSync(p) } async reconcile(): Promise { 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 { 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 { 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 } }