mirror of
https://github.com/xXJSONDeruloXx/Decky-Framegen.git
synced 2026-06-01 18:17:03 +02:00
initial path override ui and be
This commit is contained in:
@@ -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
|
||||
Submodule
+1
Submodule DeckyFileServer added at ef58c4fccd
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
|
||||
+4
-1
@@ -26,7 +26,10 @@ function MainContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptiScalerControls pathExists={pathExists} setPathExists={setPathExists} />
|
||||
<OptiScalerControls
|
||||
pathExists={pathExists}
|
||||
setPathExists={setPathExists}
|
||||
/>
|
||||
{pathExists === true ? (
|
||||
<>
|
||||
{/* <InstalledGamesSection /> */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user