summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.codacy/codacy.yaml15
m---------DeckyFileServer0
-rw-r--r--src/components/ClipboardCommands.tsx10
-rw-r--r--src/components/CustomPathOverride.tsx284
-rw-r--r--src/components/OptiScalerControls.tsx15
-rw-r--r--src/index.tsx5
-rw-r--r--src/types/index.ts11
7 files changed, 336 insertions, 4 deletions
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
+Subproject ef58c4fccd0a99072d75d5726b04cc44f349a31
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 (
<>
<SmartClipboardButton
- command="~/fgmod/fgmod %command%"
+ command={patchCommand}
buttonText="Copy Patch Command"
/>
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
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<OperationResult | null>(null);
const [uninstallResult, setUninstallResult] = useState<OperationResult | null>(null);
+ const [overrideConfig, setOverrideConfig] = useState<CustomOverrideConfig | null>(null);
+
+ useEffect(() => {
+ if (pathExists !== true && overrideConfig) {
+ setOverrideConfig(null);
+ }
+ }, [pathExists, overrideConfig]);
useEffect(() => {
if (installResult) {
@@ -76,7 +85,11 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont
<OptiScalerHeader pathExists={pathExists} />
- <ClipboardCommands pathExists={pathExists} />
+ {pathExists === true && (
+ <CustomPathOverride onOverrideChange={setOverrideConfig} />
+ )}
+
+ <ClipboardCommands pathExists={pathExists} overrideConfig={overrideConfig} />
<InstructionCard pathExists={pathExists} />
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 (
<>
- <OptiScalerControls pathExists={pathExists} setPathExists={setPathExists} />
+ <OptiScalerControls
+ pathExists={pathExists}
+ setPathExists={setPathExists}
+ />
{pathExists === true ? (
<>
{/* <InstalledGamesSection /> */}
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;
+}