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} )} )} ); };