diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/index.ts | 93 | ||||
| -rw-r--r-- | src/components/ClipboardCommands.tsx | 42 | ||||
| -rw-r--r-- | src/components/CustomPathOverride.tsx | 18 | ||||
| -rw-r--r-- | src/components/InstalledGamesSection.tsx | 12 | ||||
| -rw-r--r-- | src/components/OptiScalerControls.tsx | 134 | ||||
| -rw-r--r-- | src/components/ResultDisplay.tsx | 4 | ||||
| -rw-r--r-- | src/components/SteamGamePatcher.tsx | 314 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/index.tsx | 15 | ||||
| -rw-r--r-- | src/types.d.ts | 10 | ||||
| -rw-r--r-- | src/utils/constants.ts | 40 |
11 files changed, 632 insertions, 51 deletions
diff --git a/src/api/index.ts b/src/api/index.ts index df52fee..b1bf0b8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,8 +1,15 @@ import { callable } from "@decky/api"; export const runInstallFGMod = callable< - [], - { status: string; message?: string; output?: string } + [selected_default_variant?: string], + { + status: string; + message?: string; + output?: string; + version?: string; + selected_default_variant?: string; + selected_default_variant_label?: string; + } >("run_install_fgmod"); export const runUninstallFGMod = callable< @@ -10,14 +17,32 @@ export const runUninstallFGMod = callable< { status: string; message?: string; output?: string } >("run_uninstall_fgmod"); +export const setDefaultFsr4Variant = callable< + [selected_default_variant?: string], + { + status: string; + message?: string; + output?: string; + version?: string; + selected_default_variant?: string; + selected_default_variant_label?: string; + } +>("set_default_fsr4_variant"); + export const checkFGModPath = callable< [], - { exists: boolean } + { + exists: boolean; + version?: string | null; + selected_fsr4_variant?: string | null; + selected_fsr4_variant_label?: string | null; + install_manifest_present?: boolean; + } >("check_fgmod_path"); export const listInstalledGames = callable< [], - { status: string; games: { appid: string; name: string }[] } + { status: string; message?: string; games: { appid: string; name: string; install_found?: boolean }[] } >("list_installed_games"); export const logError = callable<[string], void>("log_error"); @@ -28,11 +53,67 @@ export const getPathDefaults = callable< >("get_path_defaults"); export const runManualPatch = callable< - [string], - { status: string; message?: string; output?: string } + [string, string, string], + { + status: string; + message?: string; + output?: string; + fsr4_variant?: string; + fsr4_variant_label?: string; + fsr4_upscaler_sha256?: string; + optiscaler_version?: string | null; + } >("manual_patch_directory"); export const runManualUnpatch = callable< [string], { status: string; message?: string; output?: string } >("manual_unpatch_directory"); + +export const getGameStatus = callable< + [appid: string], + { + status: string; + message?: string; + appid?: string; + name?: 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; + } +>("get_game_status"); + +export const patchGame = callable< + [appid: string, dll_name: string, current_launch_options: string, fsr4_variant: string], + { + status: string; + message?: string; + appid?: string; + name?: string; + dll_name?: string; + target_dir?: string; + launch_options?: string; + original_launch_options?: string; + optiscaler_version?: string | null; + fsr4_variant?: string; + fsr4_variant_label?: string; + fsr4_upscaler_sha256?: string; + } +>("patch_game"); + +export const unpatchGame = callable< + [appid: string], + { + status: string; + message?: string; + appid?: string; + name?: string; + launch_options?: string; + } +>("unpatch_game"); 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'; diff --git a/src/index.tsx b/src/index.tsx index fb9635d..4a9a9f6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,8 +7,17 @@ import { checkFGModPath } from "./api"; import { safeAsyncOperation } from "./utils"; import { TIMEOUTS } from "./utils/constants"; +type FgmodInfo = { + exists: boolean; + version?: string | null; + selected_fsr4_variant?: string | null; + selected_fsr4_variant_label?: string | null; + install_manifest_present?: boolean; +}; + function MainContent() { const [pathExists, setPathExists] = useState<boolean | null>(null); + const [fgmodInfo, setFgmodInfo] = useState<FgmodInfo | null>(null); useEffect(() => { const checkPath = async () => { @@ -16,7 +25,10 @@ function MainContent() { async () => await checkFGModPath(), 'MainContent -> checkPath' ); - if (result) setPathExists(result.exists); + if (result) { + setFgmodInfo(result); + setPathExists(result.exists); + } }; checkPath(); // Initial check @@ -29,6 +41,7 @@ function MainContent() { <OptiScalerControls pathExists={pathExists} setPathExists={setPathExists} + fgmodInfo={fgmodInfo} /> {pathExists === true ? ( <> diff --git a/src/types.d.ts b/src/types.d.ts index dfc0472..4077a9e 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -12,3 +12,13 @@ declare module "*.jpg" { const content: string; export default content; } + +declare const SteamClient: { + Apps: { + RegisterForAppDetails( + appId: number, + callback: (details: { strLaunchOptions?: string }) => void + ): { unregister: () => void }; + SetAppLaunchOptions(appId: number, options: string): void; + }; +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 1f583c0..8444240 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -45,6 +45,36 @@ export const STYLES = { } }; +// Proxy DLL name options for OptiScaler injection +export const PROXY_DLL_OPTIONS = [ + { value: "dxgi.dll", label: "dxgi.dll (default)", hint: "Works for most DX12 games. Default." }, + { value: "winmm.dll", label: "winmm.dll", hint: "Use when dxgi.dll conflicts with an existing game file." }, + { value: "version.dll", label: "version.dll", hint: "Common fallback; works well with many launchers." }, + { value: "dbghelp.dll", label: "dbghelp.dll", hint: "Use for debug helper hook paths." }, + { value: "winhttp.dll", label: "winhttp.dll", hint: "Use when other DLL names conflict." }, + { value: "wininet.dll", label: "wininet.dll", hint: "Use when other DLL names conflict." }, + { value: "OptiScaler.asi", label: "OptiScaler.asi", hint: "For ASI loaders. Requires an ASI loader already installed in the game." }, +] as const; + +export type ProxyDllValue = typeof PROXY_DLL_OPTIONS[number]["value"]; +export const DEFAULT_PROXY_DLL: ProxyDllValue = "dxgi.dll"; + +export const FSR4_VARIANT_OPTIONS = [ + { + value: "rdna23-int8", + label: "Steam Deck / RDNA2-3 optimized", + hint: "Uses the bundled FSR4 INT8 4.0.2c override. Recommended for Steam Deck and other non-RDNA4 systems.", + }, + { + value: "rdna4-native", + label: "Native bundle / RDNA4", + hint: "Uses the amd_fidelityfx_upscaler_dx12.dll that ships inside the OptiScaler 0.9.2a bundle.", + }, +] as const; + +export type Fsr4VariantValue = typeof FSR4_VARIANT_OPTIONS[number]["value"]; +export const DEFAULT_FSR4_VARIANT: Fsr4VariantValue = "rdna23-int8"; + // Common timeout values export const TIMEOUTS = { resultDisplay: 5000, // 5 seconds @@ -53,14 +83,14 @@ export const TIMEOUTS = { // Message strings export const MESSAGES = { - modInstalled: "✅ OptiScaler Mod Installed", - modNotInstalled: "❌ OptiScaler Mod Not Installed", + modInstalled: "OptiScaler Mod Installed", + modNotInstalled: "OptiScaler Mod Not Installed", installing: "Installing OptiScaler...", installButton: "Setup OptiScaler Mod", uninstalling: "Removing OptiScaler...", uninstallButton: "Remove OptiScaler Mod", - installSuccess: "✅ OptiScaler mod setup successfully!", - uninstallSuccess: "✅ OptiScaler mod removed successfully.", + installSuccess: "OptiScaler mod setup successfully!", + uninstallSuccess: "OptiScaler mod removed successfully.", instructionTitle: "How to Use:", - instructionText: "Click 'Copy Patch Command' or 'Copy Unpatch Command', then go to your game's properties, and paste the command into the Launch Options field.\n\nIn-game: Enable DLSS in graphics settings to unlock FSR 3.1/XeSS 2.0 in DirectX12 Games.\n\nFor extended OptiScaler options, assign a back button to a keyboard's 'Insert' key." + instructionText: "Use 'Copy launch options' for the standard direct launch-options method. If you want the wrapper commands instead, enable Manual Mode to reveal 'Copy Patch Command' and 'Copy Unpatch Command'.\n\nIn-game: Enable DLSS in graphics settings to unlock FSR 3.1/XeSS 2.0 in DirectX12 Games.\n\nFor extended OptiScaler options, assign a back button to a keyboard's 'Insert' key." }; |
