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 --- src/components/CustomPathOverride.tsx | 284 ++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 src/components/CustomPathOverride.tsx (limited to 'src/components/CustomPathOverride.tsx') 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 -- cgit v1.2.3