From cbed25162a1058e67180aafb8fbd424bf2573e95 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 11:32:03 -0400 Subject: initial path override ui and be --- .codacy/codacy.yaml | 15 ++ DeckyFileServer | 1 + src/components/ClipboardCommands.tsx | 10 +- src/components/CustomPathOverride.tsx | 284 ++++++++++++++++++++++++++++++++++ src/components/OptiScalerControls.tsx | 15 +- src/index.tsx | 5 +- src/types/index.ts | 11 ++ 7 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 .codacy/codacy.yaml create mode 160000 DeckyFileServer create mode 100644 src/components/CustomPathOverride.tsx diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml new file mode 100644 index 0000000..ac85c06 --- /dev/null +++ b/.codacy/codacy.yaml @@ -0,0 +1,15 @@ +runtimes: + - dart@3.7.2 + - go@1.22.3 + - java@17.0.10 + - node@22.2.0 + - python@3.11.11 +tools: + - dartanalyzer@3.7.2 + - eslint@8.57.0 + - lizard@1.17.31 + - pmd@7.11.0 + - pylint@3.3.6 + - revive@1.7.0 + - semgrep@1.78.0 + - trivy@0.65.0 diff --git a/DeckyFileServer b/DeckyFileServer new file mode 160000 index 0000000..ef58c4f --- /dev/null +++ b/DeckyFileServer @@ -0,0 +1 @@ +Subproject commit ef58c4fccd0a99072d75d5726b04cc44f349a311 diff --git a/src/components/ClipboardCommands.tsx b/src/components/ClipboardCommands.tsx index 5a6f38f..f96b460 100644 --- a/src/components/ClipboardCommands.tsx +++ b/src/components/ClipboardCommands.tsx @@ -1,16 +1,22 @@ import { SmartClipboardButton } from "./SmartClipboardButton"; +import type { CustomOverrideConfig } from "../types/index"; interface ClipboardCommandsProps { pathExists: boolean | null; + overrideConfig?: CustomOverrideConfig | null; } -export function ClipboardCommands({ pathExists }: ClipboardCommandsProps) { +export function ClipboardCommands({ pathExists, overrideConfig }: 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 new file mode 100644 index 0000000..9e7d34b --- /dev/null +++ b/src/components/CustomPathOverride.tsx @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ButtonItem, Field, PanelSectionRow, ToggleField } from "@decky/ui"; +import { FileSelectionType, openFilePicker } from "@decky/api"; +import type { CustomOverrideConfig } from "../types/index"; + +interface CustomPathOverrideProps { + onOverrideChange: (override: CustomOverrideConfig | null) => void; +} + +const DEFAULT_START_PATH = "/home"; + +const normalizePath = (path: string) => path.replace(/\\/g, "/"); + +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 longestCommonPrefix = (left: string[], right: string[]) => { + const length = Math.min(left.length, right.length); + let idx = 0; + while (idx < length && left[idx] === right[idx]) { + idx++; + } + return idx; +}; + +interface ComputedOverride { + config: CustomOverrideConfig | null; + error: string | null; +} + +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.", + }; + } + + const searchSuffix = searchSuffixParts.join("/"); + const replaceSuffix = replaceSuffixParts.join("/"); + const pattern = defaultParts[prefixLength - 1] ?? defaultParts[defaultParts.length - 1]; + + if (!pattern) { + return { + config: null, + error: "Unable to infer game identifier from path.", + }; + } + + 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 }; +}; + +export const CustomPathOverride = ({ onOverrideChange }: CustomPathOverrideProps) => { + const [launcherPath, setLauncherPath] = useState(null); + const [overridePath, setOverridePath] = useState(null); + const [isEnabled, setEnabled] = useState(false); + + const { config, error } = useMemo( + () => buildOverride(launcherPath, overridePath), + [launcherPath, overridePath] + ); + + useEffect(() => { + if (isEnabled && config) { + onOverrideChange(config); + } else { + onOverrideChange(null); + } + }, [config, isEnabled, onOverrideChange]); + + const openPicker = useCallback( + async (existing: string | null, setter: (value: string) => void) => { + try { + const startPath = existing ? normalizePath(existing) : DEFAULT_START_PATH; + const result = await openFilePicker( + FileSelectionType.FILE, + startPath, + true, + false, + undefined, + undefined, + true + ); + if (result?.path) { + setter(normalizePath(result.path)); + } + } catch (err) { + console.error("CustomPathOverride -> openPicker", err); + } + }, + [] + ); + + const handleToggle = (value: boolean) => { + setEnabled(value); + if (!value) { + setLauncherPath(null); + setOverridePath(null); + onOverrideChange(null); + } else if (config) { + onOverrideChange(config); + } + }; + + return ( + <> + + + + + {isEnabled && ( + <> + + openPicker(launcherPath, setLauncherPath)} + description={launcherPath || "Pick the EXE Steam currently uses."} + > + Select Steam-provided EXE + + + + + openPicker(overridePath, setOverridePath)} + description={overridePath || "Pick the executable that should run instead."} + > + Select Override EXE + + + + {(launcherPath || overridePath) && ( + + { + setLauncherPath(null); + setOverridePath(null); + }} + > + Clear selections + + + )} + + {error && ( + + + ⚠️ + + + )} + + {!error && config && ( + <> + + + {config.pattern} + + + + + +
+ {config.snippet} +
+
+
+ + + + {config.envAssignment} + + + + )} + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx index 7b2db0e..5dac6d0 100644 --- a/src/components/OptiScalerControls.tsx +++ b/src/components/OptiScalerControls.tsx @@ -10,6 +10,8 @@ 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"; interface OptiScalerControlsProps { pathExists: boolean | null; @@ -21,6 +23,13 @@ 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) { @@ -76,7 +85,11 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont - + {pathExists === true && ( + + )} + + diff --git a/src/index.tsx b/src/index.tsx index 0ea93e0..fb9635d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,7 +26,10 @@ function MainContent() { return ( <> - + {pathExists === true ? ( <> {/* */} diff --git a/src/types/index.ts b/src/types/index.ts index c810754..7b5b890 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,3 +24,14 @@ 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 From 7d2322e637faae5ccfab58c54f7a13e6a5f7ea88 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 12:16:22 -0400 Subject: feat: first arg steam path start, second mirror first --- main.py | 13 +++ src/api/index.ts | 5 ++ src/components/CustomPathOverride.tsx | 146 +++++++++++++++++++++++++++++----- 3 files changed, 144 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index 76a7b22..a22acca 100644 --- a/main.py +++ b/main.py @@ -429,5 +429,18 @@ class Plugin: decky.logger.error(str(e)) return {"status": "error", "message": str(e)} + async def get_path_defaults(self) -> dict: + try: + home_path = Path(decky.HOME) + except TypeError: + home_path = Path(str(decky.HOME)) + + steam_common = home_path / ".local" / "share" / "Steam" / "steamapps" / "common" + + return { + "home": str(home_path), + "steam_common": str(steam_common), + } + async def log_error(self, error: str) -> None: decky.logger.error(f"FRONTEND: {error}") diff --git a/src/api/index.ts b/src/api/index.ts index 11e4213..1cc3285 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,3 +21,8 @@ export const listInstalledGames = callable< >("list_installed_games"); export const logError = callable<[string], void>("log_error"); + +export const getPathDefaults = callable< + [], + { home: string; steam_common?: string } +>("get_path_defaults"); diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx index 9e7d34b..9915efc 100644 --- a/src/components/CustomPathOverride.tsx +++ b/src/components/CustomPathOverride.tsx @@ -1,6 +1,7 @@ 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 { @@ -8,9 +9,23 @@ interface CustomPathOverrideProps { } const DEFAULT_START_PATH = "/home"; +const DEFAULT_STEAM_LIBRARY_PATH = "/home/deck/.local/share/Steam/steamapps/common"; + +interface PathDefaults { + home: string; + steamCommon: string; +} + +const INITIAL_PATH_DEFAULTS: PathDefaults = { + home: DEFAULT_START_PATH, + steamCommon: DEFAULT_STEAM_LIBRARY_PATH, +}; const normalizePath = (path: string) => path.replace(/\\/g, "/"); +const stripTrailingSlash = (value: string) => + value.endsWith("/") ? value.slice(0, -1) : value; + const escapeForDoubleQuotes = (value: string) => value.replace(/[`"\\$]/g, (match) => `\\${match}`); @@ -31,6 +46,14 @@ const escapeForReplacement = (value: string) => 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 : "/"; +}; + const longestCommonPrefix = (left: string[], right: string[]) => { const length = Math.min(left.length, right.length); let idx = 0; @@ -129,6 +152,40 @@ export const CustomPathOverride = ({ onOverrideChange }: CustomPathOverrideProps const [launcherPath, setLauncherPath] = useState(null); const [overridePath, setOverridePath] = useState(null); const [isEnabled, setEnabled] = useState(false); + const [pathDefaults, setPathDefaults] = useState(INITIAL_PATH_DEFAULTS); + + useEffect(() => { + let cancelled = false; + + const fetchDefaults = async () => { + try { + const result = await getPathDefaults(); + if (!result) { + return; + } + + const home = result.home ? normalizePath(result.home) : INITIAL_PATH_DEFAULTS.home; + const steamCommonSource = result.steam_common + ? normalizePath(result.steam_common) + : normalizePath(`${stripTrailingSlash(home)}/.local/share/Steam/steamapps/common`); + + if (!cancelled) { + setPathDefaults({ + home, + steamCommon: steamCommonSource || INITIAL_PATH_DEFAULTS.steamCommon, + }); + } + } catch (err) { + console.error("CustomPathOverride -> getPathDefaults", err); + } + }; + + fetchDefaults(); + + return () => { + cancelled = true; + }; + }, []); const { config, error } = useMemo( () => buildOverride(launcherPath, overridePath), @@ -143,27 +200,58 @@ export const CustomPathOverride = ({ onOverrideChange }: CustomPathOverrideProps } }, [config, isEnabled, onOverrideChange]); + interface PickerArgs { + existing: string | null; + setter: (value: string) => void; + fallbackStart?: string | null; + } + const openPicker = useCallback( - async (existing: string | null, setter: (value: string) => void) => { - try { - const startPath = existing ? normalizePath(existing) : DEFAULT_START_PATH; - const result = await openFilePicker( - FileSelectionType.FILE, - startPath, - true, - false, - undefined, - undefined, - true - ); - if (result?.path) { - setter(normalizePath(result.path)); + async ({ existing, setter, fallbackStart }: PickerArgs) => { + const candidates = new Set(); + + if (existing) { + candidates.add(normalizePath(existing)); + } else { + if (fallbackStart) { + candidates.add(normalizePath(fallbackStart)); } - } catch (err) { - console.error("CustomPathOverride -> openPicker", err); + candidates.add(pathDefaults.steamCommon); + candidates.add(pathDefaults.home); + } + + let lastError: unknown = null; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + 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; + } + } + + if (lastError) { + console.error("CustomPathOverride -> openPicker", lastError); } }, - [] + [pathDefaults] ); const handleToggle = (value: boolean) => { @@ -193,7 +281,13 @@ export const CustomPathOverride = ({ onOverrideChange }: CustomPathOverrideProps openPicker(launcherPath, setLauncherPath)} + onClick={() => + openPicker({ + existing: launcherPath, + setter: setLauncherPath, + fallbackStart: pathDefaults.steamCommon, + }) + } description={launcherPath || "Pick the EXE Steam currently uses."} > Select Steam-provided EXE @@ -203,8 +297,20 @@ export const CustomPathOverride = ({ onOverrideChange }: CustomPathOverrideProps openPicker(overridePath, setOverridePath)} - description={overridePath || "Pick the executable that should run instead."} + disabled={!launcherPath} + onClick={() => + launcherPath && + openPicker({ + existing: overridePath, + setter: setOverridePath, + fallbackStart: launcherPath ? dirname(launcherPath) : pathDefaults.steamCommon, + }) + } + description={ + launcherPath + ? overridePath || "Pick the executable that should run instead." + : "Select the Steam-provided executable first." + } > Select Override EXE -- cgit v1.2.3 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 From 1b4a70c67013b87ff413506d5a25f66e4eb5bb87 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 20:21:36 -0400 Subject: fix patching, logging additions --- main.py | 303 ++++++++++++++++++++++++++++++++++++---------------------------- 1 file changed, 171 insertions(+), 132 deletions(-) diff --git a/main.py b/main.py index 881298a..5b6b8e8 100644 --- a/main.py +++ b/main.py @@ -420,155 +420,194 @@ class Plugin: for file_name in required_files: if not path.joinpath(file_name).exists(): return {"exists": False} - + # Check plugins directory and OptiPatcher ASI plugins_dir = path / "plugins" if not plugins_dir.exists() or not (plugins_dir / "OptiPatcher.asi").exists(): return {"exists": False} - + return {"exists": True} 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.", - } + def _resolve_target_directory(self, directory: str) -> Path: + decky.logger.info(f"Resolving target directory: {directory}") + 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}") + decky.logger.info(f"Resolved directory {directory} to absolute path {target}") + 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.", + } - dll_name = "dxgi.dll" - preserve_ini = True + optiscaler_dll = fgmod_path / "OptiScaler.dll" + if not optiscaler_dll.exists(): + return { + "status": "error", + "message": "OptiScaler.dll not found in ~/fgmod. Reinstall OptiScaler.", + } - try: - decky.logger.info(f"Manual patch started for {directory}") + dll_name = "dxgi.dll" + preserve_ini = True - for filename in INJECTOR_FILENAMES: - path = directory / filename - if path.exists(): - path.unlink() + try: + decky.logger.info(f"Manual patch started for {directory}") + + removed_injectors = [] + for filename in INJECTOR_FILENAMES: + path = directory / filename + if path.exists(): + path.unlink() + removed_injectors.append(filename) + decky.logger.info(f"Removed injector DLLs: {removed_injectors}" if removed_injectors else "No injector DLLs found to remove") + + backed_up_originals = [] + 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) + backed_up_originals.append(dll) + decky.logger.info(f"Backed up original DLLs: {backed_up_originals}" if backed_up_originals else "No original DLLs required backup") + + removed_legacy = [] + for legacy in ["nvapi64.dll", "nvapi64.dll.b"]: + legacy_path = directory / legacy + if legacy_path.exists(): + legacy_path.unlink() + removed_legacy.append(legacy) + decky.logger.info(f"Removed legacy files: {removed_legacy}" if removed_legacy else "No legacy files to remove") + + 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) + decky.logger.info(f"Copied injector DLL from {source_for_copy} to {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) + decky.logger.info(f"Copied OptiScaler.ini from {source_ini} to {target_ini}") + else: + decky.logger.warning("No OptiScaler.ini found to copy") - 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}", - } + plugins_src = fgmod_path / "plugins" + plugins_dest = directory / "plugins" + if plugins_src.exists(): + shutil.copytree(plugins_src, plugins_dest, dirs_exist_ok=True) + decky.logger.info(f"Synced plugins directory from {plugins_src} to {plugins_dest}") + else: + decky.logger.warning("Plugins directory missing in fgmod bundle") + + copied_support = [] + missing_support = [] + for filename in SUPPORT_FILES: + source = fgmod_path / filename + dest = directory / filename + if source.exists(): + shutil.copy2(source, dest) + copied_support.append(filename) + else: + missing_support.append(filename) + if copied_support: + decky.logger.info(f"Copied support files: {copied_support}") + if missing_support: + decky.logger.warning(f"Support files missing from fgmod bundle: {missing_support}") - 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}", - } + decky.logger.info(f"Manual patch complete for {directory}") + return { + "status": "success", + "message": f"OptiScaler files copied to {directory}", + } - def _manual_unpatch_directory_impl(self, directory: Path) -> dict: - try: - decky.logger.info(f"Manual unpatch started for {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}", + } - for filename in set(INJECTOR_FILENAMES + SUPPORT_FILES): - path = directory / filename - if path.exists(): + def _manual_unpatch_directory_impl(self, directory: Path) -> dict: + try: + decky.logger.info(f"Manual unpatch started for {directory}") + + removed_files = [] + for filename in set(INJECTOR_FILENAMES + SUPPORT_FILES): + path = directory / filename + if path.exists(): + path.unlink() + removed_files.append(filename) + decky.logger.info(f"Removed injector/support files: {removed_files}" if removed_files else "No injector/support files found to remove") + + legacy_removed = [] + for legacy in LEGACY_FILES: + path = directory / legacy + if path.exists(): + try: path.unlink() + except IsADirectoryError: + shutil.rmtree(path, ignore_errors=True) + legacy_removed.append(legacy) + decky.logger.info(f"Removed legacy artifacts: {legacy_removed}" if legacy_removed else "No legacy artifacts present") + + plugins_dir = directory / "plugins" + if plugins_dir.exists(): + shutil.rmtree(plugins_dir, ignore_errors=True) + decky.logger.info(f"Removed plugins directory at {plugins_dir}") + + restored_backups = [] + 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) + restored_backups.append(dll) + decky.logger.info(f"Restored backups: {restored_backups}" if restored_backups else "No backups found to restore") + + uninstaller = directory / "fgmod-uninstaller.sh" + if uninstaller.exists(): + uninstaller.unlink() + decky.logger.info(f"Removed fgmod uninstaller at {uninstaller}") + + decky.logger.info(f"Manual unpatch complete for {directory}") + return { + "status": "success", + "message": f"OptiScaler files removed from {directory}", + } - 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}", - } + 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: -- cgit v1.2.3 From c22c11714150d53701016efe2daeb5cd02ae02a3 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 20:38:49 -0400 Subject: ui cleanup --- justfile | 2 +- src/components/CustomPathOverride.tsx | 36 ++++++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/justfile b/justfile index 4407567..3059bf9 100644 --- a/justfile +++ b/justfile @@ -8,5 +8,5 @@ test: scp "out/Decky Framegen.zip" deck@192.168.0.6:~/Desktop clean: - rm -rf node_modules dist + sudo rm -rf node_modules dist sudo rm -rf /tmp/decky \ No newline at end of file diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx index b28a8de..26e5c8f 100644 --- a/src/components/CustomPathOverride.tsx +++ b/src/components/CustomPathOverride.tsx @@ -61,6 +61,7 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = const [isPatching, setIsPatching] = useState(false); const [isUnpatching, setIsUnpatching] = useState(false); const [operationResult, setOperationResult] = useState(null); + const [lastOperation, setLastOperation] = useState<"patch" | "unpatch" | null>(null); useEffect(() => { let cancelled = false; @@ -94,6 +95,7 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = setEnabled(false); setPickerState(INITIAL_PICKER_STATE); setOperationResult(null); + setLastOperation(null); } }, [isAvailable]); @@ -101,6 +103,13 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = const selectedPath = pickerState.selectedPath; const statusMessage = useMemo(() => formatResultMessage(operationResult), [operationResult]); const wasSuccessful = operationResult?.status === "success"; + const statusLabel = useMemo(() => { + if (!operationResult || !lastOperation) return null; + if (operationResult.status === "success") { + return lastOperation === "patch" ? "Game patched" : "Game unpatched"; + } + return lastOperation === "patch" ? "Patch failed" : "Unpatch failed"; + }, [lastOperation, operationResult]); const openDirectoryPicker = useCallback(async () => { const candidates = [ @@ -146,6 +155,7 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = if (!selectedPath) return; const setBusy = action === "patch" ? setIsPatching : setIsUnpatching; + setLastOperation(action); setBusy(true); setOperationResult(null); @@ -177,6 +187,7 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = if (!value) { setPickerState(INITIAL_PICKER_STATE); setOperationResult(null); + setLastOperation(null); } }; @@ -204,9 +215,7 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = Select directory @@ -230,7 +239,21 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = label="Target directory" description="OptiScaler files will be copied here." > - {selectedPath} +
+ {selectedPath} +
@@ -259,10 +282,9 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = {operationResult && ( - {statusMessage} + {!wasSuccessful && statusMessage ? statusMessage : null} )} -- cgit v1.2.3 From 1ff60fe98f2adeb4f19a5db5ae8b10ebc77a2749 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 20:59:29 -0400 Subject: ui cleanup and hide on mode change to manual --- src/components/CustomPathOverride.tsx | 12 ++++++++++-- src/components/OptiScalerControls.tsx | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx index 26e5c8f..efae9de 100644 --- a/src/components/CustomPathOverride.tsx +++ b/src/components/CustomPathOverride.tsx @@ -3,6 +3,7 @@ import { ButtonItem, Field, PanelSectionRow, ToggleField } from "@decky/ui"; import { FileSelectionType, openFilePicker } from "@decky/api"; import { getPathDefaults, runManualPatch, runManualUnpatch } from "../api"; import type { ApiResponse } from "../types/index"; +import { SmartClipboardButton } from "./SmartClipboardButton"; interface PathDefaults { home: string; @@ -34,6 +35,7 @@ const ensureDirectory = (value: string) => { interface ManualPatchControlsProps { isAvailable: boolean; + onManualModeChange?: (enabled: boolean) => void; } interface PickerState { @@ -54,7 +56,7 @@ const formatResultMessage = (result: ApiResponse | null) => { return result.message || result.output || "Operation failed."; }; -export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) => { +export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualPatchControlsProps) => { const [isEnabled, setEnabled] = useState(false); const [defaults, setDefaults] = useState(INITIAL_DEFAULTS); const [pickerState, setPickerState] = useState(INITIAL_PICKER_STATE); @@ -96,8 +98,9 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = setPickerState(INITIAL_PICKER_STATE); setOperationResult(null); setLastOperation(null); + onManualModeChange?.(false); } - }, [isAvailable]); + }, [isAvailable, onManualModeChange]); const canInteract = isAvailable && isEnabled; const selectedPath = pickerState.selectedPath; @@ -184,6 +187,7 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = } setEnabled(value); + onManualModeChange?.(value); if (!value) { setPickerState(INITIAL_PICKER_STATE); setOperationResult(null); @@ -211,6 +215,10 @@ export const ManualPatchControls = ({ isAvailable }: ManualPatchControlsProps) = {canInteract && ( <> + (null); const [uninstallResult, setUninstallResult] = useState(null); + const [manualModeEnabled, setManualModeEnabled] = useState(false); useEffect(() => { if (installResult) { return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); @@ -76,12 +77,18 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont - + - - - - + {!manualModeEnabled && ( + <> + + + + + )} Date: Fri, 26 Sep 2025 21:32:42 -0400 Subject: rm submodule --- DeckyFileServer | 1 - 1 file changed, 1 deletion(-) delete mode 160000 DeckyFileServer diff --git a/DeckyFileServer b/DeckyFileServer deleted file mode 160000 index ef58c4f..0000000 --- a/DeckyFileServer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ef58c4fccd0a99072d75d5726b04cc44f349a311 -- cgit v1.2.3 From cf29301fc9270a574549ead06982431551fe43ac Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 26 Sep 2025 21:44:17 -0400 Subject: desc tweak --- src/components/CustomPathOverride.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx index efae9de..ffc4b1f 100644 --- a/src/components/CustomPathOverride.tsx +++ b/src/components/CustomPathOverride.tsx @@ -201,7 +201,7 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualP <> Date: Fri, 26 Sep 2025 21:44:41 -0400 Subject: bump plugin version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c7ccfec..f9863c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "decky-framegen", - "version": "0.12.6", + "version": "0.13.0", "description": "This plugin installs and manages OptiScaler, a tool that enhances upscaling and enables frame generation in a range of DirectX 12 games.", "type": "module", "scripts": { -- cgit v1.2.3