diff options
| -rw-r--r-- | main.py | 477 | ||||
| -rw-r--r-- | src/api/index.ts | 42 | ||||
| -rw-r--r-- | src/components/ClipboardCommands.tsx | 20 | ||||
| -rw-r--r-- | src/components/SteamGamePatcher.tsx | 346 |
4 files changed, 673 insertions, 212 deletions
@@ -4,6 +4,7 @@ import subprocess import json import shutil import re +from datetime import datetime, timezone from pathlib import Path # Toggle to enable overwriting the upscaler DLL from the static remote binary. @@ -53,6 +54,25 @@ SUPPORT_FILES = [ "fakenvapi.ini", ] +MARKER_FILENAME = "FRAMEGEN_PATCH" + +BAD_EXE_SUBSTRINGS = [ + "crashreport", + "crashreportclient", + "eac", + "easyanticheat", + "beclient", + "eosbootstrap", + "benchmark", + "uninstall", + "setup", + "launcher", + "updater", + "bootstrap", + "_redist", + "prereq", +] + LEGACY_FILES = [ "dlssg_to_fsr3.ini", "dlssg_to_fsr3.log", @@ -716,51 +736,258 @@ class Plugin: "message": f"Manual unpatch failed: {exc}", } - async def list_installed_games(self) -> dict: - try: - steam_root = Path(decky.HOME) / ".steam" / "steam" - library_file = Path(steam_root) / "steamapps" / "libraryfolders.vdf" - + # ── Steam library discovery ─────────────────────────────────────────────── + def _home_path(self) -> Path: + try: + return Path(decky.HOME) + except TypeError: + return Path(str(decky.HOME)) + + def _steam_root_candidates(self) -> list[Path]: + home = self._home_path() + candidates = [ + home / ".local" / "share" / "Steam", + home / ".steam" / "steam", + home / ".steam" / "root", + home / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam", + home / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".steam" / "steam", + ] + unique: list[Path] = [] + seen: set[str] = set() + for c in candidates: + key = str(c) + if key not in seen: + unique.append(c) + seen.add(key) + return unique + + def _steam_library_paths(self) -> list[Path]: + library_paths: list[Path] = [] + seen: set[str] = set() + for steam_root in self._steam_root_candidates(): + if steam_root.exists(): + key = str(steam_root) + if key not in seen: + library_paths.append(steam_root) + seen.add(key) + library_file = steam_root / "steamapps" / "libraryfolders.vdf" if not library_file.exists(): - return {"status": "error", "message": "libraryfolders.vdf not found"} - - library_paths = [] - with open(library_file, "r", encoding="utf-8", errors="replace") as file: - for line in file: - if '"path"' in line: - path = line.split('"path"')[1].strip().strip('"').replace("\\\\", "/") - library_paths.append(path) - - games = [] - for library_path in library_paths: - steamapps_path = Path(library_path) / "steamapps" - if not steamapps_path.exists(): + continue + try: + with open(library_file, "r", encoding="utf-8", errors="replace") as f: + for line in f: + if '"path"' not in line: + continue + path = line.split('"path"', 1)[1].strip().strip('"').replace("\\\\", "/") + candidate = Path(path) + key = str(candidate) + if key not in seen: + library_paths.append(candidate) + seen.add(key) + except Exception as exc: + decky.logger.error(f"[Framegen] failed to parse libraryfolders: {library_file}: {exc}") + return library_paths + + def _find_installed_games(self, appid: str | None = None) -> list[dict]: + games: list[dict] = [] + for library_path in self._steam_library_paths(): + steamapps_path = library_path / "steamapps" + if not steamapps_path.exists(): + continue + for appmanifest in steamapps_path.glob("appmanifest_*.acf"): + game_info: dict = {"appid": "", "name": "", "library_path": str(library_path), "install_path": ""} + install_dir = "" + try: + with open(appmanifest, "r", encoding="utf-8", errors="replace") as f: + for line in f: + if '"appid"' in line: + game_info["appid"] = line.split('"appid"', 1)[1].strip().strip('"') + elif '"name"' in line: + game_info["name"] = line.split('"name"', 1)[1].strip().strip('"') + elif '"installdir"' in line: + install_dir = line.split('"installdir"', 1)[1].strip().strip('"') + except Exception as exc: + decky.logger.error(f"[Framegen] skipping manifest {appmanifest}: {exc}") continue + if not game_info["appid"] or not game_info["name"]: + continue + if "Proton" in game_info["name"] or "Steam Linux Runtime" in game_info["name"]: + continue + install_path = steamapps_path / "common" / install_dir if install_dir else Path() + game_info["install_path"] = str(install_path) + if appid is None or str(game_info["appid"]) == str(appid): + games.append(game_info) + deduped: dict[str, dict] = {} + for game in games: + deduped[str(game["appid"])] = game + return sorted(deduped.values(), key=lambda g: g["name"].lower()) + + def _game_record(self, appid: str) -> dict | None: + matches = self._find_installed_games(appid) + return matches[0] if matches else None + + # ── Patch target auto-detection ─────────────────────────────────────────── + + def _normalized_path_string(self, value: str) -> str: + normalized = value.lower().replace("\\", "/") + normalized = normalized.replace("z:/", "/") + normalized = normalized.replace("//", "/") + return normalized + + def _candidate_executables(self, install_root: Path) -> list[Path]: + if not install_root.exists(): + return [] + candidates: list[Path] = [] + try: + for exe in install_root.rglob("*.exe"): + if exe.is_file(): + candidates.append(exe) + except Exception as exc: + decky.logger.error(f"[Framegen] exe scan failed for {install_root}: {exc}") + return candidates + + def _exe_score(self, exe: Path, install_root: Path, game_name: str) -> int: + normalized = self._normalized_path_string(str(exe)) + name = exe.name.lower() + score = 0 + if normalized.endswith("-win64-shipping.exe"): + score += 300 + if "shipping.exe" in name: + score += 220 + if "/binaries/win64/" in normalized: + score += 200 + if "/win64/" in normalized: + score += 80 + if exe.parent == install_root: + score += 20 + sanitized_game = re.sub(r"[^a-z0-9]", "", game_name.lower()) + sanitized_name = re.sub(r"[^a-z0-9]", "", exe.stem.lower()) + sanitized_root = re.sub(r"[^a-z0-9]", "", install_root.name.lower()) + if sanitized_game and sanitized_game in sanitized_name: + score += 120 + if sanitized_root and sanitized_root in sanitized_name: + score += 90 + for bad in BAD_EXE_SUBSTRINGS: + if bad in normalized: + score -= 200 + score -= len(exe.parts) + return score + + def _best_running_executable(self, candidates: list[Path]) -> Path | None: + if not candidates: + return None + try: + result = subprocess.run(["ps", "-eo", "args="], capture_output=True, text=True, check=False) + process_lines = result.stdout.splitlines() + except Exception as exc: + decky.logger.error(f"[Framegen] running exe scan failed: {exc}") + return None + normalized_candidates = [(exe, self._normalized_path_string(str(exe))) for exe in candidates] + matches: list[tuple[int, Path]] = [] + for line in process_lines: + normalized_line = self._normalized_path_string(line) + for exe, normalized_exe in normalized_candidates: + if normalized_exe in normalized_line: + matches.append((len(normalized_exe), exe)) + if not matches: + return None + matches.sort(key=lambda item: item[0], reverse=True) + return matches[0][1] + + def _guess_patch_target(self, game_info: dict) -> tuple[Path, Path | None]: + install_root = Path(game_info["install_path"]) + candidates = self._candidate_executables(install_root) + if not candidates: + return install_root, None + running_exe = self._best_running_executable(candidates) + if running_exe: + return running_exe.parent, running_exe + best = max(candidates, key=lambda exe: self._exe_score(exe, install_root, game_info["name"])) + return best.parent, best + + def _is_game_running(self, game_info: dict) -> bool: + install_root = Path(game_info["install_path"]) + candidates = self._candidate_executables(install_root) + return self._best_running_executable(candidates) is not None + + # ── Marker file tracking ────────────────────────────────────────────────── + + def _find_marker(self, install_root: Path) -> Path | None: + if not install_root.exists(): + return None + try: + for marker in install_root.rglob(MARKER_FILENAME): + if marker.is_file(): + return marker + except Exception: + pass + return None + + def _read_marker(self, marker_path: Path) -> dict: + try: + with open(marker_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _write_marker( + self, + marker_path: Path, + *, + appid: str, + game_name: str, + dll_name: str, + target_dir: Path, + original_launch_options: str, + backed_up_files: list[str], + ) -> None: + payload = { + "appid": str(appid), + "game_name": game_name, + "dll_name": dll_name, + "target_dir": str(target_dir), + "original_launch_options": original_launch_options, + "backed_up_files": backed_up_files, + "patched_at": datetime.now(timezone.utc).isoformat(), + } + with open(marker_path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) - for appmanifest in steamapps_path.glob("appmanifest_*.acf"): - game_info = {"appid": "", "name": ""} - - try: - with open(appmanifest, "r", encoding="utf-8") as file: - for line in file: - if '"appid"' in line: - game_info["appid"] = line.split('"appid"')[1].strip().strip('"') - if '"name"' in line: - game_info["name"] = line.split('"name"')[1].strip().strip('"') - except UnicodeDecodeError as e: - decky.logger.error(f"Skipping {appmanifest} due to encoding issue: {e}") - finally: - pass # Ensures loop continues even if an error occurs - - if game_info["appid"] and game_info["name"]: - games.append(game_info) + # ── Launch options helpers ──────────────────────────────────────────────── - # Filter out games whose name contains "Proton" or "Steam Linux Runtime" - filtered_games = [g for g in games if "Proton" not in g["name"] and "Steam Linux Runtime" not in g["name"]] + def _build_managed_launch_options(self, dll_name: str) -> str: + if dll_name == "OptiScaler.asi": + return "SteamDeck=0 %command%" + base = dll_name.replace(".dll", "") + return f"WINEDLLOVERRIDES={base}=n,b SteamDeck=0 %command%" - return {"status": "success", "games": filtered_games} + def _is_managed_launch_options(self, opts: str) -> bool: + if not opts or not opts.strip(): + return False + normalized = " ".join(opts.strip().split()) + for dll_name in VALID_DLL_NAMES: + if dll_name == "OptiScaler.asi": + continue + base = dll_name.replace(".dll", "") + if f"WINEDLLOVERRIDES={base}=n,b" in normalized: + return True + if "fgmod/fgmod" in normalized: + return True + return False + async def list_installed_games(self) -> dict: + try: + games = [] + for game in self._find_installed_games(): + install_root = Path(game["install_path"]) + games.append({ + "appid": str(game["appid"]), + "name": game["name"], + "install_found": install_root.exists(), + }) + return {"status": "success", "games": games} except Exception as e: decky.logger.error(str(e)) return {"status": "error", "message": str(e)} @@ -800,3 +1027,177 @@ class Plugin: return {"status": "error", "message": str(exc)} return self._manual_unpatch_directory_impl(target_dir) + + # ── AppID-based patch / unpatch / status ─────────────────────────────────────── + + async def get_game_status(self, appid: str) -> dict: + try: + game_info = self._game_record(str(appid)) + if not game_info: + return { + "status": "success", + "appid": str(appid), + "install_found": False, + "patched": False, + "dll_name": None, + "target_dir": None, + "message": "Game not found in Steam library.", + } + install_root = Path(game_info["install_path"]) + if not install_root.exists(): + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "install_found": False, + "patched": False, + "dll_name": None, + "target_dir": None, + "message": "Game install directory not found.", + } + marker = self._find_marker(install_root) + if not marker: + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "install_found": True, + "patched": False, + "dll_name": None, + "target_dir": None, + "message": "Not patched.", + } + metadata = self._read_marker(marker) + dll_name = metadata.get("dll_name", "dxgi.dll") + target_dir = Path(metadata.get("target_dir", str(marker.parent))) + dll_present = (target_dir / dll_name).exists() + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "install_found": True, + "patched": dll_present, + "dll_name": dll_name, + "target_dir": str(target_dir), + "patched_at": metadata.get("patched_at"), + "message": ( + f"Patched using {dll_name}." + if dll_present + else f"Marker found but {dll_name} is missing. Reinstall recommended." + ), + } + except Exception as exc: + decky.logger.error(f"[Framegen] get_game_status failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} + + async def patch_game(self, appid: str, dll_name: str = "dxgi.dll", current_launch_options: str = "") -> dict: + try: + if dll_name not in VALID_DLL_NAMES: + return {"status": "error", "message": f"Invalid proxy DLL name: {dll_name}"} + game_info = self._game_record(str(appid)) + if not game_info: + return {"status": "error", "message": "Game not found in Steam library."} + install_root = Path(game_info["install_path"]) + if not install_root.exists(): + return {"status": "error", "message": "Game install directory does not exist."} + if self._is_game_running(game_info): + return {"status": "error", "message": "Close the game before patching."} + fgmod_path = Path(decky.HOME) / "fgmod" + if not fgmod_path.exists(): + return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} + + # Preserve true original launch options across re-patches + original_launch_options = current_launch_options or "" + existing_marker = self._find_marker(install_root) + if existing_marker: + metadata = self._read_marker(existing_marker) + stored_opts = str(metadata.get("original_launch_options") or "") + if stored_opts and not self._is_managed_launch_options(stored_opts): + original_launch_options = stored_opts + try: + existing_marker.unlink() + except Exception: + pass + if self._is_managed_launch_options(original_launch_options): + original_launch_options = "" + + # Auto-detect the right directory to patch + target_dir, target_exe = self._guess_patch_target(game_info) + decky.logger.info(f"[Framegen] patch_game: appid={appid} dll={dll_name} target={target_dir} exe={target_exe}") + + result = self._manual_patch_directory_impl(target_dir, dll_name) + if result["status"] != "success": + return result + + backed_up = [dll for dll in ORIGINAL_DLL_BACKUPS if (target_dir / f"{dll}.b").exists()] + marker_path = target_dir / MARKER_FILENAME + self._write_marker( + marker_path, + appid=str(appid), + game_name=game_info["name"], + dll_name=dll_name, + target_dir=target_dir, + original_launch_options=original_launch_options, + backed_up_files=backed_up, + ) + + managed_launch_options = self._build_managed_launch_options(dll_name) + decky.logger.info(f"[Framegen] patch_game success: appid={appid} launch_options={managed_launch_options}") + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "dll_name": dll_name, + "target_dir": str(target_dir), + "launch_options": managed_launch_options, + "original_launch_options": original_launch_options, + "message": f"Patched {game_info['name']} using {dll_name}.", + } + except Exception as exc: + decky.logger.error(f"[Framegen] patch_game failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} + + async def unpatch_game(self, appid: str) -> dict: + try: + game_info = self._game_record(str(appid)) + if not game_info: + return {"status": "error", "message": "Game not found in Steam library."} + install_root = Path(game_info["install_path"]) + if not install_root.exists(): + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "launch_options": "", + "message": "Game install directory does not exist.", + } + if self._is_game_running(game_info): + return {"status": "error", "message": "Close the game before unpatching."} + marker = self._find_marker(install_root) + if not marker: + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "launch_options": "", + "message": "No Framegen patch found for this game.", + } + metadata = self._read_marker(marker) + target_dir = Path(metadata.get("target_dir", str(marker.parent))) + original_launch_options = str(metadata.get("original_launch_options") or "") + self._manual_unpatch_directory_impl(target_dir) + try: + marker.unlink() + except FileNotFoundError: + pass + decky.logger.info(f"[Framegen] unpatch_game success: appid={appid} target={target_dir}") + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "launch_options": original_launch_options, + "message": f"Unpatched {game_info['name']}.", + } + except Exception as exc: + decky.logger.error(f"[Framegen] unpatch_game failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} diff --git a/src/api/index.ts b/src/api/index.ts index 226f29f..c205a87 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -17,7 +17,7 @@ export const checkFGModPath = callable< 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"); @@ -36,3 +36,43 @@ 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; + } +>("get_game_status"); + +export const patchGame = callable< + [appid: string, dll_name: string, current_launch_options: string], + { + status: string; + message?: string; + appid?: string; + name?: string; + dll_name?: string; + target_dir?: string; + launch_options?: string; + original_launch_options?: 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 7bbd12d..e1f6ef9 100644 --- a/src/components/ClipboardCommands.tsx +++ b/src/components/ClipboardCommands.tsx @@ -8,17 +8,15 @@ interface ClipboardCommandsProps { export function ClipboardCommands({ pathExists, dllName }: ClipboardCommandsProps) { if (pathExists !== true) return null; - return ( - <> - <SmartClipboardButton - command={`DLL=${dllName} ~/fgmod/fgmod %command%`} - buttonText="Copy Patch Command" - /> + const launchCmd = + dllName === "OptiScaler.asi" + ? "SteamDeck=0 %command%" + : `WINEDLLOVERRIDES=${dllName.replace(".dll", "")}=n,b SteamDeck=0 %command%`; - <SmartClipboardButton - command="~/fgmod/fgmod-uninstaller.sh %command%" - buttonText="Copy Unpatch Command" - /> - </> + return ( + <SmartClipboardButton + command={launchCmd} + buttonText="Copy launch options" + /> ); } diff --git a/src/components/SteamGamePatcher.tsx b/src/components/SteamGamePatcher.tsx index 06c373c..b17ed48 100644 --- a/src/components/SteamGamePatcher.tsx +++ b/src/components/SteamGamePatcher.tsx @@ -1,36 +1,24 @@ 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"; +import { toaster } from "@decky/api"; +import { listInstalledGames, getGameStatus, patchGame, unpatchGame } from "../api"; // ─── 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<string> => +const getAppLaunchOptions = (appId: number): Promise<string> => new Promise((resolve, reject) => { - if ( - typeof SteamClient === "undefined" || - !SteamClient?.Apps?.RegisterForAppDetails - ) { + 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 }) => { @@ -41,172 +29,197 @@ const getSteamLaunchOptions = (appId: number): Promise<string> => 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."); +const setAppLaunchOptions = (appId: number, options: string): void => { + if (typeof SteamClient !== "undefined" && SteamClient?.Apps?.SetAppLaunchOptions) { + SteamClient.Apps.SetAppLaunchOptions(appId, options); } - SteamClient.Apps.SetAppLaunchOptions(appId, options); }; -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Types ─────────────────────────────────────────────────────────────────── -/** 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(); +type GameEntry = { appid: string; name: string; install_found?: boolean }; -/** 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; +type GameStatus = { + status: "success" | "error"; + message?: string; + install_found?: boolean; + patched?: boolean; + dll_name?: string | null; + target_dir?: string | null; + patched_at?: string | null; }; +// ─── Module-level state persistence ────────────────────────────────────────── + +let lastSelectedAppId = ""; + // ─── Component ─────────────────────────────────────────────────────────────── interface SteamGamePatcherProps { dllName: string; } -type GameEntry = { appid: string; name: string }; - export function SteamGamePatcher({ dllName }: SteamGamePatcherProps) { const [games, setGames] = useState<GameEntry[]>([]); const [gamesLoading, setGamesLoading] = useState(true); - const [selectedAppId, setSelectedAppId] = useState<string>(""); - const [launchOptions, setLaunchOptions] = useState<string>(""); - const [launchOptionsLoading, setLaunchOptionsLoading] = useState(false); - const [busy, setBusy] = useState(false); + 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>(""); - // Auto-clear result message - useEffect(() => { - if (resultMessage) { - return createAutoCleanupTimer( - () => setResultMessage(""), - TIMEOUTS.resultDisplay - ); - } - return undefined; - }, [resultMessage]); + // ── Data loaders ─────────────────────────────────────────────────────────── - // 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); + 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; } - })(); - return () => { - cancelled = true; - }; + 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); + } }, []); - // Reload launch options when selected game changes + useEffect(() => { + void loadGames(); + }, [loadGames]); + useEffect(() => { if (!selectedAppId) { - setLaunchOptions(""); + setGameStatus(null); 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]); + void loadStatus(selectedAppId); + }, [selectedAppId, loadStatus]); + + // ── Derived state ────────────────────────────────────────────────────────── + const selectedGame = useMemo( () => games.find((g) => g.appid === selectedAppId) ?? null, [games, selectedAppId] ); - const handleSet = useCallback(() => { - if (!selectedAppId || busy) return; - setBusy(true); + 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 { - setSteamLaunchOptions(Number(selectedAppId), targetCommand); - setLaunchOptions(targetCommand); - setResultMessage( - `Launch options set for ${selectedGame?.name ?? selectedAppId}` - ); - } catch (e) { - setResultMessage(`Error: ${e instanceof Error ? e.message : String(e)}`); + let currentLaunchOptions = ""; + try { + currentLaunchOptions = await getAppLaunchOptions(Number(selectedAppId)); + } catch { + // non-fatal: proceed without current launch options + } + const result = await patchGame(selectedAppId, dllName, currentLaunchOptions); + 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 { - setBusy(false); + setBusyAction(null); } - }, [selectedAppId, targetCommand, selectedGame, busy]); + }, [busyAction, dllName, loadStatus, selectedAppId, selectedGame]); - const handleRemove = useCallback(() => { - if (!selectedAppId || busy) return; - setBusy(true); + const handleUnpatch = useCallback(async () => { + if (!selectedGame || !selectedAppId || busyAction) return; + setBusyAction("unpatch"); + setResultMessage(""); try { - const stripped = stripFgmod(launchOptions); - setSteamLaunchOptions(Number(selectedAppId), stripped); - setLaunchOptions(stripped); - setResultMessage( - `Removed fgmod from ${selectedGame?.name ?? selectedAppId}` - ); - } catch (e) { - setResultMessage(`Error: ${e instanceof Error ? e.message : String(e)}`); + 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 { - setBusy(false); + setBusyAction(null); } - }, [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]); + }, [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 ( <> @@ -214,14 +227,17 @@ export function SteamGamePatcher({ dllName }: SteamGamePatcherProps) { <DropdownItem label="Steam game" menuLabel="Select a Steam game" - strDefaultLabel={ - gamesLoading ? "Loading games..." : "Choose a game" - } + strDefaultLabel={gamesLoading ? "Loading games..." : "Choose a game"} disabled={gamesLoading || games.length === 0} selectedOption={selectedAppId} - rgOptions={games.map((g) => ({ data: g.appid, label: g.name }))} + rgOptions={games.map((g) => ({ + data: g.appid, + label: g.install_found === false ? `${g.name} (not installed)` : g.name, + }))} onChange={(option) => { - setSelectedAppId(String(option.data)); + const next = String(option.data); + lastSelectedAppId = next; + setSelectedAppId(next); setResultMessage(""); }} /> @@ -230,42 +246,48 @@ export function SteamGamePatcher({ dllName }: SteamGamePatcherProps) { {selectedGame && ( <> <PanelSectionRow> - <Field focusable label="Launch options status"> - {statusColor ? ( - <span style={{ color: statusColor, fontWeight: 600 }}> - {statusText} + <Field {...focusableFieldProps} label="Patch status"> + {statusDisplay.color ? ( + <span style={{ color: statusDisplay.color, fontWeight: 600 }}> + {statusDisplay.text} </span> ) : ( - statusText + statusDisplay.text )} </Field> </PanelSectionRow> <PanelSectionRow> - <ButtonItem - layout="below" - disabled={busy || launchOptionsLoading} - onClick={handleSet} - > - {setButtonLabel} + <ButtonItem layout="below" disabled={!canPatch} onClick={handlePatch}> + {patchButtonLabel} </ButtonItem> </PanelSectionRow> - {isManaged && ( + {canUnpatch && ( <PanelSectionRow> <ButtonItem layout="below" - disabled={busy} - onClick={handleRemove} + disabled={busyAction !== null} + onClick={handleUnpatch} > - {busy ? "Removing..." : "Remove from launch options"} + {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 focusable label="Result"> + <Field {...focusableFieldProps} label="Result"> {resultMessage} </Field> </PanelSectionRow> |
