summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2025-12-06 21:54:45 -0500
committerGitHub <noreply@github.com>2025-12-06 21:54:45 -0500
commiteb4cb1f5aa2f76bc7dac8d48c583928a1f3f4047 (patch)
tree85274d33020adbac5b804480032eef895eb2ba08
parent1369909692f0c3eb617f3125027b681cef373ecd (diff)
parente6c32d27db7075d66ecef33f368bdd69d3b035d0 (diff)
downloadDecky-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.yaml15
-rw-r--r--justfile2
-rw-r--r--main.py264
-rw-r--r--package.json2
-rw-r--r--src/api/index.ts15
-rw-r--r--src/components/CustomPathOverride.tsx303
-rw-r--r--src/components/OptiScalerControls.tsx19
-rw-r--r--src/components/index.ts1
-rw-r--r--src/index.tsx5
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
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/main.py b/main.py
index 76a7b22..5b6b8e8 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")
@@ -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 /> */}