From 8352ee74e8a437c1f4a4dadb75d63049f45b164b Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 16:25:11 -0400 Subject: add back workarounds sections, scope out of now playing --- src/utils/steamLaunchOptions.ts | 211 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/utils/steamLaunchOptions.ts (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts new file mode 100644 index 0000000..9c7b5eb --- /dev/null +++ b/src/utils/steamLaunchOptions.ts @@ -0,0 +1,211 @@ +// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. +import { cleanupLegacyWrapper, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; + +// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. +export * from "./steamLaunchOptionParser.ts"; + +export interface SteamLaunchOptionsSnapshot { + appId: number; + nonSteam: boolean; + options: string; + details: SteamAppDetails; +} + +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); +} + +function getSteamApps(): Partial | undefined { + return (globalThis as typeof globalThis & { + SteamClient?: { Apps?: Partial }; + }).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 the legacy ~/lsfg wrapper; restore its original executable first"); + } + return { + appId, + nonSteam, + options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", + details, + }; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function registerSteamAppDetails( + appId: number, + onDetails: (details: SteamAppDetails) => boolean | void, +): () => void { + validateAppId(appId); + const apps = getSteamApps(); + const registerForAppDetails = apps?.RegisterForAppDetails; + if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable"); + + let active = true; + let unregisterPending = false; + let registration: SteamAppDetailsRegistration | undefined; + const unsubscribe = () => { + active = false; + if (!registration) { + unregisterPending = true; + return; + } + try { + registration.unregister(); + } catch { + // Steam may invalidate registrations during a details refresh. + } + }; + + try { + registration = registerForAppDetails.call(apps, appId, (details) => { + if (!active) return; + if (onDetails(details || {}) === false && active) unsubscribe(); + }); + if (unregisterPending) { + try { + registration.unregister(); + } catch { + // The registration can be invalidated before a synchronous callback returns. + } + } + } catch (error) { + throw asError(error); + } + return unsubscribe; +} + +export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let timeout = 0; + let unsubscribe = () => {}; + const finish = (error?: unknown, details?: SteamAppDetails) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + unsubscribe(); + if (error) { + reject(asError(error)); + return; + } + try { + resolve(snapshotFromDetails(appId, nonSteam, details || {})); + } catch (snapshotError) { + reject(asError(snapshotError)); + } + }; + + timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000); + try { + unsubscribe = registerSteamAppDetails(appId, (details) => { + finish(undefined, details); + return false; + }); + } catch (error) { + finish(error); + } + }); +} + +export function subscribeSteamLaunchOptions( + appId: number, + nonSteam: boolean, + onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void, + onError: (error: Error) => void, +): () => void { + return registerSteamAppDetails(appId, (details) => { + try { + onSnapshot(snapshotFromDetails(appId, nonSteam, details)); + } catch (error) { + onError(asError(error)); + } + }); +} + +async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise { + const apps = getSteamApps(); + const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; + if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`); + await Promise.resolve(setter.call(apps, appId, options)); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} + +async function waitForLaunchOptions( + appId: number, + nonSteam: boolean, + expected: string, +): Promise { + 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; + } 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"); +} + +const operationQueues = new Map>(); + +function queueKey(appId: number, nonSteam: boolean): string { + return `${nonSteam ? "shortcut" : "app"}:${appId}`; +} + +function queueSteamAppOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { + const key = queueKey(appId, nonSteam); + const previous = operationQueues.get(key) || Promise.resolve(); + const queued = previous.catch(() => undefined).then(operation); + let cleanup: Promise; + 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; +} + +export function updateSteamLaunchOptions( + appId: number, + nonSteam: boolean, + transform: (options: string) => string, +): Promise { + return queueSteamAppOperation(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); + }); +} + +export function cleanupSteamLaunchOptions( + appId: number, + nonSteam: boolean, +): Promise { + return queueSteamAppOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = cleanupLegacyWrapper(current.options); + if (next === current.options) return current; + await setSteamLaunchOptions(appId, nonSteam, next); + return waitForLaunchOptions(appId, nonSteam, next); + }); +} -- cgit v1.2.3 From bec26fe025c97c00d398e7a4fb571195706b9e76 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 16:56:57 -0400 Subject: feat: more launch arg janitoring --- src/utils/steamLaunchOptions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 9c7b5eb..39125bd 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,5 @@ // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyWrapper, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. export * from "./steamLaunchOptionParser.ts"; @@ -23,7 +23,7 @@ function getSteamApps(): Partial | undefined { function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) { - throw new Error("The shortcut Target still points to the legacy ~/lsfg wrapper; restore its original executable first"); + throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first"); } return { appId, @@ -203,7 +203,7 @@ export function cleanupSteamLaunchOptions( ): Promise { return queueSteamAppOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupLegacyWrapper(current.options); + const next = cleanupLegacyLaunchOptions(current.options); if (next === current.options) return current; await setSteamLaunchOptions(appId, nonSteam, next); return waitForLaunchOptions(appId, nonSteam, next); -- cgit v1.2.3 From 790132668c4421c68c32bdc8fc9792b0d6028f97 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 22:18:13 -0400 Subject: I really dont want to but here you go little guy --- src/utils/steamLaunchOptions.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 39125bd..82ff94b 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,5 @@ // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +import { cleanupLegacyLaunchOptions, cleanupPluginLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. export * from "./steamLaunchOptionParser.ts"; @@ -200,6 +200,19 @@ export function updateSteamLaunchOptions( export function cleanupSteamLaunchOptions( appId: number, nonSteam: boolean, +): Promise { + return queueSteamAppOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = cleanupPluginLaunchOptions(current.options); + if (next === current.options) return current; + await setSteamLaunchOptions(appId, nonSteam, next); + return waitForLaunchOptions(appId, nonSteam, next); + }); +} + +export function cleanupLegacySteamLaunchOptions( + appId: number, + nonSteam: boolean, ): Promise { return queueSteamAppOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); -- cgit v1.2.3 From 1b932fd69c3dba925e0cbf027e05508b2daf5e8c Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Wed, 9 Sep 2026 01:01:42 -0400 Subject: add back launcher script and correct pathing --- src/utils/steamLaunchOptions.ts | 472 +++++++++++++++++++++++++++++++++++----- 1 file changed, 422 insertions(+), 50 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') 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 | 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 { 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 { + return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); +} + async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise { 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 { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +async function setShortcutExecutable(appId: number, executable: string): Promise { + 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 { 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>(); +async function writeLaunchOptionsAndVerify( + appId: number, + nonSteam: boolean, + previous: string, + next: string, + message: string, +): Promise { + 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 { + 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(appId: number, nonSteam: boolean, operation: () => Promise): Promise { - const key = queueKey(appId, nonSteam); +const operationQueues = new Map>(); + +function queueSteamOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; const previous = operationQueues.get(key) || Promise.resolve(); const queued = previous.catch(() => undefined).then(operation); - let cleanup: Promise; - 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 { - 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 { + 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 { - 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 { - 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; +} -- cgit v1.2.3 From 450d3e5e6612d467a00bb937538fded640c66ecb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:24:05 -0400 Subject: refactor: simplify migration implementation --- src/utils/steamLaunchOptions.ts | 566 ++++++++++++++-------------------------- 1 file changed, 197 insertions(+), 369 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index e00b32d..0af1bbf 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -12,27 +12,14 @@ export const LEGACY_WRAPPER_TOKENS = new Set([ ]); 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", + "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; -} - +interface LaunchToken { raw: string; value: string; } export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; @@ -40,42 +27,33 @@ export interface SteamLaunchOptionsSnapshot { 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"); +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); } -function getSteamApps(): Partial | undefined { - return (globalThis as typeof globalThis & { - SteamClient?: { Apps?: Partial }; - }).SteamClient?.Apps; +function apps(): Partial | undefined { + return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial } }).SteamClient?.Apps; } -interface TimerHost { - setTimeout(handler: () => void, timeout: number): number; - clearTimeout(timeout: number): void; +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } -function timerHost(): TimerHost { - if (typeof window !== "undefined") { - return { - setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), - clearTimeout: (timeout) => window.clearTimeout(timeout), - }; - } +function timer() { + const host = typeof window !== "undefined" ? window : globalThis; return { - setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, - clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number, + clear: (id: number) => host.clearTimeout(id), }; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { +function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { return { appId, nonSteam, @@ -85,73 +63,39 @@ function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamApp }; } -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function registerSteamAppDetails( - appId: number, - onDetails: (details: SteamAppDetails) => boolean | void, -): () => void { +function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void { validateAppId(appId); - const apps = getSteamApps(); - const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); - + const register = apps()?.RegisterForAppDetails; + if (!register) throw new Error("Steam app-details API is unavailable"); let active = true; - let unregisterPending = false; let registration: SteamAppDetailsRegistration | undefined; const unsubscribe = () => { active = false; - if (!registration) { - unregisterPending = true; - return; - } - try { - registration.unregister(); - } catch { - // Steam can invalidate a registration while details are refreshing. - } + try { registration?.unregister(); } catch {} }; - - try { - registration = registerForAppDetails.call(apps, appId, (details) => { - if (!active) return; - if (onDetails(details || {}) === false && active) unsubscribe(); - }); - if (unregisterPending) { - try { - registration.unregister(); - } catch { - // A synchronous callback can invalidate the registration before return. - } - } - } catch (error) { - throw asError(error); - } + 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 settled = false; - let timeout: number | undefined; + 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 (settled) return; - settled = true; - if (timeout !== undefined) timerHost().clearTimeout(timeout); + if (done) return; + done = true; + clock.clear(timeout); unsubscribe(); - if (error) { - reject(asError(error)); - return; - } - resolve(snapshotFromDetails(appId, nonSteam, details || {})); + if (error) reject(asError(error)); + else resolve(snapshot(appId, nonSteam, details || {})); }; - - timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { - unsubscribe = registerSteamAppDetails(appId, (details) => { + unsubscribe = registerDetails(appId, (details) => { finish(undefined, details); return false; }); @@ -164,34 +108,24 @@ export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): export function subscribeSteamLaunchOptions( appId: number, nonSteam: boolean, - onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void, + onSnapshot: (value: SteamLaunchOptionsSnapshot) => void, onError: (error: Error) => void, ): () => void { - return registerSteamAppDetails(appId, (details) => { - try { - onSnapshot(snapshotFromDetails(appId, nonSteam, details)); - } catch (error) { - onError(asError(error)); - } + 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 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; - } + 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; } @@ -201,299 +135,184 @@ function tokenize(options: string): 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); + 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; } -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; -} +const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +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); -function commandIndex(tokens: readonly LaunchToken[]): number { - return tokens.findIndex(isCommandToken); -} +export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); +export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); -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); +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 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 { +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { 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)); + 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 insertion = leadingAssignments(tokens); - const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); - if (tokens.length !== insertion && !argumentsOnly) { + let insertion = 0; + while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; + if (insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { 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 }); + tokens.splice(insertion, 0, + { raw: wrapperPath, value: 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); + 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 { - 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; + return /^[A-Za-z0-9_./:+,%=-]+$/.test(value) + ? value + : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -/** 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); + 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 cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); - if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); - continue; + 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}` }] : []; } - if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); - } - return serialize(retained); + return MANAGED_ENV_KEYS.has(key) ? [] : [token]; + })); } export function cleanupLegacyLaunchOptions(options: string): string { const tokens = tokenize(options); - removeLegacyTokens(tokens); + removeMatchingWrappers(tokens, isLegacyToken); 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 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 index = commandIndex(tokens); - return index > 0 && tokens[index - 1].value === wrapperPath; -} - -function delay(milliseconds: number): Promise { - return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); -} - -async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise { - const apps = getSteamApps(); - const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; - if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`); - await Promise.resolve(setter.call(apps, appId, options)); + const command = commandIndex(tokens); + return command > 0 && tokens[command - 1].value === wrapperPath; } -async function setShortcutExecutable(appId: number, executable: string): Promise { - const apps = getSteamApps(); - if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); - await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); +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 waitForSnapshot( +async function waitFor( appId: number, nonSteam: boolean, - matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean, + matches: (value: SteamLaunchOptionsSnapshot) => boolean, message: string, ): Promise { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { - const snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (matches(snapshot)) return snapshot; - } catch (error) { - lastError = asError(error); - } - if (Date.now() >= deadline) break; - await delay(100); + 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)); } - if (lastError) throw new Error(`${message}: ${lastError.message}`); - throw new Error(`${message} before the readback timeout`); + throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`); } -async function writeLaunchOptionsAndVerify( +async function writeVerified( appId: number, nonSteam: boolean, previous: string, next: string, + write: (value: string) => Promise, + read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise { + const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; 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; - } -} - -async function writeShortcutExecutableAndVerify( - appId: number, - previous: string, - next: string, - message: string, -): Promise { - try { - await setShortcutExecutable(appId, next); - return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + await write(next); + return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(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}`); + await write(previous); + await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + } catch (rollback) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); } throw failure; } } -const operationQueues = new Map>(); +const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; +const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; -function queueSteamOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { - const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; - const previous = operationQueues.get(key) || Promise.resolve(); - const queued = previous.catch(() => undefined).then(operation); - 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; +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)); +} +function writeTarget(appId: number, value: string): Promise { + const setter = apps()?.SetShortcutExe; + if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); + return Promise.resolve(setter.call(apps(), appId, value)); } export function updateSteamLaunchOptions( @@ -501,11 +320,14 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise { - return queueSteamOperation(appId, nonSteam, async () => { + return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); + return next === current.options ? current : writeVerified( + appId, nonSteam, current.options, next, + (value) => writeOptions(appId, nonSteam, value), readOptions, + "Steam did not accept the launch options", + ); }); } @@ -515,33 +337,41 @@ export function installWrapperIntegration( wrapperPath: string, commandTokenAdded = false, ): Promise { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); + return queued(appId, nonSteam, async () => { + let 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 cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleaned !== current.options) { + current = await writeVerified( + appId, true, current.options, cleaned, + (value) => writeOptions(appId, true, value), readOptions, + "Steam did not accept shortcut launch options", + ); } + if (current.target === wrapperPath) return { snapshot: current, 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 value = await writeVerified( + appId, true, originalExecutable, wrapperPath, + (target) => writeTarget(appId, target), readTarget, + "Steam did not accept the shortcut Target", + ); + return { snapshot: value, 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 }; + if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + const value = await writeVerified( + appId, false, current.options, rewrite.options, + (options) => writeOptions(appId, false, options), readOptions, + "Steam did not accept the launch options", + ); + return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; }); } @@ -552,8 +382,8 @@ export function removeWrapperIntegration( originalExecutable?: string, commandTokenAdded = false, ): Promise { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); + return queued(appId, nonSteam, async () => { + let current = await readSteamLaunchOptions(appId, nonSteam); if (nonSteam) { if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); @@ -563,34 +393,32 @@ export function removeWrapperIntegration( } const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); + current = await writeVerified( + appId, true, current.options, cleaned, + (value) => writeOptions(appId, true, value), readOptions, + "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"); + if (current.target === originalExecutable) return current; + return writeVerified( + appId, true, wrapperPath, originalExecutable, + (target) => writeTarget(appId, target), readTarget, + "Steam did not restore the shortcut Target", + ); } - - const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); - const next = cleanupPluginAssignments(withoutWrapper); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); + const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); + return next === current.options ? current : writeVerified( + appId, false, current.options, next, + (options) => writeOptions(appId, false, options), readOptions, + "Steam did not clean the launch options", + ); }); } -export function cleanupLegacySteamLaunchOptions( +export const cleanupLegacySteamLaunchOptions = ( appId: number, nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, -): Promise { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); - }); -} +) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath)); -export function getDefaultWrapperPath(): string { - return DEFAULT_WRAPPER_PATH; -} +export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH; -- cgit v1.2.3 From b197e25b45d53c6c7175a45dd0b14642f2aab198 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 11:51:22 -0400 Subject: fixes for appimage and flatpak --- src/utils/steamLaunchOptions.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 0af1bbf..65541d8 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -172,7 +172,11 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { +export function installWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + allowCommandArgs = false, +) { const tokens = tokenize(options); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); @@ -185,7 +189,7 @@ export function installWrapperLaunchOption(options: string, wrapperPath = DEFAUL } let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { + if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } tokens.splice(insertion, 0, @@ -336,10 +340,11 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, + transport: "host" | "flatpak" = "host", ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { + if (nonSteam && transport === "flatpak") { 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"); @@ -364,11 +369,11 @@ export function installWrapperIntegration( const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); + const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; const value = await writeVerified( - appId, false, current.options, rewrite.options, - (options) => writeOptions(appId, false, options), readOptions, + appId, nonSteam, current.options, rewrite.options, + (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; @@ -381,10 +386,11 @@ export function removeWrapperIntegration( wrapperPath: string, originalExecutable?: string, commandTokenAdded = false, + transport: "host" | "flatpak" = "host", ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { + if (nonSteam && transport === "flatpak") { if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); } @@ -408,8 +414,8 @@ export function removeWrapperIntegration( } const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( - appId, false, current.options, next, - (options) => writeOptions(appId, false, options), readOptions, + appId, nonSteam, current.options, next, + (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not clean the launch options", ); }); -- cgit v1.2.3 From 670f36e8cc75da9c8b1b174c24e722657bbf2a56 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 12:15:05 -0400 Subject: cleanup flatpak handles --- src/utils/steamLaunchOptions.ts | 95 +++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 22 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 65541d8..959ce9e 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,3 +1,5 @@ +import type { TargetTransport } from "../api/lsfgApi"; + const DEFAULT_WRAPPER_PATH = "~/.lsfg"; const COMMAND_TOKEN = "%command%"; @@ -31,6 +33,7 @@ export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; originalExecutable?: string; commandTokenAdded: boolean; + changed: boolean; } function asError(error: unknown): Error { @@ -159,6 +162,13 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token 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); +const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak"; + +function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { + if (transport.kind !== "flatpak") return undefined; + const value = candidate?.trim(); + return value ? (value.startsWith("/") ? value : "/usr/bin/flatpak") : undefined; +} export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); @@ -172,10 +182,10 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption( +function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, - allowCommandArgs = false, + shortcutLaunchOptions = false, ) { const tokens = tokenize(options); removeMatchingWrappers(tokens, isLegacyToken); @@ -189,7 +199,7 @@ export function installWrapperLaunchOption( } let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { + if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } tokens.splice(insertion, 0, @@ -199,6 +209,10 @@ export function installWrapperLaunchOption( 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, @@ -249,6 +263,29 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU return command > 0 && tokens[command - 1].value === wrapperPath; } +export function isWrapperIntegrationInstalled( + steam: SteamLaunchOptionsSnapshot, + nonSteam: boolean, + transport: TargetTransport, + wrapperPath = DEFAULT_WRAPPER_PATH, +): boolean { + return usesShortcutTarget(nonSteam, transport) + ? steam.target === wrapperPath + : hasWrapperLaunchIntegration(steam.options, wrapperPath); +} + +export function assertKnownShortcutTarget( + steam: SteamLaunchOptionsSnapshot, + nonSteam: boolean, + transport: TargetTransport, + wrapperPath: string, + originalExecutable?: string | null, +): void { + if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) { + throw new Error("Managed shortcut Target has no saved original executable"); + } +} + const queues = new Map>(); function queued(appId: number, nonSteam: boolean, operation: () => Promise): Promise { const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; @@ -340,43 +377,52 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", + transport: TargetTransport = { kind: "host" }, + originalExecutable?: string, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { + if (usesShortcutTarget(nonSteam, transport)) { 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 cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { + const launchOptionsChanged = cleaned !== current.options; + if (launchOptionsChanged) { current = await writeVerified( appId, true, current.options, cleaned, (value) => writeOptions(appId, true, value), readOptions, "Steam did not accept shortcut launch options", ); } - if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false }; - const originalExecutable = current.target; + const savedOriginal = selectFlatpakExecutable(transport, originalExecutable); + if (current.target === wrapperPath) { + if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable"); + return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged }; + } + if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) { + throw new Error("Shortcut Target changed externally; refusing to replace it"); + } + const currentOriginal = selectFlatpakExecutable(transport, current.target); const value = await writeVerified( - appId, true, originalExecutable, wrapperPath, + appId, true, current.target, wrapperPath, (target) => writeTarget(appId, target), readTarget, "Steam did not accept the shortcut Target", ); - return { snapshot: value, originalExecutable, commandTokenAdded: false }; + return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true }; } const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); - if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); + 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), readOptions, "Steam did not accept the launch options", ); - return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true }; }); } @@ -386,17 +432,11 @@ export function removeWrapperIntegration( wrapperPath: string, originalExecutable?: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", + transport: TargetTransport = { kind: "host" }, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { - 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"); - } + if (usesShortcutTarget(nonSteam, transport)) { const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { current = await writeVerified( @@ -405,7 +445,18 @@ export function removeWrapperIntegration( "Steam did not clean shortcut launch options", ); } - if (current.target === originalExecutable) return current; + if (current.target !== wrapperPath) { + if (isWrapperToken(current.target, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } + if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) { + throw new Error("Shortcut Target changed externally; refusing to restore it"); + } + return current; + } + if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } return writeVerified( appId, true, wrapperPath, originalExecutable, (target) => writeTarget(appId, target), readTarget, -- cgit v1.2.3 From 9902135e53be129bd6096d51d5e510ab298c1ae2 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 12:19:40 -0400 Subject: fix: handle bare Flatpak shortcut targets --- src/utils/steamLaunchOptions.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 959ce9e..82cf116 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -166,8 +166,10 @@ const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => no function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { if (transport.kind !== "flatpak") return undefined; - const value = candidate?.trim(); - return value ? (value.startsWith("/") ? value : "/usr/bin/flatpak") : undefined; + const value = candidate?.trim() ? decodeToken(candidate.trim()) : ""; + if (value === "flatpak") return "/usr/bin/flatpak"; + if (value === "/usr/bin/flatpak") return value; + return undefined; } export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); @@ -405,6 +407,9 @@ export function installWrapperIntegration( throw new Error("Shortcut Target changed externally; refusing to replace it"); } const currentOriginal = selectFlatpakExecutable(transport, current.target); + if (!currentOriginal || (originalExecutable && !savedOriginal)) { + throw new Error("Flatpak shortcut Target is not a supported executable"); + } const value = await writeVerified( appId, true, current.target, wrapperPath, (target) => writeTarget(appId, target), readTarget, -- cgit v1.2.3 From 6c22d5ee540bc4c71a72c1e83ffc40e6b0494bc1 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:40:57 -0400 Subject: refactor: use explicit direct flatpak target wrapper --- src/utils/steamLaunchOptions.ts | 114 +++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 66 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 82cf116..08ae136 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,3 @@ -import type { TargetTransport } from "../api/lsfgApi"; - const DEFAULT_WRAPPER_PATH = "~/.lsfg"; const COMMAND_TOKEN = "%command%"; @@ -31,7 +29,6 @@ export interface SteamLaunchOptionsSnapshot { } export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; - originalExecutable?: string; commandTokenAdded: boolean; changed: boolean; } @@ -162,14 +159,28 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token 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); -const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak"; -function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { - if (transport.kind !== "flatpak") return undefined; - const value = candidate?.trim() ? decodeToken(candidate.trim()) : ""; - if (value === "flatpak") return "/usr/bin/flatpak"; - if (value === "/usr/bin/flatpak") return value; - return undefined; +function flatpakExecutable(value: string): string | undefined { + const decoded = decodeToken(value.trim()); + return decoded === "flatpak" || decoded === "/usr/bin/flatpak" ? "/usr/bin/flatpak" : undefined; +} + +function wrappedFlatpakExecutable(target: string, wrapperPath: string, includeLegacy = true): string | undefined { + const tokens = tokenize(target); + if (tokens.length !== 2) return undefined; + const wrapper = tokens[0].value; + if (wrapper !== wrapperPath && !(includeLegacy && isLegacyToken(wrapper))) return undefined; + return flatpakExecutable(tokens[1].value); +} + +function directFlatpakExecutable(target: string, wrapperPath: string): string | undefined { + const tokens = tokenize(target); + if (tokens.length === 1) return flatpakExecutable(tokens[0].value); + return wrappedFlatpakExecutable(target, wrapperPath); +} + +function managedFlatpakTarget(wrapperPath: string, executable: string): string { + return `${wrapperPath} "${executable.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); @@ -268,24 +279,14 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU export function isWrapperIntegrationInstalled( steam: SteamLaunchOptionsSnapshot, nonSteam: boolean, - transport: TargetTransport, + directFlatpak = false, wrapperPath = DEFAULT_WRAPPER_PATH, ): boolean { - return usesShortcutTarget(nonSteam, transport) - ? steam.target === wrapperPath - : hasWrapperLaunchIntegration(steam.options, wrapperPath); -} - -export function assertKnownShortcutTarget( - steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - transport: TargetTransport, - wrapperPath: string, - originalExecutable?: string | null, -): void { - if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) { - throw new Error("Managed shortcut Target has no saved original executable"); + if (nonSteam && directFlatpak) { + const tokens = tokenize(steam.target); + return tokens.length === 2 && tokens[0].value === wrapperPath && flatpakExecutable(tokens[1].value) !== undefined; } + return hasWrapperLaunchIntegration(steam.options, wrapperPath); } const queues = new Map>(); @@ -328,7 +329,7 @@ async function writeVerified( read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise { - const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; + const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => normalizeLaunchOptions(value); try { await write(next); return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); @@ -379,16 +380,11 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: TargetTransport = { kind: "host" }, - originalExecutable?: string, + directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (usesShortcutTarget(nonSteam, transport)) { - 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"); - } + if (nonSteam && directFlatpak) { const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); const launchOptionsChanged = cleaned !== current.options; if (launchOptionsChanged) { @@ -398,24 +394,18 @@ export function installWrapperIntegration( "Steam did not accept shortcut launch options", ); } - const savedOriginal = selectFlatpakExecutable(transport, originalExecutable); - if (current.target === wrapperPath) { - if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable"); - return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged }; - } - if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) { - throw new Error("Shortcut Target changed externally; refusing to replace it"); - } - const currentOriginal = selectFlatpakExecutable(transport, current.target); - if (!currentOriginal || (originalExecutable && !savedOriginal)) { - throw new Error("Flatpak shortcut Target is not a supported executable"); + const executable = directFlatpakExecutable(current.target, wrapperPath); + if (!executable) throw new Error("Flatpak shortcut Target is not a supported direct Flatpak executable"); + const target = managedFlatpakTarget(wrapperPath, executable); + if (normalizeLaunchOptions(current.target) === normalizeLaunchOptions(target)) { + return { snapshot: current, commandTokenAdded: false, changed: launchOptionsChanged }; } const value = await writeVerified( - appId, true, current.target, wrapperPath, - (target) => writeTarget(appId, target), readTarget, + appId, true, current.target, target, + (next) => writeTarget(appId, next), readTarget, "Steam did not accept the shortcut Target", ); - return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true }; + return { snapshot: value, commandTokenAdded: false, changed: true }; } const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); @@ -435,13 +425,12 @@ export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, - originalExecutable?: string, commandTokenAdded = false, - transport: TargetTransport = { kind: "host" }, + directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (usesShortcutTarget(nonSteam, transport)) { + if (nonSteam && directFlatpak) { const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { current = await writeVerified( @@ -450,23 +439,16 @@ export function removeWrapperIntegration( "Steam did not clean shortcut launch options", ); } - if (current.target !== wrapperPath) { - if (isWrapperToken(current.target, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } - return current; - } - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + const wrapped = wrappedFlatpakExecutable(current.target, wrapperPath); + if (wrapped) { + return writeVerified( + appId, true, current.target, wrapped, + (target) => writeTarget(appId, target), readTarget, + "Steam did not restore the shortcut Target", + ); } - return writeVerified( - appId, true, wrapperPath, originalExecutable, - (target) => writeTarget(appId, target), readTarget, - "Steam did not restore the shortcut Target", - ); + if (flatpakExecutable(current.target)) return current; + throw new Error("Shortcut Target changed externally; refusing to restore it"); } const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( -- cgit v1.2.3 From f6f77d775977ad17cdc73338d0f90d05a13cbfc2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:53:02 -0400 Subject: fix: normalize released flatpak target paths --- src/utils/steamLaunchOptions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 08ae136..685dcf9 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -162,7 +162,9 @@ const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value function flatpakExecutable(value: string): string | undefined { const decoded = decodeToken(value.trim()); - return decoded === "flatpak" || decoded === "/usr/bin/flatpak" ? "/usr/bin/flatpak" : undefined; + return decoded === "flatpak" || decoded === "/usr/bin/flatpak" || decoded === "usr/bin/flatpak" + ? "/usr/bin/flatpak" + : undefined; } function wrappedFlatpakExecutable(target: string, wrapperPath: string, includeLegacy = true): string | undefined { -- cgit v1.2.3 From a6c8f95af3a28a7384d1fd4155bbd1e15cf36334 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:34:37 -0400 Subject: refactor: remove flatpak target integration --- src/utils/steamLaunchOptions.ts | 104 ++++------------------------------------ 1 file changed, 8 insertions(+), 96 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 685dcf9..f03eeab 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -24,7 +24,6 @@ export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; - target: string; details: SteamAppDetails; } export interface WrapperIntegrationResult { @@ -58,7 +57,6 @@ function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): S appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", - target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -160,31 +158,6 @@ const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(tok 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); -function flatpakExecutable(value: string): string | undefined { - const decoded = decodeToken(value.trim()); - return decoded === "flatpak" || decoded === "/usr/bin/flatpak" || decoded === "usr/bin/flatpak" - ? "/usr/bin/flatpak" - : undefined; -} - -function wrappedFlatpakExecutable(target: string, wrapperPath: string, includeLegacy = true): string | undefined { - const tokens = tokenize(target); - if (tokens.length !== 2) return undefined; - const wrapper = tokens[0].value; - if (wrapper !== wrapperPath && !(includeLegacy && isLegacyToken(wrapper))) return undefined; - return flatpakExecutable(tokens[1].value); -} - -function directFlatpakExecutable(target: string, wrapperPath: string): string | undefined { - const tokens = tokenize(target); - if (tokens.length === 1) return flatpakExecutable(tokens[0].value); - return wrappedFlatpakExecutable(target, wrapperPath); -} - -function managedFlatpakTarget(wrapperPath: string, executable: string): string { - return `${wrapperPath} "${executable.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); @@ -280,14 +253,9 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU export function isWrapperIntegrationInstalled( steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - directFlatpak = false, + _nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, ): boolean { - if (nonSteam && directFlatpak) { - const tokens = tokenize(steam.target); - return tokens.length === 2 && tokens[0].value === wrapperPath && flatpakExecutable(tokens[1].value) !== undefined; - } return hasWrapperLaunchIntegration(steam.options, wrapperPath); } @@ -328,18 +296,16 @@ async function writeVerified( previous: string, next: string, write: (value: string) => Promise, - read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise { - const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => normalizeLaunchOptions(value); try { await write(next); - return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); + 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) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + 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}`); } @@ -347,19 +313,11 @@ async function writeVerified( } } -const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; -const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; - 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)); } -function writeTarget(appId: number, value: string): Promise { - const setter = apps()?.SetShortcutExe; - if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); - return Promise.resolve(setter.call(apps(), appId, value)); -} export function updateSteamLaunchOptions( appId: number, @@ -371,7 +329,7 @@ export function updateSteamLaunchOptions( const next = transform(current.options); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (value) => writeOptions(appId, nonSteam, value), readOptions, + (value) => writeOptions(appId, nonSteam, value), "Steam did not accept the launch options", ); }); @@ -382,41 +340,16 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && directFlatpak) { - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - const launchOptionsChanged = cleaned !== current.options; - if (launchOptionsChanged) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not accept shortcut launch options", - ); - } - const executable = directFlatpakExecutable(current.target, wrapperPath); - if (!executable) throw new Error("Flatpak shortcut Target is not a supported direct Flatpak executable"); - const target = managedFlatpakTarget(wrapperPath, executable); - if (normalizeLaunchOptions(current.target) === normalizeLaunchOptions(target)) { - return { snapshot: current, commandTokenAdded: false, changed: launchOptionsChanged }; - } - const value = await writeVerified( - appId, true, current.target, target, - (next) => writeTarget(appId, next), readTarget, - "Steam did not accept the shortcut Target", - ); - return { snapshot: value, commandTokenAdded: false, changed: true }; - } - + const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); 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), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true }; @@ -428,34 +361,13 @@ export function removeWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && directFlatpak) { - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not clean shortcut launch options", - ); - } - const wrapped = wrappedFlatpakExecutable(current.target, wrapperPath); - if (wrapped) { - return writeVerified( - appId, true, current.target, wrapped, - (target) => writeTarget(appId, target), readTarget, - "Steam did not restore the shortcut Target", - ); - } - if (flatpakExecutable(current.target)) return current; - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } + 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), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not clean the launch options", ); }); -- cgit v1.2.3 From 81feb03288166755545401b9df2ff2c9bc3ae7d7 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 16:48:35 -0400 Subject: handle flatpak grey, id proton and exclusions --- src/utils/steamLaunchOptions.ts | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) (limited to 'src/utils/steamLaunchOptions.ts') diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index f03eeab..83c46f2 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -153,7 +153,25 @@ function tokenize(options: string): LaunchToken[] { } const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); -const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +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); @@ -173,9 +191,9 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, - shortcutLaunchOptions = false, ) { const tokens = tokenize(options); + normalizeCommandTokens(tokens); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); if (command >= 0) { @@ -185,11 +203,15 @@ function installLaunchOption( 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++; - if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { - throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); - } tokens.splice(insertion, 0, { raw: wrapperPath, value: wrapperPath }, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, @@ -207,6 +229,7 @@ export function removeWrapperLaunchOption( 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); @@ -345,14 +368,18 @@ export function installWrapperIntegration( const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); + 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 : rewrite.commandTokenAdded, changed: true }; + return { + snapshot: value, + commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded, + changed: true, + }; }); } -- cgit v1.2.3