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