const DEFAULT_WRAPPER_PATH = "~/.lsfg"; const COMMAND_TOKEN = "%command%"; export const LEGACY_WRAPPER_TOKENS = new Set([ "~/lsfg", "~/.local/bin/lsfg", "~/.local/bin/lsfg-vk-experimental", "~/.local/bin/mako-run", "mako-run", "~/.local/bin/mako-launch", "mako-launch", ]); const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/; const MANAGED_ENV_KEYS = new Set([ "ENABLE_GAMESCOPE_WSI", "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck", "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE", "__GLX_VENDOR_LIBRARY_NAME", "GALLIUM_DRIVER", "DXVK_FRAME_RATE", ]); const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i; interface LaunchToken { raw: string; value: string; } export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; details: SteamAppDetails; } export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; commandTokenAdded: boolean; changed: boolean; } function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } function apps(): Partial | undefined { return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial } }).SteamClient?.Apps; } function validateAppId(appId: number): void { if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } function timer() { const host = typeof window !== "undefined" ? window : globalThis; return { set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number, clear: (id: number) => host.clearTimeout(id), }; } function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { return { appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", details, }; } function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void { validateAppId(appId); const register = apps()?.RegisterForAppDetails; if (!register) throw new Error("Steam app-details API is unavailable"); let active = true; let registration: SteamAppDetailsRegistration | undefined; const unsubscribe = () => { active = false; try { registration?.unregister(); } catch {} }; registration = register.call(apps(), appId, (details) => { if (active && onDetails(details || {}) === false) unsubscribe(); }); if (!active) unsubscribe(); return unsubscribe; } export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise { return new Promise((resolve, reject) => { let done = false; let unsubscribe = () => {}; const clock = timer(); const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000); const finish = (error?: unknown, details?: SteamAppDetails) => { if (done) return; done = true; clock.clear(timeout); unsubscribe(); if (error) reject(asError(error)); else resolve(snapshot(appId, nonSteam, details || {})); }; try { unsubscribe = registerDetails(appId, (details) => { finish(undefined, details); return false; }); } catch (error) { finish(error); } }); } export function subscribeSteamLaunchOptions( appId: number, nonSteam: boolean, onSnapshot: (value: SteamLaunchOptionsSnapshot) => void, onError: (error: Error) => void, ): () => void { return registerDetails(appId, (details) => { try { onSnapshot(snapshot(appId, nonSteam, details)); } catch (error) { onError(asError(error)); } }); } function decodeToken(raw: string): string { let value = ""; let quote: "'" | '"' | null = null; for (let i = 0; i < raw.length; i++) { const c = raw[i]; if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i]; else if (quote) { if (c === quote) quote = null; else value += c; } else if (c === "'" || c === '"') quote = c; else value += c; } return value; } function tokenize(options: string): LaunchToken[] { const tokens: LaunchToken[] = []; let start = -1; let quote: "'" | '"' | null = null; let escaped = false; const push = (end: number) => { if (start < 0) return; const raw = options.slice(start, end); tokens.push({ raw, value: decodeToken(raw) }); start = -1; }; for (let i = 0; i < options.length; i++) { const c = options[i]; if (start < 0) { if (/\s/.test(c)) continue; start = i; } if (escaped) escaped = false; else if (c === "\\" && quote !== "'") escaped = true; else if (quote) { if (c === quote) quote = null; } else if (c === "'" || c === '"') quote = c; else if (/\s/.test(c)) push(i); } push(options.length); return tokens; } const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); function isCommandToken(token: LaunchToken): boolean { return token.value.toLowerCase() === COMMAND_TOKEN; } function isMalformedCommandToken(token: LaunchToken): boolean { const value = token.value.toLowerCase(); return value === "%command" || value === "command%"; } function normalizeCommandTokens(tokens: LaunchToken[]): void { for (const token of tokens) { if (isCommandToken(token) || isMalformedCommandToken(token)) { token.raw = COMMAND_TOKEN; token.value = COMMAND_TOKEN; } } } const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex(isCommandToken); const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean { const command = commandIndex(tokens); const prefixEnd = command >= 0 ? command : tokens.length; const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value)); if (kept.length === tokens.length) return false; tokens.splice(0, tokens.length, ...kept); return true; } function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, ) { const tokens = tokenize(options); normalizeCommandTokens(tokens); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); if (command >= 0) { if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false }; removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath); command = commandIndex(tokens); tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath }); return { options: serialize(tokens), commandTokenAdded: false }; } const existingWrapper = tokens.findIndex((token) => decodeToken(token.value) === wrapperPath); if (existingWrapper >= 0) { tokens.splice(existingWrapper + 1, 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); return { options: serialize(tokens), commandTokenAdded: true }; } let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; tokens.splice(insertion, 0, { raw: wrapperPath, value: wrapperPath }, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, ); return { options: serialize(tokens), commandTokenAdded: true }; } export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { return installLaunchOption(options, wrapperPath); } export function removeWrapperLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, commandTokenAdded = false, ): string { const tokens = tokenize(options); normalizeCommandTokens(tokens); if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) { const command = commandIndex(tokens); if (command >= 0) tokens.splice(command, 1); } return serialize(tokens); } function encodeAssignmentValue(value: string): string { return /^[A-Za-z0-9_./:+,%=-]+$/.test(value) ? value : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } export function cleanupPluginAssignments(options: string): string { const tokens = tokenize(options); const command = commandIndex(tokens); const prefixEnd = command >= 0 ? command : tokens.length; return serialize(tokens.flatMap((token, i) => { if (i >= prefixEnd || !isAssignment(token)) return [token]; const split = token.value.indexOf("="); const key = token.value.slice(0, split); if (key === "DXVK_CONFIG") { const value = token.value.slice(split + 1).split(";").map((part) => part.trim()) .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; "); return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : []; } return MANAGED_ENV_KEYS.has(key) ? [] : [token]; })); } export function cleanupLegacyLaunchOptions(options: string): string { const tokens = tokenize(options); removeMatchingWrappers(tokens, isLegacyToken); return serialize(tokens); } export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) => cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath)); export const cleanupLegacyWrapper = cleanupPluginLaunchOptions; export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { const tokens = tokenize(options); const command = commandIndex(tokens); return command > 0 && tokens[command - 1].value === wrapperPath; } export function isWrapperIntegrationInstalled( steam: SteamLaunchOptionsSnapshot, _nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, ): boolean { return hasWrapperLaunchIntegration(steam.options, wrapperPath); } const queues = new Map>(); function queued(appId: number, nonSteam: boolean, operation: () => Promise): Promise { const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; const previous = queues.get(key) || Promise.resolve(); const current = previous.catch(() => undefined).then(operation); const cleanup = current.then( () => { if (queues.get(key) === cleanup) queues.delete(key); }, () => { if (queues.get(key) === cleanup) queues.delete(key); }, ); queues.set(key, cleanup); return current; } async function waitFor( appId: number, nonSteam: boolean, matches: (value: SteamLaunchOptionsSnapshot) => boolean, message: string, ): Promise { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { const value = await readSteamLaunchOptions(appId, nonSteam); if (matches(value)) return value; } catch (error) { lastError = asError(error); } if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100)); } throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`); } async function writeVerified( appId: number, nonSteam: boolean, previous: string, next: string, write: (value: string) => Promise, message: string, ): Promise { try { await write(next); return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message); } catch (error) { const failure = asError(error); try { await write(previous); await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options"); } catch (rollback) { throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); } throw failure; } } function writeOptions(appId: number, nonSteam: boolean, value: string): Promise { const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions; if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`)); return Promise.resolve(setter.call(apps(), appId, value)); } export function updateSteamLaunchOptions( appId: number, nonSteam: boolean, transform: (options: string) => string, ): Promise { return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, (value) => writeOptions(appId, nonSteam, value), "Steam did not accept the launch options", ); }); } export function installWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, ): Promise { return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); const rewrite = installLaunchOption(cleaned, wrapperPath); if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false }; const value = await writeVerified( appId, nonSteam, current.options, rewrite.options, (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded, changed: true, }; }); } export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, ): Promise { return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, (options) => writeOptions(appId, nonSteam, options), "Steam did not clean the launch options", ); }); } export const cleanupLegacySteamLaunchOptions = ( appId: number, nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, ) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath)); export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH;