diff options
| -rw-r--r-- | .codacy/codacy.yaml | 15 | ||||
| m--------- | DeckyFileServer | 0 | ||||
| -rw-r--r-- | src/components/ClipboardCommands.tsx | 10 | ||||
| -rw-r--r-- | src/components/CustomPathOverride.tsx | 284 | ||||
| -rw-r--r-- | src/components/OptiScalerControls.tsx | 15 | ||||
| -rw-r--r-- | src/index.tsx | 5 | ||||
| -rw-r--r-- | src/types/index.ts | 11 |
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; +} |
