summaryrefslogtreecommitdiff
path: root/src/components/SteamGamePatcher.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/SteamGamePatcher.tsx')
-rw-r--r--src/components/SteamGamePatcher.tsx314
1 files changed, 314 insertions, 0 deletions
diff --git a/src/components/SteamGamePatcher.tsx b/src/components/SteamGamePatcher.tsx
new file mode 100644
index 0000000..2d3b0fa
--- /dev/null
+++ b/src/components/SteamGamePatcher.tsx
@@ -0,0 +1,314 @@
+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";
+
+// ─── SteamClient helpers ─────────────────────────────────────────────────────
+
+const getAppLaunchOptions = (appId: number): Promise<string> =>
+ 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 setAppLaunchOptions = (appId: number, options: string): void => {
+ if (typeof SteamClient !== "undefined" && SteamClient?.Apps?.SetAppLaunchOptions) {
+ SteamClient.Apps.SetAppLaunchOptions(appId, options);
+ }
+};
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+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;
+};
+
+// ─── Module-level state persistence ──────────────────────────────────────────
+
+let lastSelectedAppId = "";
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+interface SteamGamePatcherProps {
+ dllName: string;
+ fsr4Variant: string;
+}
+
+export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps) {
+ const [games, setGames] = useState<GameEntry[]>([]);
+ const [gamesLoading, setGamesLoading] = useState(true);
+ const [selectedAppId, setSelectedAppId] = useState<string>(() => lastSelectedAppId);
+ const [gameStatus, setGameStatus] = useState<GameStatus | null>(null);
+ const [statusLoading, setStatusLoading] = useState(false);
+ const [busyAction, setBusyAction] = useState<"patch" | "unpatch" | null>(null);
+ const [resultMessage, setResultMessage] = useState<string>("");
+
+ // ── Data loaders ───────────────────────────────────────────────────────────
+
+ 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]);
+
+ // ── Derived state ──────────────────────────────────────────────────────────
+
+ const selectedGame = useMemo(
+ () => games.find((g) => g.appid === selectedAppId) ?? null,
+ [games, selectedAppId]
+ );
+
+ 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]);
+
+ // ── Actions ────────────────────────────────────────────────────────────────
+
+ const handlePatch = useCallback(async () => {
+ if (!selectedGame || !selectedAppId || busyAction) return;
+ setBusyAction("patch");
+ setResultMessage("");
+ try {
+ let currentLaunchOptions = "";
+ try {
+ currentLaunchOptions = await getAppLaunchOptions(Number(selectedAppId));
+ } catch {
+ // non-fatal: proceed without current launch options
+ }
+ const result = await patchGame(selectedAppId, dllName, currentLaunchOptions, fsr4Variant);
+ 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, fsr4Variant, loadStatus, selectedAppId, selectedGame]);
+
+ const handleUnpatch = useCallback(async () => {
+ if (!selectedGame || !selectedAppId || busyAction) return;
+ setBusyAction("unpatch");
+ setResultMessage("");
+ try {
+ 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]);
+
+ // ── Status display ─────────────────────────────────────────────────────────
+
+ 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;
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ return (
+ <>
+ <PanelSectionRow>
+ <DropdownItem
+ label="Steam game"
+ menuLabel="Select a Steam game"
+ strDefaultLabel={gamesLoading ? "Loading games..." : "Choose a game"}
+ disabled={gamesLoading || games.length === 0}
+ selectedOption={selectedAppId}
+ rgOptions={games.map((g) => ({
+ 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("");
+ }}
+ />
+ </PanelSectionRow>
+
+ {selectedGame && (
+ <>
+ <PanelSectionRow>
+ <Field {...focusableFieldProps} label="Patch status">
+ {statusDisplay.color ? (
+ <span style={{ color: statusDisplay.color, fontWeight: 600 }}>
+ {statusDisplay.text}
+ </span>
+ ) : (
+ statusDisplay.text
+ )}
+ </Field>
+ </PanelSectionRow>
+
+ <PanelSectionRow>
+ <Field {...focusableFieldProps} label="FSR4 runtime">
+ {gameStatus?.patched
+ ? (gameStatus?.fsr4_variant_label || "Unknown")
+ : (fsr4Variant === "rdna4-native"
+ ? "Will patch with Native bundle / RDNA4"
+ : "Will patch with Steam Deck / RDNA2-3 optimized")}
+ </Field>
+ </PanelSectionRow>
+
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={!canPatch} onClick={handlePatch}>
+ {patchButtonLabel}
+ </ButtonItem>
+ </PanelSectionRow>
+
+ {canUnpatch && (
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busyAction !== null}
+ onClick={handleUnpatch}
+ >
+ {busyAction === "unpatch" ? "Unpatching..." : "Unpatch this game"}
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
+
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={!selectedAppId || busyAction !== null || statusLoading}
+ onClick={() => void loadStatus(selectedAppId)}
+ >
+ {statusLoading ? "Refreshing..." : "Refresh status"}
+ </ButtonItem>
+ </PanelSectionRow>
+
+ {resultMessage && (
+ <PanelSectionRow>
+ <Field {...focusableFieldProps} label="Result">
+ {resultMessage}
+ </Field>
+ </PanelSectionRow>
+ )}
+ </>
+ )}
+ </>
+ );
+}