diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 01:01:42 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 01:01:42 -0400 |
| commit | 1b932fd69c3dba925e0cbf027e05508b2daf5e8c (patch) | |
| tree | f7f105e3e6c8f702656a4ed9727ffd3569433a98 /src/utils/steamLaunchOptions.ts | |
| parent | 790132668c4421c68c32bdc8fc9792b0d6028f97 (diff) | |
| download | decky-lsfg-vk-1b932fd69c3dba925e0cbf027e05508b2daf5e8c.tar.gz decky-lsfg-vk-1b932fd69c3dba925e0cbf027e05508b2daf5e8c.zip | |
add back launcher script and correct pathing
Diffstat (limited to 'src/utils/steamLaunchOptions.ts')
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 472 |
1 files changed, 422 insertions, 50 deletions
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 82ff94b..e00b32d 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,16 +1,52 @@ -// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyLaunchOptions, cleanupPluginLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +const DEFAULT_WRAPPER_PATH = "~/.lsfg"; +const COMMAND_TOKEN = "%command%"; -// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -export * from "./steamLaunchOptionParser.ts"; +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; + target: string; details: SteamAppDetails; } +export interface WrapperIntegrationResult { + snapshot: SteamLaunchOptionsSnapshot; + originalExecutable?: string; + commandTokenAdded: boolean; +} + function validateAppId(appId: number): void { if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } @@ -21,14 +57,30 @@ function getSteamApps(): Partial<SteamApps> | undefined { }).SteamClient?.Apps; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { - if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) { - throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first"); +interface TimerHost { + setTimeout(handler: () => void, timeout: number): number; + clearTimeout(timeout: number): void; +} + +function timerHost(): TimerHost { + if (typeof window !== "undefined") { + return { + setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), + clearTimeout: (timeout) => window.clearTimeout(timeout), + }; } return { + setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, + clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + }; +} + +function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { + return { appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", + target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -44,7 +96,7 @@ function registerSteamAppDetails( validateAppId(appId); const apps = getSteamApps(); const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable"); + if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); let active = true; let unregisterPending = false; @@ -58,7 +110,7 @@ function registerSteamAppDetails( try { registration.unregister(); } catch { - // Steam may invalidate registrations during a details refresh. + // Steam can invalidate a registration while details are refreshing. } }; @@ -71,7 +123,7 @@ function registerSteamAppDetails( try { registration.unregister(); } catch { - // The registration can be invalidated before a synchronous callback returns. + // A synchronous callback can invalidate the registration before return. } } } catch (error) { @@ -83,25 +135,21 @@ function registerSteamAppDetails( export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> { return new Promise((resolve, reject) => { let settled = false; - let timeout = 0; + let timeout: number | undefined; let unsubscribe = () => {}; const finish = (error?: unknown, details?: SteamAppDetails) => { if (settled) return; settled = true; - window.clearTimeout(timeout); + if (timeout !== undefined) timerHost().clearTimeout(timeout); unsubscribe(); if (error) { reject(asError(error)); return; } - try { - resolve(snapshotFromDetails(appId, nonSteam, details || {})); - } catch (snapshotError) { - reject(asError(snapshotError)); - } + resolve(snapshotFromDetails(appId, nonSteam, details || {})); }; - timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000); + timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { unsubscribe = registerSteamAppDetails(appId, (details) => { finish(undefined, details); @@ -128,6 +176,224 @@ export function subscribeSteamLaunchOptions( }); } +function decodeToken(raw: string): string { + let value = ""; + let quote: "'" | '"' | null = null; + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === "\\" && quote !== "'" && index + 1 < raw.length) { + value += raw[index + 1]; + index += 1; + } else if (quote !== null) { + if (character === quote) quote = null; + else value += character; + } else if (character === "'" || character === '"') { + quote = character; + } else { + value += character; + } + } + return value; +} + +function tokenize(options: string): LaunchToken[] { + const tokens: LaunchToken[] = []; + let start = -1; + let quote: "'" | '"' | null = null; + let escaped = false; + for (let index = 0; index < options.length; index += 1) { + const character = options[index]; + if (start < 0) { + if (/\s/.test(character)) continue; + start = index; + } + if (escaped) escaped = false; + else if (character === "\\" && quote !== "'") escaped = true; + else if (quote !== null) { + if (character === quote) quote = null; + } else if (character === "'" || character === '"') quote = character; + else if (/\s/.test(character)) { + const raw = options.slice(start, index); + tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + } + } + if (start >= 0) { + const raw = options.slice(start); + tokens.push({ raw, value: decodeToken(raw) }); + } + return tokens; +} + +function serialize(tokens: readonly LaunchToken[]): string { + return tokens.map((token) => token.raw).join(" "); +} + +export function normalizeLaunchOptions(options: string): string { + return serialize(tokenize(options)); +} + +function isCommandToken(token: LaunchToken): boolean { + return token.raw.toLowerCase() === COMMAND_TOKEN; +} + +function commandIndex(tokens: readonly LaunchToken[]): number { + return tokens.findIndex(isCommandToken); +} + +function isAssignment(token: LaunchToken): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); +} + +function isLegacyToken(value: string): boolean { + return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); +} + +export function isLegacyWrapperToken(value: string): boolean { + return isLegacyToken(decodeToken(value)); +} + +function isWrapperToken(value: string, wrapperPath: string): boolean { + return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); +} + +function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean { + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function removeLegacyTokens(tokens: LaunchToken[]): boolean { + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function leadingAssignments(tokens: readonly LaunchToken[]): number { + let count = 0; + while (count < tokens.length && isAssignment(tokens[count])) count += 1; + return count; +} + +function wrapperToken(wrapperPath: string): LaunchToken { + return { raw: wrapperPath, value: wrapperPath }; +} + +export interface LaunchOptionRewrite { + options: string; + commandTokenAdded: boolean; +} + +/** Add one exact wrapper token immediately before Steam's command macro. */ +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite { + const tokens = tokenize(options); + removeLegacyTokens(tokens); + let index = commandIndex(tokens); + if (index >= 0) { + const currentWrapper = tokens[index - 1]; + if (currentWrapper && currentWrapper.value === wrapperPath) { + return { options: serialize(tokens), commandTokenAdded: false }; + } + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath); + tokens.splice(0, tokens.length, ...retained); + index = commandIndex(tokens); + tokens.splice(index, 0, wrapperToken(wrapperPath)); + return { options: serialize(tokens), commandTokenAdded: false }; + } + + const insertion = leadingAssignments(tokens); + const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); + if (tokens.length !== insertion && !argumentsOnly) { + throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); + } + tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + return { options: serialize(tokens), commandTokenAdded: true }; +} + +/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */ +export function removeWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + commandTokenAdded = false, +): string { + const tokens = tokenize(options); + const removed = removeWrapperTokens(tokens, wrapperPath); + if (removed && commandTokenAdded) { + const index = commandIndex(tokens); + if (index >= 0) tokens.splice(index, 1); + } + return serialize(tokens); +} + +function encodeAssignmentValue(value: string): string { + if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function cleanDxvkConfigValue(value: string): string | null { + const retained = value + .split(";") + .map((segment) => segment.trim()) + .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment)); + return retained.length > 0 ? retained.join("; ") : null; +} + +/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */ +export function cleanupPluginAssignments(options: string): string { + const tokens = tokenize(options); + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained: LaunchToken[] = []; + for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { + const token = tokens[tokenIndex]; + if (tokenIndex >= prefixEnd || !isAssignment(token)) { + retained.push(token); + continue; + } + const separator = token.value.indexOf("="); + const key = token.value.slice(0, separator); + if (key === "DXVK_CONFIG") { + const cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); + if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); + continue; + } + if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); + } + return serialize(retained); +} + +export function cleanupLegacyLaunchOptions(options: string): string { + const tokens = tokenize(options); + removeLegacyTokens(tokens); + return serialize(tokens); +} + +export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { + const tokens = tokenize(options); + removeWrapperTokens(tokens, wrapperPath); + return cleanupPluginAssignments(serialize(tokens)); +} + +export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { + return cleanupPluginLaunchOptions(options, wrapperPath); +} + +export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { + const tokens = tokenize(options); + const index = commandIndex(tokens); + return index > 0 && tokens[index - 1].value === wrapperPath; +} + +function delay(milliseconds: number): Promise<void> { + return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); +} + async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> { const apps = getSteamApps(); const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; @@ -135,49 +401,96 @@ async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: await Promise.resolve(setter.call(apps, appId, options)); } -function delay(milliseconds: number): Promise<void> { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +async function setShortcutExecutable(appId: number, executable: string): Promise<void> { + const apps = getSteamApps(); + if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); + await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); } -async function waitForLaunchOptions( +async function waitForSnapshot( appId: number, nonSteam: boolean, - expected: string, + matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean, + message: string, ): Promise<SteamLaunchOptionsSnapshot> { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { const snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot; + if (matches(snapshot)) return snapshot; } catch (error) { lastError = asError(error); } if (Date.now() >= deadline) break; await delay(100); } - if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`); - throw new Error("Steam did not accept the launch options before the readback timeout"); + if (lastError) throw new Error(`${message}: ${lastError.message}`); + throw new Error(`${message} before the readback timeout`); } -const operationQueues = new Map<string, Promise<void>>(); +async function writeLaunchOptionsAndVerify( + appId: number, + nonSteam: boolean, + previous: string, + next: string, + message: string, +): Promise<SteamLaunchOptionsSnapshot> { + try { + await setSteamLaunchOptions(appId, nonSteam, next); + return await waitForSnapshot( + appId, + nonSteam, + (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next), + message, + ); + } catch (error) { + const failure = asError(error); + try { + await setSteamLaunchOptions(appId, nonSteam, previous); + await waitForSnapshot( + appId, + nonSteam, + (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous), + "Steam did not restore the previous launch options", + ); + } catch (rollbackError) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + } + throw failure; + } +} -function queueKey(appId: number, nonSteam: boolean): string { - return `${nonSteam ? "shortcut" : "app"}:${appId}`; +async function writeShortcutExecutableAndVerify( + appId: number, + previous: string, + next: string, + message: string, +): Promise<SteamLaunchOptionsSnapshot> { + try { + await setShortcutExecutable(appId, next); + return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + } catch (error) { + const failure = asError(error); + try { + await setShortcutExecutable(appId, previous); + await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target"); + } catch (rollbackError) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + } + throw failure; + } } -function queueSteamAppOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { - const key = queueKey(appId, nonSteam); +const operationQueues = new Map<string, Promise<unknown>>(); + +function queueSteamOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; const previous = operationQueues.get(key) || Promise.resolve(); const queued = previous.catch(() => undefined).then(operation); - let cleanup: Promise<void>; - cleanup = queued.then( - () => { - if (operationQueues.get(key) === cleanup) operationQueues.delete(key); - }, - () => { - if (operationQueues.get(key) === cleanup) operationQueues.delete(key); - }, + const cleanup = queued.then( + () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, + () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, ); operationQueues.set(key, cleanup); return queued; @@ -188,37 +501,96 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); }); } -export function cleanupSteamLaunchOptions( +export function installWrapperIntegration( appId: number, nonSteam: boolean, + wrapperPath: string, + commandTokenAdded = false, +): Promise<WrapperIntegrationResult> { + return queueSteamOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + if (nonSteam) { + if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); + if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { + throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); + } + const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleanedOptions !== current.options) { + await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options"); + } + if (current.target === wrapperPath) { + return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false }; + } + const originalExecutable = current.target; + const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target"); + return { snapshot, originalExecutable, commandTokenAdded: false }; + } + + const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); + const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); + const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); + if (rewrite.options === current.options) { + return { snapshot: current, commandTokenAdded }; + } + const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options"); + return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + }); +} + +export function removeWrapperIntegration( + appId: number, + nonSteam: boolean, + wrapperPath: string, + originalExecutable?: string, + commandTokenAdded = false, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options); + if (nonSteam) { + if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } + if (current.target !== wrapperPath && current.target !== originalExecutable) { + throw new Error("Shortcut Target changed externally; refusing to restore it"); + } + const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleaned !== current.options) { + await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); + } + if (current.target === originalExecutable) { + return readSteamLaunchOptions(appId, true); + } + return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target"); + } + + const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); + const next = cleanupPluginAssignments(withoutWrapper); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); }); } export function cleanupLegacySteamLaunchOptions( appId: number, nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupLegacyLaunchOptions(current.options); + const next = cleanupPluginLaunchOptions(current.options, wrapperPath); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); }); } + +export function getDefaultWrapperPath(): string { + return DEFAULT_WRAPPER_PATH; +} |
