import { exec } from 'node:child_process' import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { promisify } from 'node:util' import { parseVdf, stringifyVdf, type VdfNode } from './vdf' import { getSteamPath } from './steam' const execAsync = promisify(exec) export async function isSteamRunning(): Promise { try { const { stdout } = await execAsync( 'tasklist /FI "IMAGENAME eq steam.exe" /FO CSV /NH', { windowsHide: true } ) return /steam\.exe/i.test(stdout) } catch { return false } } async function getLocalConfigPaths(): Promise { const steamPath = await getSteamPath() if (!steamPath) return [] const userdataDir = join(steamPath, 'userdata') if (!existsSync(userdataDir)) return [] let entries: string[] = [] try { entries = readdirSync(userdataDir) } catch { return [] } const paths: string[] = [] for (const entry of entries) { if (!/^\d+$/.test(entry) || entry === '0') continue const p = join(userdataDir, entry, 'config', 'localconfig.vdf') if (existsSync(p)) paths.push(p) } return paths } function findKey(node: VdfNode, target: string): string | undefined { const lower = target.toLowerCase() for (const k of Object.keys(node)) { if (k.toLowerCase() === lower) return k } return undefined } function findCaseInsensitive( node: VdfNode, ...keys: string[] ): VdfNode | undefined { let cur: VdfNode = node for (const key of keys) { const found: string | undefined = findKey(cur, key) if (!found) return undefined const value: string | VdfNode = cur[found] if (typeof value !== 'object') return undefined cur = value } return cur } function findOrCreatePath(node: VdfNode, ...keys: string[]): VdfNode { let cur: VdfNode = node for (const key of keys) { const found: string | undefined = findKey(cur, key) if (found && typeof cur[found] === 'object') { cur = cur[found] as VdfNode } else { if (found) delete cur[found] cur[key] = {} cur = cur[key] as VdfNode } } return cur } function getAppNode( parsed: VdfNode, appId: string, create: boolean ): VdfNode | undefined { if (create) { const apps = findOrCreatePath( parsed, 'UserLocalConfigStore', 'Software', 'Valve', 'Steam', 'apps' ) if (typeof apps[appId] !== 'object') apps[appId] = {} return apps[appId] as VdfNode } const apps = findCaseInsensitive( parsed, 'UserLocalConfigStore', 'Software', 'Valve', 'Steam', 'apps' ) if (!apps) return undefined const v = apps[appId] return typeof v === 'object' ? v : undefined } function writeBackup(path: string): void { try { const bak = path + '.exr.bak' if (!existsSync(bak)) copyFileSync(path, bak) } catch { // best-effort } } async function atomicWrite(path: string, contents: string): Promise { // Write to temp then rename (atomic on Windows for same directory). Retry a // few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes // hold a handle briefly during a Steam config rewrite). // // Раньше тут был busy-loop sleep — Steam-конфиги пишутся редко, но из main // process, и при попадании на занятый файл (Steam ещё держит handle) морозили // весь UI на 250мс. Заменили на async setTimeout-sleep. const tmp = path + '.exr.tmp' const delays = [0, 50, 200] let lastErr: unknown for (const delay of delays) { if (delay > 0) await new Promise((r) => setTimeout(r, delay)) try { writeFileSync(tmp, contents, 'utf-8') renameSync(tmp, path) return } catch (e) { lastErr = e } } throw lastErr } async function modifyLaunchOptions( configPath: string, appId: string, fn: (current: string) => string | null ): Promise { let raw: string try { raw = readFileSync(configPath, 'utf-8') } catch { return false } let parsed: VdfNode try { parsed = parseVdf(raw) } catch { return false } // Read existing const app = getAppNode(parsed, appId, false) const loKey = app ? Object.keys(app).find((k) => k.toLowerCase() === 'launchoptions') : undefined const existing = app && loKey ? String(app[loKey] ?? '') : '' const next = fn(existing) if (next === null) return false if (next === existing) return true const targetApp = getAppNode(parsed, appId, true)! // Remove any existing variant of the key to keep canonical name. for (const k of Object.keys(targetApp)) { if (k.toLowerCase() === 'launchoptions') delete targetApp[k] } if (next.length > 0) { targetApp['LaunchOptions'] = next } writeBackup(configPath) try { await atomicWrite(configPath, stringifyVdf(parsed)) } catch { return false } return true } export async function isLaunchOptionPresent( appId: string, option: string ): Promise { const paths = await getLocalConfigPaths() for (const p of paths) { try { const raw = readFileSync(p, 'utf-8') const parsed = parseVdf(raw) const app = getAppNode(parsed, appId, false) if (!app) continue const loKey = Object.keys(app).find( (k) => k.toLowerCase() === 'launchoptions' ) if (!loKey) continue const value = String(app[loKey] ?? '') if (value.includes(option)) return true } catch { // ignore } } return false } async function applyOptionToAllConfigs( appId: string, option: string ): Promise { const paths = await getLocalConfigPaths() for (const p of paths) { await modifyLaunchOptions(p, appId, (current) => { if (current.includes(option)) return current return current.length > 0 ? `${current} ${option}` : option }) } } async function removeOptionFromAllConfigs( appId: string, option: string ): Promise { const paths = await getLocalConfigPaths() for (const p of paths) { await modifyLaunchOptions(p, appId, (current) => { if (!current.includes(option)) return current return current .split(/\s+/) .filter((tok) => tok !== option) .join(' ') .trim() }) } } // Pending operations queue with a watcher that polls Steam and applies on exit. type PendingOp = { appId: string option: string op: 'add' | 'remove' } const pending: PendingOp[] = [] let watchTimer: NodeJS.Timeout | null = null let onAppliedCallback: (() => void) | null = null export function onLaunchOptionsApplied(cb: () => void): void { onAppliedCallback = cb } export function hasPendingOperations(): boolean { return pending.length > 0 } function dedupePush(op: PendingOp): void { // Remove conflicting entries (opposite op for same option) before adding. for (let i = pending.length - 1; i >= 0; i--) { if (pending[i].appId === op.appId && pending[i].option === op.option) { pending.splice(i, 1) } } pending.push(op) } function ensureWatcher(): void { if (watchTimer || pending.length === 0) return watchTimer = setInterval(() => { void tick() }, 5_000) } function stopWatcher(): void { if (watchTimer) { clearInterval(watchTimer) watchTimer = null } } async function tick(): Promise { if (pending.length === 0) { stopWatcher() return } if (await isSteamRunning()) return const toApply = [...pending] pending.length = 0 for (const op of toApply) { if (op.op === 'add') await applyOptionToAllConfigs(op.appId, op.option) else await removeOptionFromAllConfigs(op.appId, op.option) } stopWatcher() onAppliedCallback?.() } export type EnsureResult = 'applied' | 'queued' | 'no_user' export async function ensureLaunchOption( appId: string, option: string ): Promise { if (await isLaunchOptionPresent(appId, option)) return 'applied' const paths = await getLocalConfigPaths() if (paths.length === 0) return 'no_user' if (await isSteamRunning()) { dedupePush({ appId, option, op: 'add' }) ensureWatcher() return 'queued' } await applyOptionToAllConfigs(appId, option) return (await isLaunchOptionPresent(appId, option)) ? 'applied' : 'queued' } export async function ensureLaunchOptionRemoved( appId: string, option: string ): Promise { if (!(await isLaunchOptionPresent(appId, option))) return 'applied' if (await isSteamRunning()) { dedupePush({ appId, option, op: 'remove' }) ensureWatcher() return 'queued' } await removeOptionFromAllConfigs(appId, option) return (await isLaunchOptionPresent(appId, option)) ? 'queued' : 'applied' }