diff options
| author | Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> | 2025-12-06 21:54:45 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-06 21:54:45 -0500 |
| commit | eb4cb1f5aa2f76bc7dac8d48c583928a1f3f4047 (patch) | |
| tree | 85274d33020adbac5b804480032eef895eb2ba08 | |
| parent | 1369909692f0c3eb617f3125027b681cef373ecd (diff) | |
| parent | e6c32d27db7075d66ecef33f368bdd69d3b035d0 (diff) | |
| download | Decky-Framegen-eb4cb1f5aa2f76bc7dac8d48c583928a1f3f4047.tar.gz Decky-Framegen-eb4cb1f5aa2f76bc7dac8d48c583928a1f3f4047.zip | |
Merge pull request #148 from xXJSONDeruloXx/path-overrides
initial path override ui and be
| -rw-r--r-- | .codacy/codacy.yaml | 15 | ||||
| -rw-r--r-- | justfile | 2 | ||||
| -rw-r--r-- | main.py | 264 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/api/index.ts | 15 | ||||
| -rw-r--r-- | src/components/CustomPathOverride.tsx | 303 | ||||
| -rw-r--r-- | src/components/OptiScalerControls.tsx | 19 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/index.tsx | 5 |
9 files changed, 616 insertions, 10 deletions
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 @@ -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 @@ -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") @@ -370,16 +420,195 @@ 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: + 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.", + } + + 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}") + + 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") + + 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}") + + 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}") + + 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}", + } + + 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" @@ -429,5 +658,36 @@ 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}") + + 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/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": { diff --git a/src/api/index.ts b/src/api/index.ts index 11e4213..df52fee 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,3 +21,18 @@ 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"); + +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/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx new file mode 100644 index 0000000..ffc4b1f --- /dev/null +++ b/src/components/CustomPathOverride.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +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; + steamCommon: string; +} + +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 = (value: string) => value.replace(/\\/g, "/"); + +const stripTrailingSlash = (value: string) => + value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value; + +const ensureDirectory = (value: string) => { + const normalized = normalizePath(value); + const lastSegment = normalized.substring(normalized.lastIndexOf("/") + 1); + if (!lastSegment || !lastSegment.includes(".")) { + return stripTrailingSlash(normalized); + } + const parent = normalized.slice(0, normalized.lastIndexOf("/")); + return parent || "/"; +}; + +interface ManualPatchControlsProps { + isAvailable: boolean; + onManualModeChange?: (enabled: boolean) => void; +} + +interface PickerState { + selectedPath: string | null; + lastError: string | null; +} + +const INITIAL_PICKER_STATE: PickerState = { + selectedPath: null, + lastError: null, +}; + +const formatResultMessage = (result: ApiResponse | null) => { + if (!result) return null; + if (result.status === "success") { + return result.message || result.output || "Operation completed successfully."; + } + return result.message || result.output || "Operation failed."; +}; + +export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualPatchControlsProps) => { + const [isEnabled, setEnabled] = useState(false); + const [defaults, setDefaults] = useState<PathDefaults>(INITIAL_DEFAULTS); + const [pickerState, setPickerState] = useState<PickerState>(INITIAL_PICKER_STATE); + const [isPatching, setIsPatching] = useState(false); + const [isUnpatching, setIsUnpatching] = useState(false); + const [operationResult, setOperationResult] = useState<ApiResponse | null>(null); + const [lastOperation, setLastOperation] = useState<"patch" | "unpatch" | null>(null); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const response = await getPathDefaults(); + if (!response || cancelled) return; + + 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`); + + setDefaults({ + home, + steamCommon: steamCommon || DEFAULT_STEAM_COMMON, + }); + } catch (err) { + console.error("ManualPatchControls -> getPathDefaults", err); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!isAvailable) { + setEnabled(false); + setPickerState(INITIAL_PICKER_STATE); + setOperationResult(null); + setLastOperation(null); + onManualModeChange?.(false); + } + }, [isAvailable, onManualModeChange]); + + const canInteract = isAvailable && isEnabled; + 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 = [ + selectedPath, + defaults.steamCommon, + defaults.home, + ]; + + let lastError: string | null = null; + + for (const candidate of candidates) { + if (!candidate) continue; + + const startPath = ensureDirectory(candidate); + + 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); + } + } + + 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; + setLastOperation(action); + 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); + } + }, + [selectedPath] + ); + + const handleToggle = (value: boolean) => { + if (!isAvailable) { + setEnabled(false); + return; + } + + setEnabled(value); + onManualModeChange?.(value); + if (!value) { + setPickerState(INITIAL_PICKER_STATE); + setOperationResult(null); + setLastOperation(null); + } + }; + + const busy = isPatching || isUnpatching; + + return ( + <> + <PanelSectionRow> + <ToggleField + label="Advanced Mode" + description={ + isAvailable + ? "Manually apply OptiScaler to a specific game directory." + : "Install OptiScaler first to enable manual patching." + } + checked={isEnabled && isAvailable} + disabled={!isAvailable} + onChange={handleToggle} + /> + </PanelSectionRow> + + {canInteract && ( + <> + <SmartClipboardButton + command='WINEDLLOVERRIDES="dxgi=n,b" SteamDeck=0 %command%' + buttonText="Manual launch cmd" + /> + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={openDirectoryPicker} + description="Choose the game's installation directory (where the EXE lives)." + > + Select directory + </ButtonItem> + </PanelSectionRow> + + {pickerState.lastError && ( + <PanelSectionRow> + <Field + label="Picker error" + description={pickerState.lastError} + > + ⚠️ + </Field> + </PanelSectionRow> + )} + + {selectedPath && ( + <> + <PanelSectionRow> + <Field + label="Target directory" + description="OptiScaler files will be copied here." + > + <div + style={{ + fontFamily: "monospace", + backgroundColor: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "4px", + padding: "6px 8px", + width: "100%", + boxSizing: "border-box", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {selectedPath} + </div> + </Field> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy} + onClick={() => runOperation("patch")} + > + {isPatching ? "Patching..." : "Patch directory"} + </ButtonItem> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy} + onClick={() => runOperation("unpatch")} + > + {isUnpatching ? "Reverting..." : "Unpatch directory"} + </ButtonItem> + </PanelSectionRow> + </> + )} + + {operationResult && ( + <PanelSectionRow> + <Field + label={statusLabel ?? (wasSuccessful ? "Last action succeeded" : "Last action failed")} + > + {!wasSuccessful && statusMessage ? statusMessage : null} + </Field> + </PanelSectionRow> + )} + </> + )} + </> + ); +};
\ No newline at end of file diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx index 7b2db0e..468683c 100644 --- a/src/components/OptiScalerControls.tsx +++ b/src/components/OptiScalerControls.tsx @@ -10,6 +10,7 @@ import { ClipboardCommands } from "./ClipboardCommands"; import { InstructionCard } from "./InstructionCard"; import { OptiScalerWiki } from "./OptiScalerWiki"; import { UninstallButton } from "./UninstallButton"; +import { ManualPatchControls } from "./CustomPathOverride"; interface OptiScalerControlsProps { pathExists: boolean | null; @@ -21,7 +22,7 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont const [uninstalling, setUninstalling] = useState(false); const [installResult, setInstallResult] = useState<OperationResult | null>(null); const [uninstallResult, setUninstallResult] = useState<OperationResult | null>(null); - + const [manualModeEnabled, setManualModeEnabled] = useState(false); useEffect(() => { if (installResult) { return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); @@ -76,10 +77,18 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont <OptiScalerHeader pathExists={pathExists} /> - <ClipboardCommands pathExists={pathExists} /> - - <InstructionCard pathExists={pathExists} /> - + <ManualPatchControls + isAvailable={pathExists === true} + onManualModeChange={setManualModeEnabled} + /> + + {!manualModeEnabled && ( + <> + <ClipboardCommands pathExists={pathExists} /> + + <InstructionCard pathExists={pathExists} /> + </> + )} <OptiScalerWiki pathExists={pathExists} /> <UninstallButton 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/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 ( <> - <OptiScalerControls pathExists={pathExists} setPathExists={setPathExists} /> + <OptiScalerControls + pathExists={pathExists} + setPathExists={setPathExists} + /> {pathExists === true ? ( <> {/* <InstalledGamesSection /> */} |
