#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).
294 lines
8.9 KiB
TypeScript
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 }
|
|
}
|