Initial commit

This commit is contained in:
AnRil
2026-05-16 13:43:29 +07:00
commit 688a86b611
208 changed files with 44350 additions and 0 deletions

View 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'
}