summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/ClipboardCommands.tsx42
-rw-r--r--src/components/CustomPathOverride.tsx18
-rw-r--r--src/components/InstalledGamesSection.tsx12
-rw-r--r--src/components/OptiScalerControls.tsx134
-rw-r--r--src/components/ResultDisplay.tsx4
-rw-r--r--src/components/SteamGamePatcher.tsx314
-rw-r--r--src/components/index.ts1
7 files changed, 486 insertions, 39 deletions
diff --git a/src/components/ClipboardCommands.tsx b/src/components/ClipboardCommands.tsx
index 5a6f38f..124423e 100644
--- a/src/components/ClipboardCommands.tsx
+++ b/src/components/ClipboardCommands.tsx
@@ -2,22 +2,44 @@ import { SmartClipboardButton } from "./SmartClipboardButton";
interface ClipboardCommandsProps {
pathExists: boolean | null;
+ dllName: string;
+ manualModeEnabled?: boolean;
+ showLaunchOptions?: boolean;
}
-export function ClipboardCommands({ pathExists }: ClipboardCommandsProps) {
+export function ClipboardCommands({
+ pathExists,
+ dllName,
+ manualModeEnabled = false,
+ showLaunchOptions = true,
+}: ClipboardCommandsProps) {
if (pathExists !== true) return null;
+ const launchCmd =
+ dllName === "OptiScaler.asi"
+ ? "SteamDeck=0 %command%"
+ : `WINEDLLOVERRIDES=${dllName.replace(".dll", "")}=n,b SteamDeck=0 %command%`;
+
return (
<>
- <SmartClipboardButton
- command="~/fgmod/fgmod %command%"
- buttonText="Copy Patch Command"
- />
-
- <SmartClipboardButton
- command="~/fgmod/fgmod-uninstaller.sh %command%"
- buttonText="Copy Unpatch Command"
- />
+ {showLaunchOptions ? (
+ <SmartClipboardButton
+ command={launchCmd}
+ buttonText="Copy launch options"
+ />
+ ) : null}
+ {manualModeEnabled ? (
+ <>
+ <SmartClipboardButton
+ command="~/fgmod/fgmod %command%"
+ buttonText="Copy Patch Command"
+ />
+ <SmartClipboardButton
+ command="~/fgmod/fgmod-uninstaller.sh %command%"
+ buttonText="Copy Unpatch Command"
+ />
+ </>
+ ) : null}
</>
);
}
diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx
index ffc4b1f..af47735 100644
--- a/src/components/CustomPathOverride.tsx
+++ b/src/components/CustomPathOverride.tsx
@@ -36,6 +36,8 @@ const ensureDirectory = (value: string) => {
interface ManualPatchControlsProps {
isAvailable: boolean;
onManualModeChange?: (enabled: boolean) => void;
+ dllName: string;
+ fsr4Variant: string;
}
interface PickerState {
@@ -56,7 +58,7 @@ const formatResultMessage = (result: ApiResponse | null) => {
return result.message || result.output || "Operation failed.";
};
-export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualPatchControlsProps) => {
+export const ManualPatchControls = ({ isAvailable, onManualModeChange, dllName, fsr4Variant }: ManualPatchControlsProps) => {
const [isEnabled, setEnabled] = useState(false);
const [defaults, setDefaults] = useState<PathDefaults>(INITIAL_DEFAULTS);
const [pickerState, setPickerState] = useState<PickerState>(INITIAL_PICKER_STATE);
@@ -165,7 +167,7 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualP
try {
const response =
action === "patch"
- ? await runManualPatch(selectedPath)
+ ? await runManualPatch(selectedPath, dllName, fsr4Variant)
: await runManualUnpatch(selectedPath);
setOperationResult(response ?? { status: "error", message: "No response from backend." });
} catch (err) {
@@ -177,7 +179,7 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualP
setBusy(false);
}
},
- [selectedPath]
+ [selectedPath, dllName, fsr4Variant]
);
const handleToggle = (value: boolean) => {
@@ -216,7 +218,11 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualP
{canInteract && (
<>
<SmartClipboardButton
- command='WINEDLLOVERRIDES="dxgi=n,b" SteamDeck=0 %command%'
+ command={
+ dllName === "OptiScaler.asi"
+ ? "SteamDeck=0 %command%"
+ : `WINEDLLOVERRIDES="${dllName.replace(".dll", "")}=n,b" SteamDeck=0 %command%`
+ }
buttonText="Manual launch cmd"
/>
<PanelSectionRow>
@@ -234,9 +240,7 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualP
<Field
label="Picker error"
description={pickerState.lastError}
- >
- ⚠️
- </Field>
+ />
</PanelSectionRow>
)}
diff --git a/src/components/InstalledGamesSection.tsx b/src/components/InstalledGamesSection.tsx
index 71278d7..e0e2677 100644
--- a/src/components/InstalledGamesSection.tsx
+++ b/src/components/InstalledGamesSection.tsx
@@ -42,14 +42,14 @@ export function InstalledGamesSection() {
<ConfirmModal
strTitle={`Enable Frame Generation for ${selectedGame.name}?`}
strDescription={
- "⚠️ Important: This plugin does not automatically unpatch games when uninstalled. If you uninstall this plugin or experience game issues, use the 'Disable Frame Generation' option or verify game file integrity through Steam."
+ "Important: This plugin does not automatically unpatch games when uninstalled. If you uninstall this plugin or experience game issues, use the 'Disable Frame Generation' option or verify game file integrity through Steam."
}
strOKButtonText="Enable Frame Generation"
strCancelButtonText="Cancel"
onOK={async () => {
try {
- await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod %COMMAND%');
- setResult(`✓ Frame generation enabled for ${selectedGame.name}. Launch the game, enable DLSS in graphics settings, then press Insert to access OptiScaler options.`);
+ await SteamClient.Apps.SetAppLaunchOptions(Number(selectedGame.appid), '~/fgmod/fgmod %COMMAND%');
+ setResult(`Frame generation enabled for ${selectedGame.name}. Launch the game, enable DLSS in graphics settings, then press Insert to access OptiScaler options.`);
} catch (error) {
logError('handlePatchClick: ' + String(error));
setResult(error instanceof Error ? `Error: ${error.message}` : 'Error enabling frame generation');
@@ -63,8 +63,8 @@ export function InstalledGamesSection() {
if (!selectedGame) return;
try {
- await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod-uninstaller.sh %COMMAND%');
- setResult(`✓ Frame generation will be disabled on next launch of ${selectedGame.name}.`);
+ await SteamClient.Apps.SetAppLaunchOptions(Number(selectedGame.appid), '~/fgmod/fgmod-uninstaller.sh %COMMAND%');
+ setResult(`Frame generation will be disabled on next launch of ${selectedGame.name}.`);
} catch (error) {
logError('handleUnpatchClick: ' + String(error));
setResult(error instanceof Error ? `Error: ${error.message}` : 'Error disabling frame generation');
@@ -96,7 +96,7 @@ export function InstalledGamesSection() {
...STYLES.preWrap,
...(result.includes('Error') ? STYLES.statusNotInstalled : STYLES.statusInstalled)
}}>
- {result.includes('Error') ? '❌' : '✅'} {result}
+ {result}
</div>
</PanelSectionRow>
) : null}
diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx
index 468683c..2167fcd 100644
--- a/src/components/OptiScalerControls.tsx
+++ b/src/components/OptiScalerControls.tsx
@@ -1,9 +1,9 @@
import { useState, useEffect } from "react";
-import { PanelSection } from "@decky/ui";
-import { runInstallFGMod, runUninstallFGMod } from "../api";
+import { DropdownItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui";
+import { runInstallFGMod, runUninstallFGMod, setDefaultFsr4Variant } from "../api";
import { OperationResult } from "./ResultDisplay";
import { createAutoCleanupTimer } from "../utils";
-import { TIMEOUTS } from "../utils/constants";
+import { TIMEOUTS, PROXY_DLL_OPTIONS, DEFAULT_PROXY_DLL, FSR4_VARIANT_OPTIONS, DEFAULT_FSR4_VARIANT } from "../utils/constants";
import { InstallationStatus } from "./InstallationStatus";
import { OptiScalerHeader } from "./OptiScalerHeader";
import { ClipboardCommands } from "./ClipboardCommands";
@@ -11,18 +11,33 @@ import { InstructionCard } from "./InstructionCard";
import { OptiScalerWiki } from "./OptiScalerWiki";
import { UninstallButton } from "./UninstallButton";
import { ManualPatchControls } from "./CustomPathOverride";
+import { SteamGamePatcher } from "./SteamGamePatcher";
+
+interface FgmodInfo {
+ exists: boolean;
+ version?: string | null;
+ selected_fsr4_variant?: string | null;
+ selected_fsr4_variant_label?: string | null;
+ install_manifest_present?: boolean;
+}
interface OptiScalerControlsProps {
pathExists: boolean | null;
setPathExists: (exists: boolean | null) => void;
+ fgmodInfo?: FgmodInfo | null;
}
-export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerControlsProps) {
+export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: OptiScalerControlsProps) {
const [installing, setInstalling] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [installResult, setInstallResult] = useState<OperationResult | null>(null);
const [uninstallResult, setUninstallResult] = useState<OperationResult | null>(null);
- const [manualModeEnabled, setManualModeEnabled] = useState(false);
+ const [advancedModeEnabled, setAdvancedModeEnabled] = useState(false);
+ const [manualClipboardModeEnabled, setManualClipboardModeEnabled] = useState(false);
+ const [dllName, setDllName] = useState<string>(DEFAULT_PROXY_DLL);
+ const [fsr4Variant, setFsr4Variant] = useState<string>(DEFAULT_FSR4_VARIANT);
+ const [fsr4VariantTouched, setFsr4VariantTouched] = useState(false);
+ const [switchingVariant, setSwitchingVariant] = useState(false);
useEffect(() => {
if (installResult) {
return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay);
@@ -37,10 +52,17 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont
return () => {}; // Ensure a cleanup function is always returned
}, [uninstallResult]);
+ useEffect(() => {
+ const installedVariant = fgmodInfo?.selected_fsr4_variant;
+ if (!fsr4VariantTouched && installedVariant && FSR4_VARIANT_OPTIONS.some((option) => option.value === installedVariant)) {
+ setFsr4Variant(installedVariant);
+ }
+ }, [fgmodInfo?.selected_fsr4_variant, fsr4VariantTouched]);
+
const handleInstallClick = async () => {
try {
setInstalling(true);
- const result = await runInstallFGMod();
+ const result = await runInstallFGMod(fsr4Variant);
setInstallResult(result);
if (result.status === "success") {
setPathExists(true);
@@ -67,6 +89,31 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont
}
};
+ const handleFsr4VariantChange = async (nextVariant: string) => {
+ const previousVariant = fsr4Variant;
+ setFsr4Variant(nextVariant);
+ setFsr4VariantTouched(true);
+
+ if (pathExists !== true) return;
+
+ try {
+ setSwitchingVariant(true);
+ const result = await setDefaultFsr4Variant(nextVariant);
+ if (result.status !== "success") {
+ throw new Error(result.message || result.output || "Failed to switch default FSR4 runtime.");
+ }
+ setFsr4Variant(result.selected_default_variant || nextVariant);
+ setFsr4VariantTouched(false);
+ } catch (error) {
+ console.error(error);
+ setFsr4Variant(previousVariant);
+ } finally {
+ setSwitchingVariant(false);
+ }
+ };
+
+ const installedVariantLabel = fgmodInfo?.selected_fsr4_variant_label || FSR4_VARIANT_OPTIONS.find((option) => option.value === fsr4Variant)?.label;
+
return (
<PanelSection>
<InstallationStatus
@@ -76,18 +123,77 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont
/>
<OptiScalerHeader pathExists={pathExists} />
-
+
+ <PanelSectionRow>
+ <DropdownItem
+ label="Default FSR4 runtime"
+ description={FSR4_VARIANT_OPTIONS.find((option) => option.value === fsr4Variant)?.hint}
+ menuLabel="Default FSR4 runtime"
+ selectedOption={fsr4Variant}
+ rgOptions={FSR4_VARIANT_OPTIONS.map((option) => ({ data: option.value, label: option.label }))}
+ disabled={installing || uninstalling || switchingVariant}
+ onChange={(option) => {
+ void handleFsr4VariantChange(String(option.data));
+ }}
+ />
+ </PanelSectionRow>
+
+ {pathExists === true && fgmodInfo?.version && installedVariantLabel && (
+ <PanelSectionRow>
+ <Field label="Installed bundle" description={`OptiScaler ${fgmodInfo.version}`}>
+ {installedVariantLabel}
+ </Field>
+ </PanelSectionRow>
+ )}
+
+ {pathExists === true && (
+ <PanelSectionRow>
+ <DropdownItem
+ label="Proxy DLL name"
+ description={PROXY_DLL_OPTIONS.find((o) => o.value === dllName)?.hint}
+ menuLabel="Proxy DLL name"
+ selectedOption={dllName}
+ rgOptions={PROXY_DLL_OPTIONS.map((o) => ({ data: o.value, label: o.label }))}
+ onChange={(option) => setDllName(String(option.data))}
+ />
+ </PanelSectionRow>
+ )}
+
+ {pathExists === true && (
+ <SteamGamePatcher dllName={dllName} fsr4Variant={fsr4Variant} />
+ )}
+
+ <ClipboardCommands pathExists={pathExists} dllName={dllName} />
+
+ {pathExists === true && (
+ <PanelSectionRow>
+ <ToggleField
+ label="Manual Mode"
+ description="Show wrapper command clipboard buttons for patching and unpatching through ~/fgmod scripts."
+ checked={manualClipboardModeEnabled}
+ onChange={setManualClipboardModeEnabled}
+ />
+ </PanelSectionRow>
+ )}
+
+ {pathExists === true && manualClipboardModeEnabled ? (
+ <ClipboardCommands
+ pathExists={pathExists}
+ dllName={dllName}
+ manualModeEnabled
+ showLaunchOptions={false}
+ />
+ ) : null}
+
<ManualPatchControls
isAvailable={pathExists === true}
- onManualModeChange={setManualModeEnabled}
+ onManualModeChange={setAdvancedModeEnabled}
+ dllName={dllName}
+ fsr4Variant={fsr4Variant}
/>
- {!manualModeEnabled && (
- <>
- <ClipboardCommands pathExists={pathExists} />
-
- <InstructionCard pathExists={pathExists} />
- </>
+ {!advancedModeEnabled && (
+ <InstructionCard pathExists={pathExists} />
)}
<OptiScalerWiki pathExists={pathExists} />
diff --git a/src/components/ResultDisplay.tsx b/src/components/ResultDisplay.tsx
index bcd66c0..b54e41d 100644
--- a/src/components/ResultDisplay.tsx
+++ b/src/components/ResultDisplay.tsx
@@ -19,13 +19,13 @@ export const ResultDisplay: FC<ResultDisplayProps> = ({ result }) => {
<div style={isSuccess ? STYLES.statusInstalled : STYLES.statusNotInstalled}>
{isSuccess ? (
<>
- ✅ {result.output?.includes("uninstall") || result.output?.includes("remov")
+ {result.output?.includes("uninstall") || result.output?.includes("remov")
? "OptiScaler mod removed successfully"
: "OptiScaler mod installed successfully"}
</>
) : (
<>
- ❌ <strong>Error:</strong> {result.message || "Operation failed"}
+ <strong>Error:</strong> {result.message || "Operation failed"}
</>
)}
{result.output && !isSuccess && (
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>
+ )}
+ </>
+ )}
+ </>
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index cd599ba..ad47347 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -9,3 +9,4 @@ export { UninstallButton } from './UninstallButton';
export { SmartClipboardButton } from './SmartClipboardButton';
export { ResultDisplay } from './ResultDisplay';
export { ManualPatchControls } from './CustomPathOverride';
+export { SteamGamePatcher } from './SteamGamePatcher';