From 1cc6a781fe7d6c053b2eb59aa79766a859c66209 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 20:14:44 -0400 Subject: revised approach, pick dir then patch in plugin ui itself --- main.py | 208 ++++++++++++++++ src/api/index.ts | 10 + src/components/ClipboardCommands.tsx | 10 +- src/components/CustomPathOverride.tsx | 445 +++++++++++++--------------------- src/components/OptiScalerControls.tsx | 17 +- src/components/index.ts | 1 + src/types/index.ts | 11 - 7 files changed, 388 insertions(+), 314 deletions(-) diff --git a/main.py b/main.py index a22acca..881298a 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,56 @@ from pathlib import Path # Set to False or comment out this constant to skip the overwrite by default. UPSCALER_OVERWRITE_ENABLED = True +INJECTOR_FILENAMES = [ + "dxgi.dll", + "winmm.dll", + "nvngx.dll", + "_nvngx.dll", + "nvngx-wrapper.dll", + "dlss-enabler.dll", + "OptiScaler.dll", +] + +ORIGINAL_DLL_BACKUPS = [ + "d3dcompiler_47.dll", + "amd_fidelityfx_dx12.dll", + "amd_fidelityfx_framegeneration_dx12.dll", + "amd_fidelityfx_upscaler_dx12.dll", + "amd_fidelityfx_vk.dll", +] + +SUPPORT_FILES = [ + "libxess.dll", + "libxess_dx11.dll", + "libxess_fg.dll", + "libxell.dll", + "amd_fidelityfx_dx12.dll", + "amd_fidelityfx_framegeneration_dx12.dll", + "amd_fidelityfx_upscaler_dx12.dll", + "amd_fidelityfx_vk.dll", + "nvngx.dll", + "dlssg_to_fsr3_amd_is_better.dll", + "fakenvapi.dll", + "fakenvapi.ini", +] + +LEGACY_FILES = [ + "dlssg_to_fsr3.ini", + "dlssg_to_fsr3.log", + "nvapi64.dll", + "nvapi64.dll.b", + "fakenvapi.log", + "dlss-enabler.dll", + "dlss-enabler-upscaler.dll", + "dlss-enabler.log", + "nvngx-wrapper.dll", + "_nvngx.dll", + "dlssg_to_fsr3_amd_is_better-3.0.dll", + "OptiScaler.asi", + "OptiScaler.ini", + "OptiScaler.log", +] + class Plugin: async def _main(self): decky.logger.info("Framegen plugin loaded") @@ -380,6 +430,146 @@ class Plugin: else: return {"exists": False} + def _resolve_target_directory(self, directory: str) -> Path: + target = Path(directory).expanduser() + if not target.exists(): + raise FileNotFoundError(f"Target directory does not exist: {directory}") + if not target.is_dir(): + raise NotADirectoryError(f"Target path is not a directory: {directory}") + if not os.access(target, os.W_OK | os.X_OK): + raise PermissionError(f"Insufficient permissions for {directory}") + return target + + def _manual_patch_directory_impl(self, directory: Path) -> dict: + fgmod_path = Path(decky.HOME) / "fgmod" + if not fgmod_path.exists(): + return { + "status": "error", + "message": "OptiScaler bundle not installed. Run Install first.", + } + + optiscaler_dll = fgmod_path / "OptiScaler.dll" + if not optiscaler_dll.exists(): + return { + "status": "error", + "message": "OptiScaler.dll not found in ~/fgmod. Reinstall OptiScaler.", + } + + dll_name = "dxgi.dll" + preserve_ini = True + + try: + decky.logger.info(f"Manual patch started for {directory}") + + for filename in INJECTOR_FILENAMES: + path = directory / filename + if path.exists(): + path.unlink() + + for dll in ORIGINAL_DLL_BACKUPS: + source = directory / dll + backup = directory / f"{dll}.b" + if source.exists() and not backup.exists(): + shutil.move(source, backup) + + for legacy in ["nvapi64.dll", "nvapi64.dll.b"]: + legacy_path = directory / legacy + if legacy_path.exists(): + legacy_path.unlink() + + renamed = fgmod_path / "renames" / dll_name + destination_dll = directory / dll_name + source_for_copy = renamed if renamed.exists() else optiscaler_dll + shutil.copy2(source_for_copy, destination_dll) + + target_ini = directory / "OptiScaler.ini" + source_ini = fgmod_path / "OptiScaler.ini" + if preserve_ini and target_ini.exists(): + decky.logger.info(f"Preserving existing OptiScaler.ini at {target_ini}") + elif source_ini.exists(): + shutil.copy2(source_ini, target_ini) + + plugins_src = fgmod_path / "plugins" + plugins_dest = directory / "plugins" + if plugins_src.exists(): + shutil.copytree(plugins_src, plugins_dest, dirs_exist_ok=True) + + for filename in SUPPORT_FILES: + source = fgmod_path / filename + if source.exists(): + shutil.copy2(source, directory / filename) + + decky.logger.info(f"Manual patch complete for {directory}") + return { + "status": "success", + "message": f"OptiScaler files copied to {directory}", + } + + except PermissionError as exc: + decky.logger.error(f"Manual patch permission error: {exc}") + return { + "status": "error", + "message": f"Permission error while patching: {exc}", + } + except Exception as exc: + decky.logger.error(f"Manual patch failed: {exc}") + return { + "status": "error", + "message": f"Manual patch failed: {exc}", + } + + def _manual_unpatch_directory_impl(self, directory: Path) -> dict: + try: + decky.logger.info(f"Manual unpatch started for {directory}") + + for filename in set(INJECTOR_FILENAMES + SUPPORT_FILES): + path = directory / filename + if path.exists(): + path.unlink() + + for legacy in LEGACY_FILES: + path = directory / legacy + if path.exists(): + try: + path.unlink() + except IsADirectoryError: + shutil.rmtree(path, ignore_errors=True) + + plugins_dir = directory / "plugins" + if plugins_dir.exists(): + shutil.rmtree(plugins_dir, ignore_errors=True) + + for dll in ORIGINAL_DLL_BACKUPS: + backup = directory / f"{dll}.b" + original = directory / dll + if backup.exists(): + if original.exists(): + original.unlink() + shutil.move(backup, original) + + uninstaller = directory / "fgmod-uninstaller.sh" + if uninstaller.exists(): + uninstaller.unlink() + + decky.logger.info(f"Manual unpatch complete for {directory}") + return { + "status": "success", + "message": f"OptiScaler files removed from {directory}", + } + + except PermissionError as exc: + decky.logger.error(f"Manual unpatch permission error: {exc}") + return { + "status": "error", + "message": f"Permission error while unpatching: {exc}", + } + except Exception as exc: + decky.logger.error(f"Manual unpatch failed: {exc}") + return { + "status": "error", + "message": f"Manual unpatch failed: {exc}", + } + async def list_installed_games(self) -> dict: try: steam_root = Path(decky.HOME) / ".steam" / "steam" @@ -444,3 +634,21 @@ class Plugin: async def log_error(self, error: str) -> None: decky.logger.error(f"FRONTEND: {error}") + + async def manual_patch_directory(self, directory: str) -> dict: + try: + target_dir = self._resolve_target_directory(directory) + except (FileNotFoundError, NotADirectoryError, PermissionError) as exc: + decky.logger.error(f"Manual patch validation failed: {exc}") + return {"status": "error", "message": str(exc)} + + return self._manual_patch_directory_impl(target_dir) + + async def manual_unpatch_directory(self, directory: str) -> dict: + try: + target_dir = self._resolve_target_directory(directory) + except (FileNotFoundError, NotADirectoryError, PermissionError) as exc: + decky.logger.error(f"Manual unpatch validation failed: {exc}") + return {"status": "error", "message": str(exc)} + + return self._manual_unpatch_directory_impl(target_dir) diff --git a/src/api/index.ts b/src/api/index.ts index 1cc3285..df52fee 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,3 +26,13 @@ export const getPathDefaults = callable< [], { home: string; steam_common?: string } >("get_path_defaults"); + +export const runManualPatch = callable< + [string], + { status: string; message?: string; output?: string } +>("manual_patch_directory"); + +export const runManualUnpatch = callable< + [string], + { status: string; message?: string; output?: string } +>("manual_unpatch_directory"); diff --git a/src/components/ClipboardCommands.tsx b/src/components/ClipboardCommands.tsx index f96b460..5a6f38f 100644 --- a/src/components/ClipboardCommands.tsx +++ b/src/components/ClipboardCommands.tsx @@ -1,22 +1,16 @@ import { SmartClipboardButton } from "./SmartClipboardButton"; -import type { CustomOverrideConfig } from "../types/index"; interface ClipboardCommandsProps { pathExists: boolean | null; - overrideConfig?: CustomOverrideConfig | null; } -export function ClipboardCommands({ pathExists, overrideConfig }: ClipboardCommandsProps) { +export function ClipboardCommands({ pathExists }: ClipboardCommandsProps) { if (pathExists !== true) return null; - const patchCommand = overrideConfig - ? `${overrideConfig.envAssignment} ~/fgmod/fgmod %command%` - : "~/fgmod/fgmod %command%"; - return ( <> diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx index 9915efc..b28a8de 100644 --- a/src/components/CustomPathOverride.tsx +++ b/src/components/CustomPathOverride.tsx @@ -1,388 +1,271 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ButtonItem, Field, PanelSectionRow, ToggleField } from "@decky/ui"; import { FileSelectionType, openFilePicker } from "@decky/api"; -import { getPathDefaults } from "../api"; -import type { CustomOverrideConfig } from "../types/index"; - -interface CustomPathOverrideProps { - onOverrideChange: (override: CustomOverrideConfig | null) => void; -} - -const DEFAULT_START_PATH = "/home"; -const DEFAULT_STEAM_LIBRARY_PATH = "/home/deck/.local/share/Steam/steamapps/common"; +import { getPathDefaults, runManualPatch, runManualUnpatch } from "../api"; +import type { ApiResponse } from "../types/index"; interface PathDefaults { home: string; steamCommon: string; } -const INITIAL_PATH_DEFAULTS: PathDefaults = { - home: DEFAULT_START_PATH, - steamCommon: DEFAULT_STEAM_LIBRARY_PATH, +const DEFAULT_HOME = "/home"; +const DEFAULT_STEAM_COMMON = "/home/deck/.local/share/Steam/steamapps/common"; + +const INITIAL_DEFAULTS: PathDefaults = { + home: DEFAULT_HOME, + steamCommon: DEFAULT_STEAM_COMMON, }; -const normalizePath = (path: string) => path.replace(/\\/g, "/"); +const normalizePath = (value: string) => value.replace(/\\/g, "/"); const stripTrailingSlash = (value: string) => - value.endsWith("/") ? value.slice(0, -1) : value; - -const escapeForDoubleQuotes = (value: string) => - value.replace(/[`"\\$]/g, (match) => `\\${match}`); - -const escapeForPattern = (value: string) => - value - .replace(/\\/g, "\\\\") - .replace(/\//g, "\\/") - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]") - .replace(/\*/g, "\\*") - .replace(/\?/g, "\\?"); - -const escapeForReplacement = (value: string) => - value - .replace(/\\/g, "\\\\") - .replace(/\//g, "\\/") - .replace(/\$/g, "\\$"); - -const quoteForShell = (value: string) => `'${value.replace(/'/g, "'\\''")}'`; - -const dirname = (path: string) => { - const normalized = normalizePath(path); - const parts = normalized.split("/"); - parts.pop(); - const dir = parts.join("/"); - return dir.length > 0 ? dir : "/"; -}; + value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value; -const longestCommonPrefix = (left: string[], right: string[]) => { - const length = Math.min(left.length, right.length); - let idx = 0; - while (idx < length && left[idx] === right[idx]) { - idx++; +const ensureDirectory = (value: string) => { + const normalized = normalizePath(value); + const lastSegment = normalized.substring(normalized.lastIndexOf("/") + 1); + if (!lastSegment || !lastSegment.includes(".")) { + return stripTrailingSlash(normalized); } - return idx; + const parent = normalized.slice(0, normalized.lastIndexOf("/")); + return parent || "/"; }; -interface ComputedOverride { - config: CustomOverrideConfig | null; - error: string | null; +interface ManualPatchControlsProps { + isAvailable: boolean; } -const buildOverride = ( - rawDefault: string | null, - rawOverride: string | null -): ComputedOverride => { - if (!rawDefault || !rawOverride) { - return { config: null, error: null }; - } - - const defaultPath = normalizePath(rawDefault.trim()); - const overridePath = normalizePath(rawOverride.trim()); - - if (defaultPath === overridePath) { - return { - config: null, - error: "Paths are identical. Choose a different target executable.", - }; - } - - const defaultParts = defaultPath.split("/").filter(Boolean); - const overrideParts = overridePath.split("/").filter(Boolean); - - if (!defaultParts.length || !overrideParts.length) { - return { - config: null, - error: "Unable to parse selected paths. Pick them again.", - }; - } - - const prefixLength = longestCommonPrefix(defaultParts, overrideParts); - - if (prefixLength < 2) { - return { - config: null, - error: "Selections do not share a common game folder.", - }; - } - - const searchSuffixParts = defaultParts.slice(prefixLength); - const replaceSuffixParts = overrideParts.slice(prefixLength); - - if (!searchSuffixParts.length || !replaceSuffixParts.length) { - return { - config: null, - error: "Could not determine differing portion of the paths.", - }; - } +interface PickerState { + selectedPath: string | null; + lastError: string | null; +} - const searchSuffix = searchSuffixParts.join("/"); - const replaceSuffix = replaceSuffixParts.join("/"); - const pattern = defaultParts[prefixLength - 1] ?? defaultParts[defaultParts.length - 1]; +const INITIAL_PICKER_STATE: PickerState = { + selectedPath: null, + lastError: null, +}; - if (!pattern) { - return { - config: null, - error: "Unable to infer game identifier from path.", - }; +const formatResultMessage = (result: ApiResponse | null) => { + if (!result) return null; + if (result.status === "success") { + return result.message || result.output || "Operation completed successfully."; } - - const escapedPattern = escapeForDoubleQuotes(pattern); - const escapedSearch = escapeForPattern(searchSuffix); - const escapedReplace = escapeForReplacement(replaceSuffix); - - const expression = `[[ "$arg" == *"${escapedPattern}"* ]] && arg=\${arg//${escapedSearch}/${escapedReplace}}`; - const snippet = `[[ "$arg" == *"${pattern}"* ]] && arg=\${arg//${searchSuffix}/${replaceSuffix}}`; - const envAssignment = `FGMOD_OVERRIDE_EXPRESSION=${quoteForShell(expression)}`; - - const config: CustomOverrideConfig = { - defaultPath, - overridePath, - pattern, - searchSuffix, - replaceSuffix, - expression, - snippet, - envAssignment, - }; - - return { config, error: null }; + return result.message || result.output || "Operation failed."; }; -export const CustomPathOverride = ({ onOverrideChange }: CustomPathOverrideProps) => { - const [launcherPath, setLauncherPath] = useState(null); - const [overridePath, setOverridePath] = useState(null); +export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) => { const [isEnabled, setEnabled] = useState(false); - const [pathDefaults, setPathDefaults] = useState(INITIAL_PATH_DEFAULTS); + const [defaults, setDefaults] = useState(INITIAL_DEFAULTS); + const [pickerState, setPickerState] = useState(INITIAL_PICKER_STATE); + const [isPatching, setIsPatching] = useState(false); + const [isUnpatching, setIsUnpatching] = useState(false); + const [operationResult, setOperationResult] = useState(null); useEffect(() => { let cancelled = false; - const fetchDefaults = async () => { + (async () => { try { - const result = await getPathDefaults(); - if (!result) { - return; - } + const response = await getPathDefaults(); + if (!response || cancelled) return; - const home = result.home ? normalizePath(result.home) : INITIAL_PATH_DEFAULTS.home; - const steamCommonSource = result.steam_common - ? normalizePath(result.steam_common) + const home = response.home ? normalizePath(response.home) : DEFAULT_HOME; + const steamCommon = response.steam_common + ? normalizePath(response.steam_common) : normalizePath(`${stripTrailingSlash(home)}/.local/share/Steam/steamapps/common`); - if (!cancelled) { - setPathDefaults({ - home, - steamCommon: steamCommonSource || INITIAL_PATH_DEFAULTS.steamCommon, - }); - } + setDefaults({ + home, + steamCommon: steamCommon || DEFAULT_STEAM_COMMON, + }); } catch (err) { - console.error("CustomPathOverride -> getPathDefaults", err); + console.error("ManualPatchControls -> getPathDefaults", err); } - }; - - fetchDefaults(); + })(); return () => { cancelled = true; }; }, []); - const { config, error } = useMemo( - () => buildOverride(launcherPath, overridePath), - [launcherPath, overridePath] - ); - useEffect(() => { - if (isEnabled && config) { - onOverrideChange(config); - } else { - onOverrideChange(null); + if (!isAvailable) { + setEnabled(false); + setPickerState(INITIAL_PICKER_STATE); + setOperationResult(null); } - }, [config, isEnabled, onOverrideChange]); + }, [isAvailable]); - interface PickerArgs { - existing: string | null; - setter: (value: string) => void; - fallbackStart?: string | null; - } + const canInteract = isAvailable && isEnabled; + const selectedPath = pickerState.selectedPath; + const statusMessage = useMemo(() => formatResultMessage(operationResult), [operationResult]); + const wasSuccessful = operationResult?.status === "success"; - const openPicker = useCallback( - async ({ existing, setter, fallbackStart }: PickerArgs) => { - const candidates = new Set(); + const openDirectoryPicker = useCallback(async () => { + const candidates = [ + selectedPath, + defaults.steamCommon, + defaults.home, + ]; - if (existing) { - candidates.add(normalizePath(existing)); - } else { - if (fallbackStart) { - candidates.add(normalizePath(fallbackStart)); - } - candidates.add(pathDefaults.steamCommon); - candidates.add(pathDefaults.home); - } + let lastError: string | null = null; - let lastError: unknown = null; + for (const candidate of candidates) { + if (!candidate) continue; - for (const candidate of candidates) { - if (!candidate) { - continue; - } + const startPath = ensureDirectory(candidate); - try { - const result = await openFilePicker( - FileSelectionType.FILE, - candidate, - true, - true, - undefined, - undefined, - true - ); - - if (result?.path) { - setter(normalizePath(result.path)); - return; - } - } catch (err) { - lastError = err; + try { + const result = await openFilePicker( + FileSelectionType.FOLDER, + startPath, + true, + true, + undefined, + undefined, + true + ); + + if (result?.path) { + setPickerState({ selectedPath: normalizePath(result.path), lastError: null }); + setOperationResult(null); + return; } + } catch (err) { + console.error("ManualPatchControls -> openDirectoryPicker", err); + lastError = err instanceof Error ? err.message : String(err); } + } - if (lastError) { - console.error("CustomPathOverride -> openPicker", lastError); + setPickerState((prev) => ({ ...prev, lastError })); + }, [defaults.home, defaults.steamCommon, selectedPath]); + + const runOperation = useCallback( + async (action: "patch" | "unpatch") => { + if (!selectedPath) return; + + const setBusy = action === "patch" ? setIsPatching : setIsUnpatching; + setBusy(true); + setOperationResult(null); + + try { + const response = + action === "patch" + ? await runManualPatch(selectedPath) + : await runManualUnpatch(selectedPath); + setOperationResult(response ?? { status: "error", message: "No response from backend." }); + } catch (err) { + setOperationResult({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); } }, - [pathDefaults] + [selectedPath] ); const handleToggle = (value: boolean) => { + if (!isAvailable) { + setEnabled(false); + return; + } + setEnabled(value); if (!value) { - setLauncherPath(null); - setOverridePath(null); - onOverrideChange(null); - } else if (config) { - onOverrideChange(config); + setPickerState(INITIAL_PICKER_STATE); + setOperationResult(null); } }; + const busy = isPatching || isUnpatching; + return ( <> - {isEnabled && ( + {canInteract && ( <> - openPicker({ - existing: launcherPath, - setter: setLauncherPath, - fallbackStart: pathDefaults.steamCommon, - }) - } - description={launcherPath || "Pick the EXE Steam currently uses."} - > - Select Steam-provided EXE - - - - - - launcherPath && - openPicker({ - existing: overridePath, - setter: setOverridePath, - fallbackStart: launcherPath ? dirname(launcherPath) : pathDefaults.steamCommon, - }) - } + onClick={openDirectoryPicker} description={ - launcherPath - ? overridePath || "Pick the executable that should run instead." - : "Select the Steam-provided executable first." + selectedPath || "Choose the game's installation directory (where the EXE lives)." } > - Select Override EXE + Select directory - {(launcherPath || overridePath) && ( - - { - setLauncherPath(null); - setOverridePath(null); - }} - > - Clear selections - - - )} - - {error && ( + {pickerState.lastError && ( ⚠️ )} - {!error && config && ( + {selectedPath && ( <> - {config.pattern} + {selectedPath} - runOperation("patch")} > -
- {config.snippet} -
-
+ {isPatching ? "Patching..." : "Patch directory"} +
- runOperation("unpatch")} > - {config.envAssignment} - + {isUnpatching ? "Reverting..." : "Unpatch directory"} + )} + + {operationResult && ( + + + {statusMessage} + + + )} )} diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx index 5dac6d0..3a288e8 100644 --- a/src/components/OptiScalerControls.tsx +++ b/src/components/OptiScalerControls.tsx @@ -10,8 +10,7 @@ import { ClipboardCommands } from "./ClipboardCommands"; import { InstructionCard } from "./InstructionCard"; import { OptiScalerWiki } from "./OptiScalerWiki"; import { UninstallButton } from "./UninstallButton"; -import { CustomPathOverride } from "./CustomPathOverride"; -import type { CustomOverrideConfig } from "../types/index"; +import { ManualPatchControls } from "./CustomPathOverride"; interface OptiScalerControlsProps { pathExists: boolean | null; @@ -23,14 +22,6 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont const [uninstalling, setUninstalling] = useState(false); const [installResult, setInstallResult] = useState(null); const [uninstallResult, setUninstallResult] = useState(null); - const [overrideConfig, setOverrideConfig] = useState(null); - - useEffect(() => { - if (pathExists !== true && overrideConfig) { - setOverrideConfig(null); - } - }, [pathExists, overrideConfig]); - useEffect(() => { if (installResult) { return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); @@ -85,11 +76,9 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont - {pathExists === true && ( - - )} + - + diff --git a/src/components/index.ts b/src/components/index.ts index 3f67341..cd599ba 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,3 +8,4 @@ export { OptiScalerWiki } from './OptiScalerWiki'; export { UninstallButton } from './UninstallButton'; export { SmartClipboardButton } from './SmartClipboardButton'; export { ResultDisplay } from './ResultDisplay'; +export { ManualPatchControls } from './CustomPathOverride'; diff --git a/src/types/index.ts b/src/types/index.ts index 7b5b890..c810754 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,14 +24,3 @@ export interface ModInstallationConfig { bin: string; }; } - -export interface CustomOverrideConfig { - defaultPath: string; - overridePath: string; - pattern: string; - searchSuffix: string; - replaceSuffix: string; - expression: string; - snippet: string; - envAssignment: string; -} -- cgit v1.2.3