#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.
341 lines
8.6 KiB
TypeScript
341 lines
8.6 KiB
TypeScript
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'
|
||
}
|