summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/components/OptiScalerControls.tsx13
-rw-r--r--src/components/SteamGamePatcher.tsx277
-rw-r--r--src/components/index.ts1
-rw-r--r--src/types.d.ts10
4 files changed, 296 insertions, 5 deletions
diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx
index fb5d2f8..f88e8f9 100644
--- a/src/components/OptiScalerControls.tsx
+++ b/src/components/OptiScalerControls.tsx
@@ -11,6 +11,7 @@ import { InstructionCard } from "./InstructionCard";
import { OptiScalerWiki } from "./OptiScalerWiki";
import { UninstallButton } from "./UninstallButton";
import { ManualPatchControls } from "./CustomPathOverride";
+import { SteamGamePatcher } from "./SteamGamePatcher";
interface OptiScalerControlsProps {
pathExists: boolean | null;
@@ -91,6 +92,12 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont
</PanelSectionRow>
)}
+ {pathExists === true && (
+ <SteamGamePatcher dllName={dllName} />
+ )}
+
+ <ClipboardCommands pathExists={pathExists} dllName={dllName} />
+
<ManualPatchControls
isAvailable={pathExists === true}
onManualModeChange={setManualModeEnabled}
@@ -98,11 +105,7 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont
/>
{!manualModeEnabled && (
- <>
- <ClipboardCommands pathExists={pathExists} dllName={dllName} />
-
- <InstructionCard pathExists={pathExists} />
- </>
+ <InstructionCard pathExists={pathExists} />
)}
<OptiScalerWiki pathExists={pathExists} />
diff --git a/src/components/SteamGamePatcher.tsx b/src/components/SteamGamePatcher.tsx
new file mode 100644
index 0000000..5947f01
--- /dev/null
+++ b/src/components/SteamGamePatcher.tsx
@@ -0,0 +1,277 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { ButtonItem, DropdownItem, Field, PanelSectionRow } from "@decky/ui";
+import { listInstalledGames } from "../api";
+import { createAutoCleanupTimer } from "../utils";
+import { TIMEOUTS } from "../utils/constants";
+
+// ─── SteamClient helpers ─────────────────────────────────────────────────────
+
+/**
+ * Wrap the callback-based RegisterForAppDetails in a Promise.
+ * Resolves with the current launch options string, or "" if SteamClient is
+ * unavailable (e.g. desktop / dev mode). Times out after 5 seconds.
+ */
+const getSteamLaunchOptions = (appId: number): Promise<string> =>
+ new Promise((resolve, reject) => {
+ if (
+ typeof SteamClient === "undefined" ||
+ !SteamClient?.Apps?.RegisterForAppDetails
+ ) {
+ resolve("");
+ return;
+ }
+
+ let settled = false;
+ let unregister = () => {};
+
+ const timeout = window.setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ unregister();
+ reject(new Error("Timed out reading launch options."));
+ }, 5000);
+
+ const registration = SteamClient.Apps.RegisterForAppDetails(
+ appId,
+ (details: { strLaunchOptions?: string }) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ unregister();
+ resolve(details?.strLaunchOptions ?? "");
+ }
+ );
+
+ unregister = registration.unregister;
+ });
+
+const setSteamLaunchOptions = (appId: number, options: string): void => {
+ if (
+ typeof SteamClient === "undefined" ||
+ !SteamClient?.Apps?.SetAppLaunchOptions
+ ) {
+ throw new Error("SteamClient.Apps.SetAppLaunchOptions is not available.");
+ }
+ SteamClient.Apps.SetAppLaunchOptions(appId, options);
+};
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/** Remove any fgmod invocation from a launch options string, keeping the rest. */
+const stripFgmod = (opts: string): string =>
+ opts
+ .replace(/DLL=\S+\s+~\/fgmod\/fgmod\s+%command%/g, "")
+ .replace(/~\/fgmod\/fgmod\s+%command%/g, "")
+ .trim();
+
+/** Extract the DLL= value from a launch options string, if present. */
+const extractDllName = (opts: string): string | null => {
+ const m = opts.match(/DLL=(\S+)\s+~\/fgmod\/fgmod/);
+ return m ? m[1] : null;
+};
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+interface SteamGamePatcherProps {
+ dllName: string;
+}
+
+type GameEntry = { appid: string; name: string };
+
+export function SteamGamePatcher({ dllName }: SteamGamePatcherProps) {
+ const [games, setGames] = useState<GameEntry[]>([]);
+ const [gamesLoading, setGamesLoading] = useState(true);
+ const [selectedAppId, setSelectedAppId] = useState<string>("");
+ const [launchOptions, setLaunchOptions] = useState<string>("");
+ const [launchOptionsLoading, setLaunchOptionsLoading] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [resultMessage, setResultMessage] = useState<string>("");
+
+ // Auto-clear result message
+ useEffect(() => {
+ if (resultMessage) {
+ return createAutoCleanupTimer(
+ () => setResultMessage(""),
+ TIMEOUTS.resultDisplay
+ );
+ }
+ return undefined;
+ }, [resultMessage]);
+
+ // Load game list on mount
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setGamesLoading(true);
+ try {
+ const result = await listInstalledGames();
+ if (cancelled) return;
+ if (result.status === "success" && result.games.length > 0) {
+ setGames(result.games);
+ setSelectedAppId(result.games[0].appid);
+ }
+ } catch (e) {
+ console.error("SteamGamePatcher: failed to load games", e);
+ } finally {
+ if (!cancelled) setGamesLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Reload launch options when selected game changes
+ useEffect(() => {
+ if (!selectedAppId) {
+ setLaunchOptions("");
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ setLaunchOptionsLoading(true);
+ try {
+ const opts = await getSteamLaunchOptions(Number(selectedAppId));
+ if (!cancelled) setLaunchOptions(opts);
+ } catch {
+ if (!cancelled) setLaunchOptions("");
+ } finally {
+ if (!cancelled) setLaunchOptionsLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedAppId]);
+
+ const targetCommand = `DLL=${dllName} ~/fgmod/fgmod %command%`;
+ const isManaged = launchOptions.includes("fgmod/fgmod");
+ const activeDll = useMemo(() => extractDllName(launchOptions), [launchOptions]);
+ const selectedGame = useMemo(
+ () => games.find((g) => g.appid === selectedAppId) ?? null,
+ [games, selectedAppId]
+ );
+
+ const handleSet = useCallback(() => {
+ if (!selectedAppId || busy) return;
+ setBusy(true);
+ try {
+ setSteamLaunchOptions(Number(selectedAppId), targetCommand);
+ setLaunchOptions(targetCommand);
+ setResultMessage(
+ `✅ Launch options set for ${selectedGame?.name ?? selectedAppId}`
+ );
+ } catch (e) {
+ setResultMessage(`❌ ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [selectedAppId, targetCommand, selectedGame, busy]);
+
+ const handleRemove = useCallback(() => {
+ if (!selectedAppId || busy) return;
+ setBusy(true);
+ try {
+ const stripped = stripFgmod(launchOptions);
+ setSteamLaunchOptions(Number(selectedAppId), stripped);
+ setLaunchOptions(stripped);
+ setResultMessage(
+ `✅ Removed fgmod from ${selectedGame?.name ?? selectedAppId}`
+ );
+ } catch (e) {
+ setResultMessage(`❌ ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [selectedAppId, launchOptions, selectedGame, busy]);
+
+ // ── Status display ──────────────────────────────────────────────────────────
+ const statusText = useMemo(() => {
+ if (!selectedGame) return "—";
+ if (launchOptionsLoading) return "Loading...";
+ if (!isManaged) return "Not set";
+ if (activeDll && activeDll !== dllName)
+ return `Active — ${activeDll} · switch to apply ${dllName}`;
+ return `Active — ${activeDll ?? dllName}`;
+ }, [selectedGame, launchOptionsLoading, isManaged, activeDll, dllName]);
+
+ const statusColor = useMemo(() => {
+ if (!isManaged || launchOptionsLoading) return undefined;
+ if (activeDll && activeDll !== dllName) return "#ffd866"; // yellow — different DLL selected
+ return "#3fb950"; // green — active and matching
+ }, [isManaged, launchOptionsLoading, activeDll, dllName]);
+
+ const setButtonLabel = useMemo(() => {
+ if (busy) return "Applying...";
+ if (!isManaged) return "Enable for this game";
+ if (activeDll && activeDll !== dllName) return `Switch to ${dllName}`;
+ return "Re-apply";
+ }, [busy, isManaged, activeDll, dllName]);
+
+ return (
+ <>
+ <PanelSectionRow>
+ <DropdownItem
+ label="Steam game"
+ menuLabel="Select a Steam game"
+ strDefaultLabel={
+ gamesLoading ? "Loading games..." : "Choose a game"
+ }
+ disabled={gamesLoading || games.length === 0}
+ selectedOption={selectedAppId}
+ rgOptions={games.map((g) => ({ data: g.appid, label: g.name }))}
+ onChange={(option) => {
+ setSelectedAppId(String(option.data));
+ setResultMessage("");
+ }}
+ />
+ </PanelSectionRow>
+
+ {selectedGame && (
+ <>
+ <PanelSectionRow>
+ <Field focusable label="Launch options status">
+ {statusColor ? (
+ <span style={{ color: statusColor, fontWeight: 600 }}>
+ {statusText}
+ </span>
+ ) : (
+ statusText
+ )}
+ </Field>
+ </PanelSectionRow>
+
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy || launchOptionsLoading}
+ onClick={handleSet}
+ >
+ {setButtonLabel}
+ </ButtonItem>
+ </PanelSectionRow>
+
+ {isManaged && (
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy}
+ onClick={handleRemove}
+ >
+ {busy ? "Removing..." : "Remove from launch options"}
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
+
+ {resultMessage && (
+ <PanelSectionRow>
+ <Field focusable label="Result">
+ {resultMessage}
+ </Field>
+ </PanelSectionRow>
+ )}
+ </>
+ )}
+ </>
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index cd599ba..ad47347 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -9,3 +9,4 @@ export { UninstallButton } from './UninstallButton';
export { SmartClipboardButton } from './SmartClipboardButton';
export { ResultDisplay } from './ResultDisplay';
export { ManualPatchControls } from './CustomPathOverride';
+export { SteamGamePatcher } from './SteamGamePatcher';
diff --git a/src/types.d.ts b/src/types.d.ts
index dfc0472..4077a9e 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -12,3 +12,13 @@ declare module "*.jpg" {
const content: string;
export default content;
}
+
+declare const SteamClient: {
+ Apps: {
+ RegisterForAppDetails(
+ appId: number,
+ callback: (details: { strLaunchOptions?: string }) => void
+ ): { unregister: () => void };
+ SetAppLaunchOptions(appId: number, options: string): void;
+ };
+};