diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 01:01:42 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 01:01:42 -0400 |
| commit | 1b932fd69c3dba925e0cbf027e05508b2daf5e8c (patch) | |
| tree | f7f105e3e6c8f702656a4ed9727ffd3569433a98 /src | |
| parent | 790132668c4421c68c32bdc8fc9792b0d6028f97 (diff) | |
| download | decky-lsfg-vk-1b932fd69c3dba925e0cbf027e05508b2daf5e8c.tar.gz decky-lsfg-vk-1b932fd69c3dba925e0cbf027e05508b2daf5e8c.zip | |
add back launcher script and correct pathing
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/lsfgApi.ts | 29 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 3 | ||||
| -rw-r--r-- | src/components/Content.tsx | 2 | ||||
| -rw-r--r-- | src/components/GameConfigurationControls.tsx | 8 | ||||
| -rw-r--r-- | src/components/WorkaroundsSection.tsx | 41 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 158 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 234 | ||||
| -rw-r--r-- | src/types.d.ts | 1 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptionParser.ts | 525 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 472 |
10 files changed, 795 insertions, 678 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 8eaad98..087228c 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -60,6 +60,27 @@ export interface GameConfigResult extends ConfigUpdateResult { config?: LsfgConfig; } +export interface WorkaroundState { + dxvkFrameRate: number; + disableGamescopeWsi: boolean; + disableHdr: boolean; + disableSteamdeckMode: boolean; + disableVkbasalt: boolean; + enableZink: boolean; +} + +export interface WorkaroundStateResult { + success: boolean; + message?: string; + error?: string; + appid?: string; + state?: WorkaroundState | null; + wrapper_path?: string; + wrapper_owned?: boolean; + shortcut_exe?: string | null; + command_token_added?: boolean; +} + export interface FileContentResult { success: boolean; content?: string; @@ -120,3 +141,11 @@ export const getInstalledGames = callable<[], InstalledGamesResult>("get_install export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); +export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state"); +export const setWorkaroundState = callable<[ + string, + WorkaroundState, + string | null | undefined, + boolean, +], WorkaroundStateResult>("set_workaround_state"); +export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index c41c941..62a971b 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -15,6 +15,7 @@ interface ConfigurationTabProps { onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; onEnable: (appid: string) => Promise<boolean>; onEnableAll: () => Promise<void>; + onRepair: (appid: string) => Promise<boolean>; onReset: () => Promise<void>; onResetAll: () => Promise<void>; } @@ -27,6 +28,7 @@ export function ConfigurationTab({ onConfigChange, onEnable, onEnableAll, + onRepair, onReset, onResetAll, }: ConfigurationTabProps) { @@ -157,6 +159,7 @@ export function ConfigurationTab({ onFpsMultiplierFocused={clearFpsFocusRequest} showWorkarounds workaroundTarget={selectedTarget || undefined} + onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined} /> )} {selectedTarget?.configured && ( diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 1d7d2d1..59dc514 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -39,6 +39,7 @@ export function Content() { save, enable, enableAll, + repair, resetSelected, resetAll, reload, @@ -132,6 +133,7 @@ export function Content() { onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} onEnable={enable} onEnableAll={enableAll} + onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 58ccd02..7025f78 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -11,6 +11,7 @@ interface Props { onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">; + onRepairWorkaround?: () => Promise<boolean>; } export function GameConfigurationControls({ @@ -20,6 +21,7 @@ export function GameConfigurationControls({ onFpsMultiplierFocused, showWorkarounds = false, workaroundTarget, + onRepairWorkaround, }: Props) { return ( <> @@ -31,7 +33,11 @@ export function GameConfigurationControls({ /> <ConfigurationSection config={config} onConfigChange={onConfigChange} /> {showWorkarounds && workaroundTarget && ( - <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} /> + <WorkaroundsSection + appId={workaroundTarget.appid} + nonSteam={workaroundTarget.nonSteam} + onRepair={onRepairWorkaround} + /> )} </> ); diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index d783620..dbdd6a3 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -3,11 +3,12 @@ import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; -import type { WorkaroundField } from "../utils/steamLaunchOptions"; +import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; + onRepair?: () => Promise<boolean>; } const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2"; @@ -78,18 +79,28 @@ function usePersistentCollapsed() { return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); - const state = snapshot?.parsed.state; - const issues = snapshot?.parsed.issues || []; - const controlsDisabled = status !== "ready" || state === undefined; + const [repairing, setRepairing] = useState(false); + const state = snapshot?.state; + const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; const [fpsValue, setFpsValue] = useState<number | null>(null); const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0; const fpsLabel = effectiveFpsValue > 0 ? `${effectiveFpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + const handleRepair = async () => { + if (!onRepair || repairing) return; + setRepairing(true); + try { + if (await onRepair()) await refresh(); + } finally { + setRepairing(false); + } + }; + useEffect(() => { setFpsValue(state?.dxvkFrameRate ?? null); }, [state?.dxvkFrameRate, status]); @@ -155,13 +166,19 @@ export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) </PanelSectionRow> </> )} - {status === "ready" && issues.length > 0 && ( - <PanelSectionRow> - <Field - label="Launch options need attention" - description={`${issues.join(" ")} Adjusting a workaround will normalize its managed values.`} - /> - </PanelSectionRow> + {status === "ready" && snapshot && (!snapshot.wrapperOwned || !snapshot.integrationInstalled) && ( + <> + <PanelSectionRow> + <Field label="Wrapper needs to be reinstalled" /> + </PanelSectionRow> + {onRepair && ( + <PanelSectionRow> + <ButtonItem layout="below" disabled={repairing} onClick={() => void handleRepair()}> + {repairing ? "Reinstalling..." : "Reinstall wrapper"} + </ButtonItem> + </PanelSectionRow> + )} + </> )} <PanelSectionRow> diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 6d1fe6a..c66596a 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { applyWorkaroundState, cleanupLegacySteamLaunchOptions, cleanupSteamLaunchOptions, getDefaultWorkaroundState, updateSteamLaunchOptions } from "../utils/steamLaunchOptions"; +import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -32,6 +32,19 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta return Array.from(games.values()); } +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + export function useGameConfiguration() { const [games, setGames] = useState<GameConfigEntry[]>([]); const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false }); @@ -95,39 +108,107 @@ export function useGameConfiguration() { const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; - const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { if (!installedGames.some((game) => game.appid === target.appid)) return true; + const appId = Number(target.appid); try { - await cleanupLegacySteamLaunchOptions(Number(target.appid), target.nonSteam); - return true; - } catch (error) { - showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error)); - return false; - } - }, [installedGames]); + const existing = await getWorkaroundState(target.appid); + if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); + const current = await readSteamLaunchOptions(appId, target.nonSteam); + const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const oldState = existing.state; + const oldShortcutExe = existing.shortcut_exe || undefined; + const oldCommandTokenAdded = existing.command_token_added === true; + if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) { + throw new Error("Managed shortcut Target has no saved original executable"); + } + if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { + throw new Error("Shortcut Target changed externally; refusing to replace it"); + } + if (target.nonSteam && !oldState && current.target === wrapperPath) { + throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); + } + const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; + const originalExecutable = target.nonSteam ? (oldShortcutExe || current.target) : undefined; + const initialIntegration = target.nonSteam + ? current.target === wrapperPath + : hasWrapperLaunchIntegration(current.options, wrapperPath); + const initialStateResult = await setWorkaroundState( + target.appid, + state, + originalExecutable || null, + oldCommandTokenAdded, + ); + if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state"); - const removeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { - if (!installedGames.some((game) => game.appid === target.appid)) return true; - try { - await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); - return true; + let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; + try { + integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded); + const finalStateResult = await setWorkaroundState( + target.appid, + state, + target.nonSteam ? (integration.originalExecutable || originalExecutable || null) : null, + integration.commandTokenAdded, + ); + if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state"); + return true; + } catch (error) { + let rollbackSucceeded = true; + if (!initialIntegration && integration) { + try { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + target.nonSteam ? (integration?.originalExecutable || originalExecutable) : undefined, + integration?.commandTokenAdded ?? oldCommandTokenAdded, + ); + } catch (rollbackError) { + showErrorToast("Workaround rollback failed", asError(rollbackError).message); + rollbackSucceeded = false; + } + } + if (rollbackSucceeded) { + const restored = oldState + ? await setWorkaroundState(target.appid, oldState, oldShortcutExe || null, oldCommandTokenAdded) + : await removeWorkaroundState(target.appid); + if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state"); + } + throw error; + } } catch (error) { - showErrorToast("Could not clean up Steam launch options", error instanceof Error ? error.message : String(error)); + showErrorToast("Could not initialize workarounds", asError(error).message); return false; } }, [installedGames]); - const initializeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { if (!installedGames.some((game) => game.appid === target.appid)) return true; + const appId = Number(target.appid); try { - await updateSteamLaunchOptions( - Number(target.appid), - target.nonSteam, - (options) => applyWorkaroundState(options, getDefaultWorkaroundState()), - ); + const existing = await getWorkaroundState(target.appid); + if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); + const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + if (existing.state) { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + existing.shortcut_exe || undefined, + existing.command_token_added === true, + ); + } else { + const current = await readSteamLaunchOptions(appId, target.nonSteam); + if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { + throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); + } + await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); + } + const removed = await removeWorkaroundState(target.appid); + if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); return true; } catch (error) { - showErrorToast("Could not initialize Steam launch options", error instanceof Error ? error.message : String(error)); + showErrorToast("Could not clean up game workarounds", asError(error).message); return false; } }, [installedGames]); @@ -135,37 +216,46 @@ export function useGameConfiguration() { const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; - if (cleanupLaunchOptions && !(await cleanupTargetLaunchOptions(selectedTarget))) return; + // The profile owns its wrapper integration. Keep this check on every + // configuration save so an external edit is detected before the profile + // is changed; toggles update the sidecar only. + if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return; const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); - }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); + }, [ensureTargetWorkarounds, load, selectedAppId, targets]); const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; - if (!(await initializeTargetLaunchOptions(target))) return false; + if (!(await ensureTargetWorkarounds(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); + else await removeTargetWorkarounds(target); return result.success; - }, [initializeTargetLaunchOptions, load, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); const enableAll = useCallback(async (): Promise<void> => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; for (const target of available) { - if (!(await initializeTargetLaunchOptions(target))) return; + if (!(await ensureTargetWorkarounds(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); + await removeTargetWorkarounds(target); return; } } await load(); - }, [initializeTargetLaunchOptions, load, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const repair = useCallback(async (appid: string): Promise<boolean> => { + const target = targets.find((item) => item.appid === appid); + return target ? ensureTargetWorkarounds(target) : false; + }, [ensureTargetWorkarounds, targets]); const resetSelected = useCallback(async () => { if (selectedAppId) { const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (selectedTarget && !(await removeTargetLaunchOptions(selectedTarget))) return; + if (selectedTarget && !(await removeTargetWorkarounds(selectedTarget))) return; const result = await resetGameConfig(selectedAppId); if (result.success) { setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); @@ -173,10 +263,10 @@ export function useGameConfiguration() { await load(); } } - }, [load, removeTargetLaunchOptions, selectedAppId, targets]); + }, [load, removeTargetWorkarounds, selectedAppId, targets]); const resetAll = useCallback(async () => { for (const target of targets.filter((item) => item.configured)) { - if (!(await removeTargetLaunchOptions(target))) return; + if (!(await removeTargetWorkarounds(target))) return; } const result = await resetAllGameConfigs(); if (result.success) { @@ -184,7 +274,7 @@ export function useGameConfiguration() { setSelectedAppId(""); await load(); } - }, [load, removeTargetLaunchOptions, targets]); + }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index a937780..9e283db 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -1,29 +1,50 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - applyWorkaroundChange, - parseWorkaroundOptions, + getWorkaroundState, + removeWorkaroundState, + setWorkaroundState, + type WorkaroundState, +} from "../api/lsfgApi"; +import { + getDefaultWrapperPath, + hasWrapperLaunchIntegration, + installWrapperIntegration, + isLegacyWrapperToken, readSteamLaunchOptions, + removeWrapperIntegration, subscribeSteamLaunchOptions, - updateSteamLaunchOptions, - type ParsedWorkaroundOptions, type SteamLaunchOptionsSnapshot, - type WorkaroundField, } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; +export type WorkaroundField = keyof WorkaroundState; export type WorkaroundLoadStatus = "loading" | "ready" | "error"; const SLIDER_DEBOUNCE_MS = 250; +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + interface PendingSliderUpdate { timer: number; value: number; waiters: Array<(success: boolean) => void>; } -interface WorkaroundSnapshot { +export interface WorkaroundSnapshot { steam: SteamLaunchOptionsSnapshot; - parsed: ParsedWorkaroundOptions; + state: WorkaroundState; + wrapperPath: string; + wrapperOwned: boolean; + integrationInstalled: boolean; + commandTokenAdded: boolean; + shortcutExe?: string | null; } interface PerAppWorkarounds { @@ -38,8 +59,84 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function makeSnapshot(steam: SteamLaunchOptionsSnapshot): WorkaroundSnapshot { - return { steam, parsed: parseWorkaroundOptions(steam.options) }; +function integrationIsInstalled( + steam: SteamLaunchOptionsSnapshot, + nonSteam: boolean, + wrapperPath: string, +): boolean { + return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath); +} + +function makeSnapshot( + steam: SteamLaunchOptionsSnapshot, + result: Awaited<ReturnType<typeof getWorkaroundState>>, + nonSteam: boolean, +): WorkaroundSnapshot { + if (!result.state) throw new Error("Workaround state is not initialized for this profile"); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) { + throw new Error("Managed shortcut Target has no saved original executable"); + } + return { + steam, + state: result.state, + wrapperPath, + wrapperOwned: result.wrapper_owned === true, + integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath), + commandTokenAdded: result.command_token_added === true, + shortcutExe: result.shortcut_exe, + }; +} + +async function adoptWorkaroundState( + appId: string, + nonSteam: boolean, + steam: SteamLaunchOptionsSnapshot, + wrapperPath: string, +): Promise<WorkaroundSnapshot> { + if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { + throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); + } + const originalExecutable = nonSteam ? steam.target : null; + const initial = await setWorkaroundState(appId, DEFAULT_WORKAROUND_STATE, originalExecutable, false); + if (!initial.success) throw new Error(initial.error || "Could not create workaround state"); + let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; + try { + integration = await installWrapperIntegration( + Number(appId), + nonSteam, + wrapperPath, + ); + const finalized = await setWorkaroundState( + appId, + DEFAULT_WORKAROUND_STATE, + nonSteam ? (integration.originalExecutable || originalExecutable) : null, + integration.commandTokenAdded, + ); + if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); + return makeSnapshot(integration.snapshot, finalized, nonSteam); + } catch (error) { + let rollbackSucceeded = true; + if (integration) { + try { + await removeWrapperIntegration( + Number(appId), + nonSteam, + wrapperPath, + nonSteam ? (integration?.originalExecutable || originalExecutable || undefined) : undefined, + integration?.commandTokenAdded ?? false, + ); + } catch { + // Leave the owned integration in place rather than guessing at cleanup. + rollbackSucceeded = false; + } + } + if (rollbackSucceeded) { + const removed = await removeWorkaroundState(appId); + if (!removed.success) throw new Error(removed.error || "Could not roll back workaround state"); + } + throw error; + } } export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { @@ -49,8 +146,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo const pendingSliderUpdate = useRef<PendingSliderUpdate | null>(null); const numericAppId = Number(appId); - const applySnapshot = useCallback((steam: SteamLaunchOptionsSnapshot) => { - setSnapshot(makeSnapshot(steam)); + const loadSnapshot = useCallback(async () => { + const [result, steam] = await Promise.all([ + getWorkaroundState(appId), + readSteamLaunchOptions(numericAppId, nonSteam), + ]); + if (!result.success) throw new Error(result.error || "Could not read workaround state"); + if (!result.state) { + return adoptWorkaroundState( + appId, + nonSteam, + steam, + result.wrapper_path || getDefaultWrapperPath(), + ); + } + return makeSnapshot(steam, result, nonSteam); + }, [appId, nonSteam, numericAppId]); + + const applySnapshot = useCallback((next: WorkaroundSnapshot) => { + setSnapshot(next); setStatus("ready"); setError(null); }, []); @@ -59,65 +173,79 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo setStatus("loading"); setError(null); try { - applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam)); + applySnapshot(await loadSnapshot()); } catch (refreshError) { const nextError = asError(refreshError); setStatus("error"); setError(nextError.message); } - }, [applySnapshot, nonSteam, numericAppId]); + }, [applySnapshot, loadSnapshot]); useEffect(() => { let active = true; setStatus("loading"); setSnapshot(null); setError(null); - - const handleSnapshot = (nextSnapshot: SteamLaunchOptionsSnapshot) => { - if (!active) return; - applySnapshot(nextSnapshot); - }; - const handleSubscriptionError = (subscriptionError: Error) => { - if (!active) return; - setStatus("error"); - setError(subscriptionError.message); - }; - let unsubscribe = () => {}; try { unsubscribe = subscribeSteamLaunchOptions( numericAppId, nonSteam, - handleSnapshot, - handleSubscriptionError, + (steam) => { + if (!active) return; + setSnapshot((current) => current ? { + ...current, + steam, + integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath), + } : current); + }, + (subscriptionError) => { + if (!active) return; + setStatus("error"); + setError(subscriptionError.message); + }, ); } catch (subscriptionError) { - handleSubscriptionError(asError(subscriptionError)); + if (active) { + setStatus("error"); + setError(asError(subscriptionError).message); + } } - - void readSteamLaunchOptions(numericAppId, nonSteam) - .then((nextSnapshot) => { - if (active) applySnapshot(nextSnapshot); - }) + void loadSnapshot() + .then((next) => { if (active) applySnapshot(next); }) .catch((readError) => { - if (active) handleSubscriptionError(asError(readError)); + if (active) { + setStatus("error"); + setError(asError(readError).message); + } }); - return () => { active = false; unsubscribe(); }; - }, [applySnapshot, nonSteam, numericAppId]); + }, [applySnapshot, loadSnapshot, nonSteam, numericAppId]); const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => { + const current = snapshot; + if (!current) return false; setError(null); + const nextState = { ...current.state, [field]: value } as WorkaroundState; try { - const nextSnapshot = await updateSteamLaunchOptions( - numericAppId, - nonSteam, - (options) => applyWorkaroundChange(options, field, value), + const result = await setWorkaroundState( + appId, + nextState, + current.shortcutExe ?? null, + current.commandTokenAdded, ); - applySnapshot(nextSnapshot); + if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); + applySnapshot({ + ...current, + state: result.state, + wrapperPath: result.wrapper_path || current.wrapperPath, + wrapperOwned: result.wrapper_owned === true, + shortcutExe: result.shortcut_exe, + commandTokenAdded: result.command_token_added === true, + }); return true; } catch (updateError) { const nextError = asError(updateError); @@ -126,14 +254,13 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo showErrorToast("Workaround update failed", nextError.message); return false; } - }, [applySnapshot, nonSteam, numericAppId]); + }, [appId, applySnapshot, snapshot]); const flushSliderUpdate = useCallback(async (): Promise<boolean> => { const pending = pendingSliderUpdate.current; if (!pending) return true; - pendingSliderUpdate.current = null; - window.clearTimeout(pending.timer); + clearTimeout(pending.timer); const success = await persistUpdate("dxvkFrameRate", pending.value); pending.waiters.forEach((resolve) => resolve(success)); return success; @@ -143,30 +270,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo if (field === "dxvkFrameRate") { setError(null); return new Promise<boolean>((resolve) => { - const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] }; + const pending = pendingSliderUpdate.current || { timer: 0, value: 0, waiters: [] }; window.clearTimeout(pending.timer); pending.value = Number(value); pending.waiters.push(resolve); - pending.timer = window.setTimeout(() => { - void flushSliderUpdate(); - }, SLIDER_DEBOUNCE_MS); + pending.timer = window.setTimeout(() => { void flushSliderUpdate(); }, SLIDER_DEBOUNCE_MS); pendingSliderUpdate.current = pending; }); } - const sliderSuccess = await flushSliderUpdate(); if (!sliderSuccess) return false; return persistUpdate(field, value); }, [flushSliderUpdate, persistUpdate]); - useEffect(() => { - return () => { - const pending = pendingSliderUpdate.current; - if (!pending) return; - window.clearTimeout(pending.timer); - pendingSliderUpdate.current = null; - pending.waiters.forEach((resolve) => resolve(false)); - }; + useEffect(() => () => { + const pending = pendingSliderUpdate.current; + if (!pending) return; + window.clearTimeout(pending.timer); + pendingSliderUpdate.current = null; + pending.waiters.forEach((resolve) => resolve(false)); }, [numericAppId, nonSteam]); return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]); diff --git a/src/types.d.ts b/src/types.d.ts index 4b88d3d..7b5d055 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -30,6 +30,7 @@ interface SteamApps { ): SteamAppDetailsRegistration; SetAppLaunchOptions(appId: number, options: string): void | Promise<void>; SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>; + SetShortcutExe(appId: number, executable: string): void | Promise<void>; GetAllShortcuts?(): Promise<unknown[]>; } diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts deleted file mode 100644 index 9449b33..0000000 --- a/src/utils/steamLaunchOptionParser.ts +++ /dev/null @@ -1,525 +0,0 @@ -export interface WorkaroundState { - dxvkFrameRate: number; - disableGamescopeWsi: boolean; - disableHdr: boolean; - disableSteamdeckMode: boolean; - disableVkbasalt: boolean; - enableZink: boolean; -} - -export type WorkaroundField = keyof WorkaroundState; - -export interface ParsedWorkaroundOptions { - state: WorkaroundState; - issues: string[]; -} - -interface LaunchToken { - raw: string; - value: string; -} - -interface EnvironmentEntry { - value: string; - count: number; -} - -type BooleanWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">; -type EnvironmentSpec = readonly [key: string, value: string]; -type DxvkFrameRateKey = "dxvk.maxFrameRate" | "dxgi.maxFrameRate" | "d3d9.maxFrameRate"; - -interface WorkaroundDefinition { - spec: EnvironmentSpec; - clear: readonly string[]; - label?: string; -} - -const COMMAND_TOKEN = "%command%"; -const LEGACY_WRAPPER_TOKENS = new Set([ - "~/lsfg", - "/home/deck/lsfg", - "~/.local/bin/lsfg-vk-experimental", - "/home/deck/.local/bin/lsfg-vk-experimental", - "~/.local/bin/mako-run", - "/home/deck/.local/bin/mako-run", - "mako-run", - "~/.local/bin/mako-launch", - "/home/deck/.local/bin/mako-launch", - "mako-launch", -]); -const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [ - "dxvk.maxFrameRate", - "dxgi.maxFrameRate", - "d3d9.maxFrameRate", -]; -const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]); -const WORKAROUND_DEFINITIONS = { - disableGamescopeWsi: { - spec: ["ENABLE_GAMESCOPE_WSI", "0"], - clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI"], - }, - disableHdr: { - spec: ["DXVK_HDR", "0"], - clear: ["DXVK_HDR"], - label: "Disable HDR", - }, - disableSteamdeckMode: { - spec: ["SteamDeck", "0"], - clear: ["SteamDeck"], - label: "Steam Deck mode", - }, - disableVkbasalt: { - spec: ["DISABLE_VKBASALT", "1"], - clear: ["DISABLE_VKBASALT"], - label: "Disable vkBasalt", - }, - enableZink: { - spec: ["MESA_LOADER_DRIVER_OVERRIDE", "zink"], - clear: ["__GLX_VENDOR_LIBRARY_NAME", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER"], - }, -} as const satisfies Record<BooleanWorkaroundField, WorkaroundDefinition>; -const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [ - "disableGamescopeWsi", - "disableHdr", - "disableSteamdeckMode", - "disableVkbasalt", - "enableZink", -]; -const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI"; -const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI"; -const WORKAROUND_ENV_KEYS = new Set( - BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), -); -const MANAGED_ENV_KEYS = new Set([ - ...DXVK_MANAGED_KEYS, - ...WORKAROUND_ENV_KEYS, -]); - -const EMPTY_WORKAROUND_STATE: WorkaroundState = { - dxvkFrameRate: 0, - disableGamescopeWsi: false, - disableHdr: false, - disableSteamdeckMode: false, - disableVkbasalt: false, - enableZink: false, -}; -const DEFAULT_WORKAROUND_STATE: WorkaroundState = { - ...EMPTY_WORKAROUND_STATE, - disableGamescopeWsi: true, - disableHdr: true, -}; - -export function getDefaultWorkaroundState(): WorkaroundState { - return { ...DEFAULT_WORKAROUND_STATE }; -} - -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 { - if (tokens.length === 1 && tokens[0].raw.toLowerCase() === COMMAND_TOKEN) return ""; - return tokens.map((token) => token.raw).join(" "); -} - -export function normalizeLaunchOptions(options: string): string { - return serialize(tokenize(options)); -} - -function parseEnvironmentToken(token: LaunchToken): [string, string] | null { - const separator = token.value.indexOf("="); - if (separator < 1) return null; - const key = token.value.slice(0, separator); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null; - return [key, token.value.slice(separator + 1)]; -} - -function findCommandIndex(tokens: readonly LaunchToken[]): number { - return tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); -} - -function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number { - let count = 0; - while (count < tokens.length && parseEnvironmentToken(tokens[count]) !== null) count += 1; - return count; -} - -function effectivePrefixLimit(tokens: readonly LaunchToken[]): number { - return leadingEnvironmentCount(tokens); -} - -function effectiveEnvironmentEntries(tokens: readonly LaunchToken[]): Map<string, EnvironmentEntry> { - const entries = new Map<string, EnvironmentEntry>(); - for (let index = 0; index < effectivePrefixLimit(tokens); index += 1) { - const parsed = parseEnvironmentToken(tokens[index]); - if (!parsed) continue; - const [key, value] = parsed; - const previous = entries.get(key); - entries.set(key, { value, count: (previous?.count || 0) + 1 }); - } - return entries; -} - -function removePrefixAssignments(tokens: LaunchToken[], predicate: (token: LaunchToken) => boolean): boolean { - const limit = effectivePrefixLimit(tokens); - const retained = tokens.filter((token, index) => index >= limit || !predicate(token)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -function removeAllAssignments(tokens: LaunchToken[], keys: ReadonlySet<string>): boolean { - return removePrefixAssignments(tokens, (token) => { - const parsed = parseEnvironmentToken(token); - return parsed !== null && keys.has(parsed[0]); - }); -} - -function encodeEnvironmentValue(value: string): string { - if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function insertEnvironmentSpecs(tokens: LaunchToken[], specs: readonly EnvironmentSpec[]): void { - tokens.unshift(...specs.map(([key, value]) => ({ - raw: `${key}=${encodeEnvironmentValue(value)}`, - value: `${key}=${value}`, - }))); -} - -function ensureCommandToken(tokens: LaunchToken[]): void { - if (findCommandIndex(tokens) >= 0) return; - tokens.splice(leadingEnvironmentCount(tokens), 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); -} - -export function isLegacyWrapperToken(value: string): boolean { - const path = decodeToken(value); - return LEGACY_WRAPPER_TOKENS.has(path); -} - -function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean { - const commandIndex = findCommandIndex(tokens); - const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length; - const retained = tokens.filter((token, index) => index >= prefixEnd || !isLegacyWrapperToken(token.raw)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -interface DxvkConfigAssignment { - values: string[]; - malformed: number; -} - -interface ParsedDxvkConfig { - segments: string[]; - assignments: Map<DxvkFrameRateKey, DxvkConfigAssignment>; -} - -function splitDxvkConfig(value: string): string[] { - const segments: string[] = []; - let start = 0; - let quote: "'" | '"' | null = null; - let escaped = false; - - for (let index = 0; index < value.length; index += 1) { - const character = value[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 (character === ";") { - segments.push(value.slice(start, index)); - start = index + 1; - } - } - - segments.push(value.slice(start)); - return segments; -} - -function knownDxvkKey(value: string): DxvkFrameRateKey | null { - const key = value.match(/^([A-Za-z][A-Za-z0-9.]*)/)?.[1]; - return key && DXVK_FRAME_RATE_KEYS.includes(key as DxvkFrameRateKey) - ? key as DxvkFrameRateKey - : null; -} - -function parseDxvkConfig(value: string): ParsedDxvkConfig { - const segments = splitDxvkConfig(value); - const assignments = new Map<DxvkFrameRateKey, DxvkConfigAssignment>(); - for (const segment of segments) { - const trimmed = segment.trim(); - const key = knownDxvkKey(trimmed); - if (!key) continue; - const match = trimmed.match(/^[A-Za-z][A-Za-z0-9.]*\s*=\s*(.*?)\s*$/); - const entry = assignments.get(key) || { values: [], malformed: 0 }; - if (match) entry.values.push(match[1]); - else entry.malformed += 1; - assignments.set(key, entry); - } - return { segments, assignments }; -} - -function isDxvkFrameRateSegment(segment: string): boolean { - return knownDxvkKey(segment.trim()) !== null; -} - -function parseSupportedFrameRate(value: string): number | null { - if (!/^\d+$/.test(value)) return null; - const numericValue = Number(value); - return Number.isSafeInteger(numericValue) && numericValue <= 60 ? numericValue : null; -} - -function rewriteDxvkFrameRate(tokens: LaunchToken[], frameRate: number): void { - const config = effectiveEnvironmentEntries(tokens).get("DXVK_CONFIG"); - const parsed = parseDxvkConfig(config?.value || ""); - const retained = parsed.segments - .filter((segment) => !isDxvkFrameRateSegment(segment)) - .filter((segment) => segment.trim().length > 0) - .join(";"); - const nextConfig = frameRate > 0 - ? [`dxvk.maxFrameRate = ${frameRate}`, ...(retained ? [retained] : [])].join(";") - : retained; - - removeAllAssignments(tokens, DXVK_MANAGED_KEYS); - if (nextConfig) { - ensureCommandToken(tokens); - insertEnvironmentSpecs(tokens, [["DXVK_CONFIG", nextConfig]]); - } -} - -function environmentSpecsForState(state: WorkaroundState): EnvironmentSpec[] { - return BOOLEAN_WORKAROUND_FIELDS - .filter((field) => state[field]) - .map((field) => WORKAROUND_DEFINITIONS[field].spec); -} - -function validateFrameRate(frameRate: number): void { - if (!Number.isInteger(frameRate) || frameRate < 0 || frameRate > 60) { - throw new Error("Base FPS Cap must be an integer from 0 to 60"); - } -} - -function readBoolean( - entries: Map<string, EnvironmentEntry>, - key: string, - label: string, - trueValue: string, - issues: string[], -): boolean { - const entry = entries.get(key); - if (!entry) return false; - const falseValue = trueValue === "1" ? "0" : "1"; - if (entry.value === trueValue) return true; - if (entry.value === falseValue) return false; - issues.push(`${label} has an unsupported value.`); - return false; -} - -export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions { - const tokens = tokenize(options); - const entries = effectiveEnvironmentEntries(tokens); - const state = { ...EMPTY_WORKAROUND_STATE }; - const issues: string[] = []; - - for (const [key, entry] of entries) { - if (MANAGED_ENV_KEYS.has(key) && entry.count > 1) { - issues.push(`${key} appears more than once; Steam uses the last value.`); - } - } - - const dxvkConfig = parseDxvkConfig(entries.get("DXVK_CONFIG")?.value || ""); - const effectiveDxvkValues = new Map<DxvkFrameRateKey, number | null>(); - for (const key of DXVK_FRAME_RATE_KEYS) { - const assignment = dxvkConfig.assignments.get(key); - if (!assignment) continue; - if (assignment.malformed > 0) issues.push(`${key} in DXVK_CONFIG is malformed.`); - if (assignment.values.length > 1) { - issues.push(`${key} appears more than once in DXVK_CONFIG; DXVK uses the last value.`); - } - if (assignment.values.length === 0) continue; - const value = parseSupportedFrameRate(assignment.values[assignment.values.length - 1]); - effectiveDxvkValues.set(key, value); - if (value === null) issues.push(`${key} in DXVK_CONFIG is outside the supported 0-60 range.`); - } - - const unifiedFrameRate = effectiveDxvkValues.get("dxvk.maxFrameRate"); - const dxgiFrameRate = effectiveDxvkValues.get("dxgi.maxFrameRate"); - const d3d9FrameRate = effectiveDxvkValues.get("d3d9.maxFrameRate"); - if (unifiedFrameRate !== undefined) { - if (unifiedFrameRate !== null) state.dxvkFrameRate = unifiedFrameRate; - } else if (dxgiFrameRate !== undefined && d3d9FrameRate !== undefined) { - if (dxgiFrameRate !== null && dxgiFrameRate === d3d9FrameRate) state.dxvkFrameRate = dxgiFrameRate; - else issues.push("DXVK_CONFIG has conflicting or invalid DirectX frame caps."); - } else if (dxgiFrameRate !== undefined || d3d9FrameRate !== undefined) { - const partial = dxgiFrameRate ?? d3d9FrameRate; - if (partial !== null && partial !== undefined) state.dxvkFrameRate = partial; - issues.push("DXVK_CONFIG only caps one DirectX API; adjust the cap to normalize it."); - } - - if (entries.has("DXVK_FRAME_RATE")) { - issues.push("DXVK_FRAME_RATE is obsolete on current DXVK; adjust the cap to migrate it."); - } - - const wsiSignals: boolean[] = []; - const wsiDisable = entries.get(WSI_DISABLE_KEY); - if (wsiDisable) { - if (wsiDisable.value !== "0" && wsiDisable.value !== "1") issues.push("Disable Gamescope WSI has an unsupported value."); - else wsiSignals.push(wsiDisable.value === "1"); - } - const wsiEnable = entries.get(WSI_ENABLE_KEY); - if (wsiEnable) { - if (wsiEnable.value !== "0" && wsiEnable.value !== "1") issues.push("Enable Gamescope WSI has an unsupported value."); - else wsiSignals.push(wsiEnable.value === "0"); - } - if (wsiSignals.length > 0) { - if (wsiSignals.length === 2 && wsiSignals[0] !== wsiSignals[1]) { - issues.push("Gamescope WSI has conflicting enable and disable assignments."); - } - state.disableGamescopeWsi = wsiSignals.some(Boolean); - } - - for (const field of ["disableHdr", "disableSteamdeckMode", "disableVkbasalt"] as const) { - const { spec, label } = WORKAROUND_DEFINITIONS[field]; - state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues); - } - - const vkBasaltEnable = entries.get("ENABLE_VKBASALT"); - const vkBasaltDisable = entries.get("DISABLE_VKBASALT"); - if (vkBasaltEnable?.value === "1" && vkBasaltDisable?.value === "1") { - issues.push("vkBasalt has conflicting enable and disable assignments."); - } - - const zink = entries.get(WORKAROUND_DEFINITIONS.enableZink.spec[0]); - const glxVendor = entries.get("__GLX_VENDOR_LIBRARY_NAME"); - const galliumDriver = entries.get("GALLIUM_DRIVER"); - const hasLegacyZink = glxVendor !== undefined || galliumDriver !== undefined; - if (zink || hasLegacyZink) { - state.enableZink = zink?.value === WORKAROUND_DEFINITIONS.enableZink.spec[1]; - if (hasLegacyZink && ( - glxVendor?.value !== "mesa" || - zink?.value !== WORKAROUND_DEFINITIONS.enableZink.spec[1] || - galliumDriver?.value !== "zink" - )) { - issues.push("Zink workaround is only partially configured."); - } else if (!state.enableZink) { - issues.push("Zink workaround has an unsupported driver value."); - } - } - - return { state, issues }; -} - -export function applyWorkaroundState(options: string, state: WorkaroundState): string { - validateFrameRate(state.dxvkFrameRate); - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - rewriteDxvkFrameRate(tokens, state.dxvkFrameRate); - const keysToClear = new Set<string>( - WORKAROUND_ENV_KEYS, - ); - if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT"); - removeAllAssignments(tokens, keysToClear); - const specs = environmentSpecsForState(state); - if (specs.length > 0) { - ensureCommandToken(tokens); - insertEnvironmentSpecs(tokens, specs); - } - return serialize(tokens); -} - -export function applyWorkaroundChange(options: string, field: WorkaroundField, value: boolean | number): string { - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - - if (field === "dxvkFrameRate") { - if (typeof value !== "number") throw new Error("Base FPS Cap must be an integer from 0 to 60"); - validateFrameRate(value); - rewriteDxvkFrameRate(tokens, value); - return serialize(tokens); - } - - if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`); - const definition = WORKAROUND_DEFINITIONS[field]; - const keysToClear = new Set<string>(definition.clear); - if (value && field === "disableVkbasalt") keysToClear.add("ENABLE_VKBASALT"); - removeAllAssignments(tokens, keysToClear); - if (value) { - ensureCommandToken(tokens); - insertEnvironmentSpecs(tokens, [definition.spec]); - } - return serialize(tokens); -} - -export function cleanupLegacyLaunchOptions(options: string): string { - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - return serialize(tokens); -} - -export function cleanupPluginLaunchOptions(options: string): string { - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - rewriteDxvkFrameRate(tokens, 0); - removeAllAssignments(tokens, WORKAROUND_ENV_KEYS); - return serialize(tokens); -} - -export function cleanupLegacyWrapper(options: string): string { - return cleanupLegacyLaunchOptions(options); -} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 82ff94b..e00b32d 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,16 +1,52 @@ -// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyLaunchOptions, cleanupPluginLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +const DEFAULT_WRAPPER_PATH = "~/.lsfg"; +const COMMAND_TOKEN = "%command%"; -// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -export * from "./steamLaunchOptionParser.ts"; +export const LEGACY_WRAPPER_TOKENS = new Set([ + "~/lsfg", + "~/.local/bin/lsfg", + "~/.local/bin/lsfg-vk-experimental", + "~/.local/bin/mako-run", + "mako-run", + "~/.local/bin/mako-launch", + "mako-launch", +]); + +const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/; + +const MANAGED_ENV_KEYS = new Set([ + "ENABLE_GAMESCOPE_WSI", + "DISABLE_GAMESCOPE_WSI", + "DXVK_HDR", + "SteamDeck", + "DISABLE_VKBASALT", + "ENABLE_VKBASALT", + "MESA_LOADER_DRIVER_OVERRIDE", + "__GLX_VENDOR_LIBRARY_NAME", + "GALLIUM_DRIVER", + "DXVK_FRAME_RATE", +]); + +const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i; + +interface LaunchToken { + raw: string; + value: string; +} export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; + target: string; details: SteamAppDetails; } +export interface WrapperIntegrationResult { + snapshot: SteamLaunchOptionsSnapshot; + originalExecutable?: string; + commandTokenAdded: boolean; +} + function validateAppId(appId: number): void { if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } @@ -21,14 +57,30 @@ function getSteamApps(): Partial<SteamApps> | undefined { }).SteamClient?.Apps; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { - if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) { - throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first"); +interface TimerHost { + setTimeout(handler: () => void, timeout: number): number; + clearTimeout(timeout: number): void; +} + +function timerHost(): TimerHost { + if (typeof window !== "undefined") { + return { + setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), + clearTimeout: (timeout) => window.clearTimeout(timeout), + }; } return { + setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, + clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + }; +} + +function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { + return { appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", + target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -44,7 +96,7 @@ function registerSteamAppDetails( validateAppId(appId); const apps = getSteamApps(); const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable"); + if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); let active = true; let unregisterPending = false; @@ -58,7 +110,7 @@ function registerSteamAppDetails( try { registration.unregister(); } catch { - // Steam may invalidate registrations during a details refresh. + // Steam can invalidate a registration while details are refreshing. } }; @@ -71,7 +123,7 @@ function registerSteamAppDetails( try { registration.unregister(); } catch { - // The registration can be invalidated before a synchronous callback returns. + // A synchronous callback can invalidate the registration before return. } } } catch (error) { @@ -83,25 +135,21 @@ function registerSteamAppDetails( export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> { return new Promise((resolve, reject) => { let settled = false; - let timeout = 0; + let timeout: number | undefined; let unsubscribe = () => {}; const finish = (error?: unknown, details?: SteamAppDetails) => { if (settled) return; settled = true; - window.clearTimeout(timeout); + if (timeout !== undefined) timerHost().clearTimeout(timeout); unsubscribe(); if (error) { reject(asError(error)); return; } - try { - resolve(snapshotFromDetails(appId, nonSteam, details || {})); - } catch (snapshotError) { - reject(asError(snapshotError)); - } + resolve(snapshotFromDetails(appId, nonSteam, details || {})); }; - timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000); + timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { unsubscribe = registerSteamAppDetails(appId, (details) => { finish(undefined, details); @@ -128,6 +176,224 @@ export function subscribeSteamLaunchOptions( }); } +function decodeToken(raw: string): string { + let value = ""; + let quote: "'" | '"' | null = null; + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === "\\" && quote !== "'" && index + 1 < raw.length) { + value += raw[index + 1]; + index += 1; + } else if (quote !== null) { + if (character === quote) quote = null; + else value += character; + } else if (character === "'" || character === '"') { + quote = character; + } else { + value += character; + } + } + return value; +} + +function tokenize(options: string): LaunchToken[] { + const tokens: LaunchToken[] = []; + let start = -1; + let quote: "'" | '"' | null = null; + let escaped = false; + for (let index = 0; index < options.length; index += 1) { + const character = options[index]; + if (start < 0) { + if (/\s/.test(character)) continue; + start = index; + } + if (escaped) escaped = false; + else if (character === "\\" && quote !== "'") escaped = true; + else if (quote !== null) { + if (character === quote) quote = null; + } else if (character === "'" || character === '"') quote = character; + else if (/\s/.test(character)) { + const raw = options.slice(start, index); + tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + } + } + if (start >= 0) { + const raw = options.slice(start); + tokens.push({ raw, value: decodeToken(raw) }); + } + return tokens; +} + +function serialize(tokens: readonly LaunchToken[]): string { + return tokens.map((token) => token.raw).join(" "); +} + +export function normalizeLaunchOptions(options: string): string { + return serialize(tokenize(options)); +} + +function isCommandToken(token: LaunchToken): boolean { + return token.raw.toLowerCase() === COMMAND_TOKEN; +} + +function commandIndex(tokens: readonly LaunchToken[]): number { + return tokens.findIndex(isCommandToken); +} + +function isAssignment(token: LaunchToken): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); +} + +function isLegacyToken(value: string): boolean { + return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); +} + +export function isLegacyWrapperToken(value: string): boolean { + return isLegacyToken(decodeToken(value)); +} + +function isWrapperToken(value: string, wrapperPath: string): boolean { + return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); +} + +function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean { + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function removeLegacyTokens(tokens: LaunchToken[]): boolean { + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function leadingAssignments(tokens: readonly LaunchToken[]): number { + let count = 0; + while (count < tokens.length && isAssignment(tokens[count])) count += 1; + return count; +} + +function wrapperToken(wrapperPath: string): LaunchToken { + return { raw: wrapperPath, value: wrapperPath }; +} + +export interface LaunchOptionRewrite { + options: string; + commandTokenAdded: boolean; +} + +/** Add one exact wrapper token immediately before Steam's command macro. */ +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite { + const tokens = tokenize(options); + removeLegacyTokens(tokens); + let index = commandIndex(tokens); + if (index >= 0) { + const currentWrapper = tokens[index - 1]; + if (currentWrapper && currentWrapper.value === wrapperPath) { + return { options: serialize(tokens), commandTokenAdded: false }; + } + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath); + tokens.splice(0, tokens.length, ...retained); + index = commandIndex(tokens); + tokens.splice(index, 0, wrapperToken(wrapperPath)); + return { options: serialize(tokens), commandTokenAdded: false }; + } + + const insertion = leadingAssignments(tokens); + const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); + if (tokens.length !== insertion && !argumentsOnly) { + throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); + } + tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + return { options: serialize(tokens), commandTokenAdded: true }; +} + +/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */ +export function removeWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + commandTokenAdded = false, +): string { + const tokens = tokenize(options); + const removed = removeWrapperTokens(tokens, wrapperPath); + if (removed && commandTokenAdded) { + const index = commandIndex(tokens); + if (index >= 0) tokens.splice(index, 1); + } + return serialize(tokens); +} + +function encodeAssignmentValue(value: string): string { + if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function cleanDxvkConfigValue(value: string): string | null { + const retained = value + .split(";") + .map((segment) => segment.trim()) + .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment)); + return retained.length > 0 ? retained.join("; ") : null; +} + +/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */ +export function cleanupPluginAssignments(options: string): string { + const tokens = tokenize(options); + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained: LaunchToken[] = []; + for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { + const token = tokens[tokenIndex]; + if (tokenIndex >= prefixEnd || !isAssignment(token)) { + retained.push(token); + continue; + } + const separator = token.value.indexOf("="); + const key = token.value.slice(0, separator); + if (key === "DXVK_CONFIG") { + const cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); + if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); + continue; + } + if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); + } + return serialize(retained); +} + +export function cleanupLegacyLaunchOptions(options: string): string { + const tokens = tokenize(options); + removeLegacyTokens(tokens); + return serialize(tokens); +} + +export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { + const tokens = tokenize(options); + removeWrapperTokens(tokens, wrapperPath); + return cleanupPluginAssignments(serialize(tokens)); +} + +export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { + return cleanupPluginLaunchOptions(options, wrapperPath); +} + +export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { + const tokens = tokenize(options); + const index = commandIndex(tokens); + return index > 0 && tokens[index - 1].value === wrapperPath; +} + +function delay(milliseconds: number): Promise<void> { + return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); +} + async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> { const apps = getSteamApps(); const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; @@ -135,49 +401,96 @@ async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: await Promise.resolve(setter.call(apps, appId, options)); } -function delay(milliseconds: number): Promise<void> { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +async function setShortcutExecutable(appId: number, executable: string): Promise<void> { + const apps = getSteamApps(); + if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); + await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); } -async function waitForLaunchOptions( +async function waitForSnapshot( appId: number, nonSteam: boolean, - expected: string, + matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean, + message: string, ): Promise<SteamLaunchOptionsSnapshot> { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { const snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot; + if (matches(snapshot)) return snapshot; } catch (error) { lastError = asError(error); } if (Date.now() >= deadline) break; await delay(100); } - if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`); - throw new Error("Steam did not accept the launch options before the readback timeout"); + if (lastError) throw new Error(`${message}: ${lastError.message}`); + throw new Error(`${message} before the readback timeout`); } -const operationQueues = new Map<string, Promise<void>>(); +async function writeLaunchOptionsAndVerify( + appId: number, + nonSteam: boolean, + previous: string, + next: string, + message: string, +): Promise<SteamLaunchOptionsSnapshot> { + try { + await setSteamLaunchOptions(appId, nonSteam, next); + return await waitForSnapshot( + appId, + nonSteam, + (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next), + message, + ); + } catch (error) { + const failure = asError(error); + try { + await setSteamLaunchOptions(appId, nonSteam, previous); + await waitForSnapshot( + appId, + nonSteam, + (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous), + "Steam did not restore the previous launch options", + ); + } catch (rollbackError) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + } + throw failure; + } +} -function queueKey(appId: number, nonSteam: boolean): string { - return `${nonSteam ? "shortcut" : "app"}:${appId}`; +async function writeShortcutExecutableAndVerify( + appId: number, + previous: string, + next: string, + message: string, +): Promise<SteamLaunchOptionsSnapshot> { + try { + await setShortcutExecutable(appId, next); + return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + } catch (error) { + const failure = asError(error); + try { + await setShortcutExecutable(appId, previous); + await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target"); + } catch (rollbackError) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + } + throw failure; + } } -function queueSteamAppOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { - const key = queueKey(appId, nonSteam); +const operationQueues = new Map<string, Promise<unknown>>(); + +function queueSteamOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; const previous = operationQueues.get(key) || Promise.resolve(); const queued = previous.catch(() => undefined).then(operation); - let cleanup: Promise<void>; - cleanup = queued.then( - () => { - if (operationQueues.get(key) === cleanup) operationQueues.delete(key); - }, - () => { - if (operationQueues.get(key) === cleanup) operationQueues.delete(key); - }, + const cleanup = queued.then( + () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, + () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, ); operationQueues.set(key, cleanup); return queued; @@ -188,37 +501,96 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); }); } -export function cleanupSteamLaunchOptions( +export function installWrapperIntegration( appId: number, nonSteam: boolean, + wrapperPath: string, + commandTokenAdded = false, +): Promise<WrapperIntegrationResult> { + return queueSteamOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + if (nonSteam) { + if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); + if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { + throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); + } + const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleanedOptions !== current.options) { + await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options"); + } + if (current.target === wrapperPath) { + return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false }; + } + const originalExecutable = current.target; + const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target"); + return { snapshot, originalExecutable, commandTokenAdded: false }; + } + + const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); + const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); + const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); + if (rewrite.options === current.options) { + return { snapshot: current, commandTokenAdded }; + } + const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options"); + return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + }); +} + +export function removeWrapperIntegration( + appId: number, + nonSteam: boolean, + wrapperPath: string, + originalExecutable?: string, + commandTokenAdded = false, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options); + if (nonSteam) { + if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } + if (current.target !== wrapperPath && current.target !== originalExecutable) { + throw new Error("Shortcut Target changed externally; refusing to restore it"); + } + const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleaned !== current.options) { + await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); + } + if (current.target === originalExecutable) { + return readSteamLaunchOptions(appId, true); + } + return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target"); + } + + const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); + const next = cleanupPluginAssignments(withoutWrapper); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); }); } export function cleanupLegacySteamLaunchOptions( appId: number, nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupLegacyLaunchOptions(current.options); + const next = cleanupPluginLaunchOptions(current.options, wrapperPath); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); }); } + +export function getDefaultWrapperPath(): string { + return DEFAULT_WRAPPER_PATH; +} |
