summaryrefslogtreecommitdiff
path: root/src/components/CustomPathOverride.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/CustomPathOverride.tsx')
-rw-r--r--src/components/CustomPathOverride.tsx284
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