diff options
Diffstat (limited to 'src/components/CustomPathOverride.tsx')
| -rw-r--r-- | src/components/CustomPathOverride.tsx | 284 |
1 files changed, 284 insertions, 0 deletions
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<string | null>(null); + const [overridePath, setOverridePath] = useState<string | null>(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 ( + <> + <PanelSectionRow> + <ToggleField + label="Custom Launcher Override" + description="Select launcher and target executables from the Deck's file browser." + checked={isEnabled} + onChange={handleToggle} + /> + </PanelSectionRow> + + {isEnabled && ( + <> + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={() => openPicker(launcherPath, setLauncherPath)} + description={launcherPath || "Pick the EXE Steam currently uses."} + > + Select Steam-provided EXE + </ButtonItem> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={() => openPicker(overridePath, setOverridePath)} + description={overridePath || "Pick the executable that should run instead."} + > + Select Override EXE + </ButtonItem> + </PanelSectionRow> + + {(launcherPath || overridePath) && ( + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={() => { + setLauncherPath(null); + setOverridePath(null); + }} + > + Clear selections + </ButtonItem> + </PanelSectionRow> + )} + + {error && ( + <PanelSectionRow> + <Field + label="Override status" + description={error} + > + ⚠️ + </Field> + </PanelSectionRow> + )} + + {!error && config && ( + <> + <PanelSectionRow> + <Field + label="Detected game" + description="Based on the shared folder between both selections." + > + {config.pattern} + </Field> + </PanelSectionRow> + + <PanelSectionRow> + <Field + label="Code snippet" + description="This is added to fgmod before resolving the install path." + bottomSeparator="none" + > + <div + style={{ + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: "6px", + padding: "12px", + fontFamily: "monospace", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {config.snippet} + </div> + </Field> + </PanelSectionRow> + + <PanelSectionRow> + <Field + label="Environment preview" + description="Automatically appended to the Patch command." + > + {config.envAssignment} + </Field> + </PanelSectionRow> + </> + )} + </> + )} + </> + ); +};
\ No newline at end of file |
