Files
laude/src/main/games/steam-launch-options.ts
AnRil 4745f5e091 perf+fix: sprint B — async I/O, before-quit, immutable getState, lucide tree-shake
#2  atomicWrite spin-loop → async setTimeout. Раньше при retry на
    EBUSY/EPERM (антивирус, OneDrive) main process замораживался на
    50/200/800ms × до 3 итераций ≈ секунда залипания UI. Сейчас async
    sleep — event-loop живёт. Сохранён atomicWriteSync для flushNow
    (вызывается из before-quit когда event-loop уже умирает).
    Аналогичный фикс в games/steam-launch-options.ts.
#5  before-quit теперь дожидается stopGamesRegistry через
    e.preventDefault() + app.exit(0). Раньше GSI HTTP server не успевал
    closeAllConnections до exit, и следующий запуск получал
    EADDRINUSE на port 4701 (TIME_WAIT) — GSI молча не работал.
#10 IPC.getState возвращает поверхностную копию settings вместо мутации
    кэша. Раньше startWithWindows писалось напрямую в state.settings,
    разъезжаясь с persisted-disk-значением до следующего mutation.
#19 lib/icon.tsx: `import * as Lucide` (wildcard, ~500KB в bundle,
    1500+ иконок) → explicit named imports + ICON_MAP. В bundle
    остаются только 18 ICON_CHOICES.
2026-05-22 01:15:31 +07:00

341 lines
8.6 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 { 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<boolean> {
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<string[]> {
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<void> {
// 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<void>((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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<EnsureResult> {
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<EnsureResult> {
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'
}