Initial commit
This commit is contained in:
310
src/main/games/steam-launch-options.ts
Normal file
310
src/main/games/steam-launch-options.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, contents: string): void {
|
||||
// Write to temp then rename (atomic on Windows for same directory).
|
||||
const tmp = path + '.exr.tmp'
|
||||
writeFileSync(tmp, contents, 'utf-8')
|
||||
// fs.renameSync replaces destination atomically on Windows
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('node:fs') as typeof import('node:fs')
|
||||
fs.renameSync(tmp, path)
|
||||
}
|
||||
|
||||
function modifyLaunchOptions(
|
||||
configPath: string,
|
||||
appId: string,
|
||||
fn: (current: string) => string | null
|
||||
): 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 {
|
||||
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) {
|
||||
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) {
|
||||
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'
|
||||
}
|
||||
Reference in New Issue
Block a user