import { useCallback, useEffect, useMemo, useState } from "react"; import { ButtonItem, DropdownItem, Field, PanelSectionRow } from "@decky/ui"; import { toaster } from "@decky/api"; import { listInstalledGames, getGameStatus, patchGame, unpatchGame } from "../api"; import { FSR4_VARIANT_OPTIONS } from "../utils/constants"; type SteamAppsApi = { RegisterForAppDetails?: ( appId: number, callback: (details: { strLaunchOptions?: string }) => void ) => { unregister: () => void }; SetAppLaunchOptions?: (appId: number, options: string) => void; }; const getSteamApps = (): SteamAppsApi | undefined => { if (typeof SteamClient === "undefined") return undefined; return SteamClient.Apps as unknown as SteamAppsApi; }; const getAppLaunchOptions = (appId: number): Promise => new Promise((resolve, reject) => { const apps = getSteamApps(); if (!apps || typeof apps.RegisterForAppDetails !== "function" || typeof apps.SetAppLaunchOptions !== "function") { reject(new Error("Steam launch-options API is unavailable; patching was not started.")); 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 = apps.RegisterForAppDetails( appId, (details: { strLaunchOptions?: string }) => { if (settled) return; settled = true; window.clearTimeout(timeout); unregister(); resolve(details?.strLaunchOptions ?? ""); } ); unregister = registration.unregister; if (settled) unregister(); }); const setAppLaunchOptions = (appId: number, options: string): void => { const apps = getSteamApps(); if (!apps || typeof apps.SetAppLaunchOptions !== "function") { throw new Error("Steam launch-options API is unavailable; launch options were not changed."); } apps.SetAppLaunchOptions(appId, options); }; const canManageLaunchOptions = (): boolean => { const apps = getSteamApps(); return Boolean( apps && typeof apps.RegisterForAppDetails === "function" && typeof apps.SetAppLaunchOptions === "function" ); }; type GameEntry = { appid: string; name: string; install_found?: boolean }; type GameStatus = { status: "success" | "error"; message?: string; install_found?: boolean; patched?: boolean; dll_name?: string | null; target_dir?: string | null; patched_at?: string | null; optiscaler_version?: string | null; fsr4_variant?: string | null; fsr4_variant_label?: string | null; fsr4_upscaler_sha256?: string | null; framegen_backend?: string | null; framegen_backend_label?: string | null; }; let lastSelectedAppId = ""; interface SteamGamePatcherProps { dllName: string; fsr4Variant: string; framegenBackend: string; } export function SteamGamePatcher({ dllName, fsr4Variant, framegenBackend }: SteamGamePatcherProps) { const [games, setGames] = useState([]); const [gamesLoading, setGamesLoading] = useState(true); const [selectedAppId, setSelectedAppId] = useState(() => lastSelectedAppId); const [gameStatus, setGameStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(false); const [busyAction, setBusyAction] = useState<"patch" | "unpatch" | null>(null); const [resultMessage, setResultMessage] = useState(""); const loadGames = useCallback(async () => { setGamesLoading(true); try { const result = await listInstalledGames(); if (result.status !== "success") throw new Error(result.message || "Failed to load games."); const gameList = result.games as GameEntry[]; setGames(gameList); if (!gameList.length) { lastSelectedAppId = ""; setSelectedAppId(""); return; } setSelectedAppId((current) => { const valid = current && gameList.some((g) => g.appid === current) ? current : gameList[0].appid; lastSelectedAppId = valid; return valid; }); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to load games."; toaster.toast({ title: "Decky Framegen", body: msg }); } finally { setGamesLoading(false); } }, []); const loadStatus = useCallback(async (appid: string) => { if (!appid) { setGameStatus(null); return; } setStatusLoading(true); try { const result = await getGameStatus(appid); setGameStatus(result as GameStatus); } catch (err) { setGameStatus({ status: "error", message: err instanceof Error ? err.message : "Failed to load status.", }); } finally { setStatusLoading(false); } }, []); useEffect(() => { void loadGames(); }, [loadGames]); useEffect(() => { if (!selectedAppId) { setGameStatus(null); return; } void loadStatus(selectedAppId); }, [selectedAppId, loadStatus]); const selectedGame = useMemo( () => games.find((g) => g.appid === selectedAppId) ?? null, [games, selectedAppId] ); const selectedVariantLabel = useMemo( () => FSR4_VARIANT_OPTIONS.find((option) => option.value === fsr4Variant)?.label ?? fsr4Variant, [fsr4Variant] ); const isPatchedWithDifferentDll = gameStatus?.patched && gameStatus?.dll_name && gameStatus.dll_name !== dllName; const canPatch = Boolean(selectedGame && gameStatus?.install_found && !busyAction); const canUnpatch = Boolean(selectedGame && gameStatus?.patched && !busyAction); const patchButtonLabel = useMemo(() => { if (busyAction === "patch") return "Patching..."; if (!selectedGame) return "Patch this game"; if (!gameStatus?.install_found) return "Install not found"; if (isPatchedWithDifferentDll) return `Switch to ${dllName}`; if (gameStatus?.patched) return `Reinstall (${dllName})`; return `Patch with ${dllName}`; }, [busyAction, dllName, gameStatus, isPatchedWithDifferentDll, selectedGame]); const handlePatch = useCallback(async () => { if (!selectedGame || !selectedAppId || busyAction) return; setBusyAction("patch"); setResultMessage(""); try { if (!canManageLaunchOptions()) { throw new Error("Steam launch-options API is unavailable; patching was not started."); } const currentLaunchOptions = await getAppLaunchOptions(Number(selectedAppId)); const result = await patchGame(selectedAppId, dllName, currentLaunchOptions, fsr4Variant, framegenBackend); if (result.status !== "success") throw new Error(result.message || "Patch failed."); setAppLaunchOptions(Number(selectedAppId), result.launch_options || ""); const msg = result.message || `Patched ${selectedGame.name}.`; setResultMessage(msg); toaster.toast({ title: "Decky Framegen", body: msg }); await loadStatus(selectedAppId); } catch (err) { const msg = err instanceof Error ? err.message : "Patch failed."; setResultMessage(`Error: ${msg}`); toaster.toast({ title: "Decky Framegen", body: msg }); } finally { setBusyAction(null); } }, [busyAction, dllName, framegenBackend, fsr4Variant, loadStatus, selectedAppId, selectedGame]); const handleUnpatch = useCallback(async () => { if (!selectedGame || !selectedAppId || busyAction) return; setBusyAction("unpatch"); setResultMessage(""); try { if (!canManageLaunchOptions()) { throw new Error("Steam launch-options API is unavailable; unpatching was not started."); } const result = await unpatchGame(selectedAppId); if (result.status !== "success") throw new Error(result.message || "Unpatch failed."); setAppLaunchOptions(Number(selectedAppId), result.launch_options || ""); const msg = result.message || `Unpatched ${selectedGame.name}.`; setResultMessage(msg); toaster.toast({ title: "Decky Framegen", body: msg }); await loadStatus(selectedAppId); } catch (err) { const msg = err instanceof Error ? err.message : "Unpatch failed."; setResultMessage(`Error: ${msg}`); toaster.toast({ title: "Decky Framegen", body: msg }); } finally { setBusyAction(null); } }, [busyAction, loadStatus, selectedAppId, selectedGame]); const statusDisplay = useMemo(() => { if (!selectedGame) return { text: "—", color: undefined as string | undefined }; if (statusLoading) return { text: "Loading...", color: undefined }; if (!gameStatus || gameStatus.status === "error") return { text: gameStatus?.message || "—", color: undefined }; if (!gameStatus.install_found) return { text: "Install not found", color: "#ffd866" }; if (!gameStatus.patched) return { text: "Not patched", color: undefined }; const dllLabel = gameStatus.dll_name || "unknown"; if (isPatchedWithDifferentDll) return { text: `Patched (${dllLabel}) — switch available`, color: "#ffd866" }; return { text: `Patched (${dllLabel})`, color: "#3fb950" }; }, [gameStatus, isPatchedWithDifferentDll, selectedGame, statusLoading]); const focusableFieldProps = { focusable: true, highlightOnFocus: true } as const; return ( <> ({ data: g.appid, label: g.install_found === false ? `${g.name} (not installed)` : g.name, }))} onChange={(option) => { const next = String(option.data); lastSelectedAppId = next; setSelectedAppId(next); setResultMessage(""); }} /> {selectedGame && ( <> {statusDisplay.color ? ( {statusDisplay.text} ) : ( statusDisplay.text )} {gameStatus?.patched ? (gameStatus?.fsr4_variant_label || "Unknown") : `Will patch with ${selectedVariantLabel}`} {gameStatus?.patched ? (gameStatus?.framegen_backend_label || gameStatus?.framegen_backend || "Unknown") : `Will patch with ${framegenBackend}`} {patchButtonLabel} {canUnpatch && ( {busyAction === "unpatch" ? "Unpatching..." : "Unpatch this game"} )} void loadStatus(selectedAppId)} > {statusLoading ? "Refreshing..." : "Refresh status"} {resultMessage && ( {resultMessage} )} )} ); }