From 3d813ea87335298be5a47de3441f410651851b71 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 3 Apr 2026 10:06:44 -0400 Subject: feat: add Steam game picker with one-click launch option setter --- src/components/SteamGamePatcher.tsx | 277 ++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/components/SteamGamePatcher.tsx (limited to 'src/components/SteamGamePatcher.tsx') diff --git a/src/components/SteamGamePatcher.tsx b/src/components/SteamGamePatcher.tsx new file mode 100644 index 0000000..5947f01 --- /dev/null +++ b/src/components/SteamGamePatcher.tsx @@ -0,0 +1,277 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ButtonItem, DropdownItem, Field, PanelSectionRow } from "@decky/ui"; +import { listInstalledGames } from "../api"; +import { createAutoCleanupTimer } from "../utils"; +import { TIMEOUTS } from "../utils/constants"; + +// ─── SteamClient helpers ───────────────────────────────────────────────────── + +/** + * Wrap the callback-based RegisterForAppDetails in a Promise. + * Resolves with the current launch options string, or "" if SteamClient is + * unavailable (e.g. desktop / dev mode). Times out after 5 seconds. + */ +const getSteamLaunchOptions = (appId: number): Promise => + new Promise((resolve, reject) => { + if ( + typeof SteamClient === "undefined" || + !SteamClient?.Apps?.RegisterForAppDetails + ) { + resolve(""); + return; + } + + let settled = false; + let unregister = () => {}; + + const timeout = window.setTimeout(() => { + if (settled) return; + settled = true; + unregister(); + reject(new Error("Timed out reading launch options.")); + }, 5000); + + const registration = SteamClient.Apps.RegisterForAppDetails( + appId, + (details: { strLaunchOptions?: string }) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + unregister(); + resolve(details?.strLaunchOptions ?? ""); + } + ); + + unregister = registration.unregister; + }); + +const setSteamLaunchOptions = (appId: number, options: string): void => { + if ( + typeof SteamClient === "undefined" || + !SteamClient?.Apps?.SetAppLaunchOptions + ) { + throw new Error("SteamClient.Apps.SetAppLaunchOptions is not available."); + } + SteamClient.Apps.SetAppLaunchOptions(appId, options); +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Remove any fgmod invocation from a launch options string, keeping the rest. */ +const stripFgmod = (opts: string): string => + opts + .replace(/DLL=\S+\s+~\/fgmod\/fgmod\s+%command%/g, "") + .replace(/~\/fgmod\/fgmod\s+%command%/g, "") + .trim(); + +/** Extract the DLL= value from a launch options string, if present. */ +const extractDllName = (opts: string): string | null => { + const m = opts.match(/DLL=(\S+)\s+~\/fgmod\/fgmod/); + return m ? m[1] : null; +}; + +// ─── Component ─────────────────────────────────────────────────────────────── + +interface SteamGamePatcherProps { + dllName: string; +} + +type GameEntry = { appid: string; name: string }; + +export function SteamGamePatcher({ dllName }: SteamGamePatcherProps) { + const [games, setGames] = useState([]); + const [gamesLoading, setGamesLoading] = useState(true); + const [selectedAppId, setSelectedAppId] = useState(""); + const [launchOptions, setLaunchOptions] = useState(""); + const [launchOptionsLoading, setLaunchOptionsLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [resultMessage, setResultMessage] = useState(""); + + // Auto-clear result message + useEffect(() => { + if (resultMessage) { + return createAutoCleanupTimer( + () => setResultMessage(""), + TIMEOUTS.resultDisplay + ); + } + return undefined; + }, [resultMessage]); + + // Load game list on mount + useEffect(() => { + let cancelled = false; + (async () => { + setGamesLoading(true); + try { + const result = await listInstalledGames(); + if (cancelled) return; + if (result.status === "success" && result.games.length > 0) { + setGames(result.games); + setSelectedAppId(result.games[0].appid); + } + } catch (e) { + console.error("SteamGamePatcher: failed to load games", e); + } finally { + if (!cancelled) setGamesLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Reload launch options when selected game changes + useEffect(() => { + if (!selectedAppId) { + setLaunchOptions(""); + return; + } + let cancelled = false; + (async () => { + setLaunchOptionsLoading(true); + try { + const opts = await getSteamLaunchOptions(Number(selectedAppId)); + if (!cancelled) setLaunchOptions(opts); + } catch { + if (!cancelled) setLaunchOptions(""); + } finally { + if (!cancelled) setLaunchOptionsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedAppId]); + + const targetCommand = `DLL=${dllName} ~/fgmod/fgmod %command%`; + const isManaged = launchOptions.includes("fgmod/fgmod"); + const activeDll = useMemo(() => extractDllName(launchOptions), [launchOptions]); + const selectedGame = useMemo( + () => games.find((g) => g.appid === selectedAppId) ?? null, + [games, selectedAppId] + ); + + const handleSet = useCallback(() => { + if (!selectedAppId || busy) return; + setBusy(true); + try { + setSteamLaunchOptions(Number(selectedAppId), targetCommand); + setLaunchOptions(targetCommand); + setResultMessage( + `✅ Launch options set for ${selectedGame?.name ?? selectedAppId}` + ); + } catch (e) { + setResultMessage(`❌ ${e instanceof Error ? e.message : String(e)}`); + } finally { + setBusy(false); + } + }, [selectedAppId, targetCommand, selectedGame, busy]); + + const handleRemove = useCallback(() => { + if (!selectedAppId || busy) return; + setBusy(true); + try { + const stripped = stripFgmod(launchOptions); + setSteamLaunchOptions(Number(selectedAppId), stripped); + setLaunchOptions(stripped); + setResultMessage( + `✅ Removed fgmod from ${selectedGame?.name ?? selectedAppId}` + ); + } catch (e) { + setResultMessage(`❌ ${e instanceof Error ? e.message : String(e)}`); + } finally { + setBusy(false); + } + }, [selectedAppId, launchOptions, selectedGame, busy]); + + // ── Status display ────────────────────────────────────────────────────────── + const statusText = useMemo(() => { + if (!selectedGame) return "—"; + if (launchOptionsLoading) return "Loading..."; + if (!isManaged) return "Not set"; + if (activeDll && activeDll !== dllName) + return `Active — ${activeDll} · switch to apply ${dllName}`; + return `Active — ${activeDll ?? dllName}`; + }, [selectedGame, launchOptionsLoading, isManaged, activeDll, dllName]); + + const statusColor = useMemo(() => { + if (!isManaged || launchOptionsLoading) return undefined; + if (activeDll && activeDll !== dllName) return "#ffd866"; // yellow — different DLL selected + return "#3fb950"; // green — active and matching + }, [isManaged, launchOptionsLoading, activeDll, dllName]); + + const setButtonLabel = useMemo(() => { + if (busy) return "Applying..."; + if (!isManaged) return "Enable for this game"; + if (activeDll && activeDll !== dllName) return `Switch to ${dllName}`; + return "Re-apply"; + }, [busy, isManaged, activeDll, dllName]); + + return ( + <> + + ({ data: g.appid, label: g.name }))} + onChange={(option) => { + setSelectedAppId(String(option.data)); + setResultMessage(""); + }} + /> + + + {selectedGame && ( + <> + + + {statusColor ? ( + + {statusText} + + ) : ( + statusText + )} + + + + + + {setButtonLabel} + + + + {isManaged && ( + + + {busy ? "Removing..." : "Remove from launch options"} + + + )} + + {resultMessage && ( + + + {resultMessage} + + + )} + + )} + + ); +} -- cgit v1.2.3