From d81bb130385114389728f849d0ab8cccf62b90d1 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 20 Mar 2026 17:32:52 -0400 Subject: Add Steam UI for prefix-managed integration --- src/components/ClipboardCommands.tsx | 12 +-- src/components/InstalledGamesSection.tsx | 158 ++++++++++++++++++++----------- src/components/InstructionCard.tsx | 6 +- src/components/OptiScalerControls.tsx | 56 ++++------- src/components/SmartClipboardButton.tsx | 123 ++++++------------------ src/components/index.ts | 3 +- 6 files changed, 156 insertions(+), 202 deletions(-) (limited to 'src/components') diff --git a/src/components/ClipboardCommands.tsx b/src/components/ClipboardCommands.tsx index 5a6f38f..5344a6b 100644 --- a/src/components/ClipboardCommands.tsx +++ b/src/components/ClipboardCommands.tsx @@ -9,14 +9,14 @@ export function ClipboardCommands({ pathExists }: ClipboardCommandsProps) { return ( <> - - - ); diff --git a/src/components/InstalledGamesSection.tsx b/src/components/InstalledGamesSection.tsx index 71278d7..eb750c8 100644 --- a/src/components/InstalledGamesSection.tsx +++ b/src/components/InstalledGamesSection.tsx @@ -1,122 +1,166 @@ -import { useState, useEffect } from "react"; -import { PanelSection, PanelSectionRow, ButtonItem, DropdownItem, ConfirmModal, showModal } from "@decky/ui"; -import { listInstalledGames, logError } from "../api"; +import { useEffect, useState } from "react"; +import { + ButtonItem, + ConfirmModal, + DropdownItem, + PanelSection, + PanelSectionRow, + showModal, +} from "@decky/ui"; +import { cleanupManagedGame, listInstalledGames, logError } from "../api"; import { safeAsyncOperation } from "../utils"; -import { STYLES } from "../utils/constants"; import { GameInfo } from "../types/index"; +import { STYLES } from "../utils/constants"; + +const DEFAULT_LAUNCH_COMMAND = 'OPTISCALER_PROXY=winmm ~/fgmod/fgmod %COMMAND%'; -export function InstalledGamesSection() { +interface InstalledGamesSectionProps { + isAvailable: boolean; +} + +export function InstalledGamesSection({ isAvailable }: InstalledGamesSectionProps) { const [games, setGames] = useState([]); const [selectedGame, setSelectedGame] = useState(null); - const [result, setResult] = useState(''); + const [result, setResult] = useState(""); + const [loadingGames, setLoadingGames] = useState(false); + const [enabling, setEnabling] = useState(false); + const [disabling, setDisabling] = useState(false); useEffect(() => { + if (!isAvailable) return; + + let cancelled = false; + const fetchGames = async () => { - const response = await safeAsyncOperation( - async () => await listInstalledGames(), - 'fetchGames' - ); - - if (response?.status === "success") { + setLoadingGames(true); + const response = await safeAsyncOperation(async () => await listInstalledGames(), "InstalledGamesSection.fetchGames"); + + if (cancelled || !response) { + setLoadingGames(false); + return; + } + + if (response.status === "success") { const sortedGames = [...response.games] - .map(game => ({ + .map((game) => ({ ...game, - appid: parseInt(game.appid, 10), + appid: parseInt(String(game.appid), 10), })) .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); setGames(sortedGames); - } else if (response) { - logError('fetchGames: ' + JSON.stringify(response)); - console.error('fetchGames: ' + JSON.stringify(response)); + } else { + logError(`InstalledGamesSection.fetchGames: ${JSON.stringify(response)}`); } + + setLoadingGames(false); }; - + fetchGames(); - }, []); - const handlePatchClick = async () => { + return () => { + cancelled = true; + }; + }, [isAvailable]); + + const handleEnable = async () => { if (!selectedGame) return; - // Show confirmation modal showModal( - { + setEnabling(true); try { - await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod %COMMAND%'); - setResult(`✓ Frame generation enabled for ${selectedGame.name}. Launch the game, enable DLSS in graphics settings, then press Insert to access OptiScaler options.`); + await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, DEFAULT_LAUNCH_COMMAND); + setResult(`✓ Enabled prefix-managed OptiScaler for ${selectedGame.name}. Launch the game, enable DLSS if needed, then press Insert for the OptiScaler menu.`); } catch (error) { - logError('handlePatchClick: ' + String(error)); - setResult(error instanceof Error ? `Error: ${error.message}` : 'Error enabling frame generation'); + logError(`InstalledGamesSection.handleEnable: ${String(error)}`); + setResult(error instanceof Error ? `Error: ${error.message}` : "Error enabling prefix-managed OptiScaler"); + } finally { + setEnabling(false); } }} /> ); }; - const handleUnpatchClick = async () => { + const handleDisable = async () => { if (!selectedGame) return; + setDisabling(true); try { - await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod-uninstaller.sh %COMMAND%'); - setResult(`✓ Frame generation will be disabled on next launch of ${selectedGame.name}.`); + const cleanupResult = await cleanupManagedGame(String(selectedGame.appid)); + if (cleanupResult?.status !== "success") { + setResult(`Error: ${cleanupResult?.message || cleanupResult?.output || "Failed to clean managed compatdata prefix"}`); + return; + } + + await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, ""); + setResult(`✓ Cleared launch options and cleaned the managed compatdata prefix for ${selectedGame.name}.`); } catch (error) { - logError('handleUnpatchClick: ' + String(error)); - setResult(error instanceof Error ? `Error: ${error.message}` : 'Error disabling frame generation'); + logError(`InstalledGamesSection.handleDisable: ${String(error)}`); + setResult(error instanceof Error ? `Error: ${error.message}` : "Error disabling prefix-managed OptiScaler"); + } finally { + setDisabling(false); } }; + if (!isAvailable) return null; + return ( - + ({ + rgOptions={games.map((game) => ({ data: game.appid, - label: game.name + label: game.name, }))} selectedOption={selectedGame?.appid} onChange={(option) => { - const game = games.find(g => g.appid === option.data); + const game = games.find((entry) => entry.appid === option.data); setSelectedGame(game || null); - setResult(''); + setResult(""); }} - strDefaultLabel="Choose a game" - menuLabel="Installed Games" + strDefaultLabel={loadingGames ? "Loading installed games..." : "Choose a game"} + menuLabel="Installed Steam games" + disabled={loadingGames || games.length === 0} /> + +
+ Enable writes the launch option automatically. Disable clears launch options and removes staged files from the selected game's compatdata prefix. +
+
+ {result ? ( -
- {result.includes('Error') ? '❌' : '✅'} {result} +
+ {result.startsWith("Error") ? "❌" : "✅"} {result}
) : null} - + {selectedGame ? ( <> - - Enable Frame Generation + + {enabling ? "Enabling..." : "Enable for selected game"} - - Disable Frame Generation + + {disabling ? "Cleaning..." : "Disable and clean selected game"} diff --git a/src/components/InstructionCard.tsx b/src/components/InstructionCard.tsx index fdf6755..392c782 100644 --- a/src/components/InstructionCard.tsx +++ b/src/components/InstructionCard.tsx @@ -11,12 +11,10 @@ export function InstructionCard({ pathExists }: InstructionCardProps) { return (
-
+
{MESSAGES.instructionTitle}
-
- {MESSAGES.instructionText} -
+
{MESSAGES.instructionText}
); diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx index 468683c..4a33b58 100644 --- a/src/components/OptiScalerControls.tsx +++ b/src/components/OptiScalerControls.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { PanelSection } from "@decky/ui"; import { runInstallFGMod, runUninstallFGMod } from "../api"; import { OperationResult } from "./ResultDisplay"; @@ -10,7 +10,7 @@ import { ClipboardCommands } from "./ClipboardCommands"; import { InstructionCard } from "./InstructionCard"; import { OptiScalerWiki } from "./OptiScalerWiki"; import { UninstallButton } from "./UninstallButton"; -import { ManualPatchControls } from "./CustomPathOverride"; +import { InstalledGamesSection } from "./InstalledGamesSection"; interface OptiScalerControlsProps { pathExists: boolean | null; @@ -22,19 +22,15 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont const [uninstalling, setUninstalling] = useState(false); const [installResult, setInstallResult] = useState(null); const [uninstallResult, setUninstallResult] = useState(null); - const [manualModeEnabled, setManualModeEnabled] = useState(false); + useEffect(() => { - if (installResult) { - return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); - } - return () => {}; // Ensure a cleanup function is always returned + if (!installResult) return () => {}; + return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); }, [installResult]); useEffect(() => { - if (uninstallResult) { - return createAutoCleanupTimer(() => setUninstallResult(null), TIMEOUTS.resultDisplay); - } - return () => {}; // Ensure a cleanup function is always returned + if (!uninstallResult) return () => {}; + return createAutoCleanupTimer(() => setUninstallResult(null), TIMEOUTS.resultDisplay); }, [uninstallResult]); const handleInstallClick = async () => { @@ -45,8 +41,8 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont if (result.status === "success") { setPathExists(true); } - } catch (e) { - console.error(e); + } catch (error) { + console.error(error); } finally { setInstalling(false); } @@ -60,8 +56,8 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont if (result.status === "success") { setPathExists(false); } - } catch (e) { - console.error(e); + } catch (error) { + console.error(error); } finally { setUninstalling(false); } @@ -69,33 +65,13 @@ export function OptiScalerControls({ pathExists, setPathExists }: OptiScalerCont return ( - - + - - - - {!manualModeEnabled && ( - <> - - - - - )} + + + - - + ); } diff --git a/src/components/SmartClipboardButton.tsx b/src/components/SmartClipboardButton.tsx index d88a58a..8cc52b1 100644 --- a/src/components/SmartClipboardButton.tsx +++ b/src/components/SmartClipboardButton.tsx @@ -1,119 +1,68 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem, ConfirmModal, showModal } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; +import { useEffect, useState } from "react"; +import { ButtonItem, PanelSectionRow } from "@decky/ui"; import { toaster } from "@decky/api"; +import { FaCheck, FaClipboard } from "react-icons/fa"; interface SmartClipboardButtonProps { command?: string; buttonText?: string; } -export function SmartClipboardButton({ - command = "~/fgmod/fgmod %command%", - buttonText = "Copy Launch Command" +export function SmartClipboardButton({ + command = 'OPTISCALER_PROXY=winmm ~/fgmod/fgmod %command%', + buttonText = 'Copy Launch Command', }: SmartClipboardButtonProps) { const [isLoading, setIsLoading] = useState(false); const [showSuccess, setShowSuccess] = useState(false); - // Reset success state after 3 seconds useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; + if (!showSuccess) return undefined; + const timer = setTimeout(() => setShowSuccess(false), 3000); + return () => clearTimeout(timer); }, [showSuccess]); - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - const isPatchCommand = command.includes("fgmod %command%") && !command.includes("uninstaller"); - - if (isPatchCommand) { - showModal( - { - await performCopy(); - }} - /> - ); - return; - } - - // For non-patch commands, copy directly - await performCopy(); - }; - const performCopy = async () => { if (isLoading || showSuccess) return; - + setIsLoading(true); try { - const text = command; - - // Use the proven input simulation method - const tempInput = document.createElement('input'); - tempInput.value = text; - tempInput.style.position = 'absolute'; - tempInput.style.left = '-9999px'; + const tempInput = document.createElement("input"); + tempInput.value = command; + tempInput.style.position = "absolute"; + tempInput.style.left = "-9999px"; document.body.appendChild(tempInput); - - // Focus and select the text tempInput.focus(); tempInput.select(); - - // Try copying using execCommand first (most reliable in gaming mode) + let copySuccess = false; try { - if (document.execCommand('copy')) { + if (document.execCommand("copy")) { copySuccess = true; } - } catch (e) { - // If execCommand fails, try navigator.clipboard as fallback + } catch (execError) { try { - await navigator.clipboard.writeText(text); + await navigator.clipboard.writeText(command); copySuccess = true; } catch (clipboardError) { - console.error('Both copy methods failed:', e, clipboardError); + console.error("Clipboard copy failed", execError, clipboardError); } } - - // Clean up + document.body.removeChild(tempInput); - - if (copySuccess) { - // Show success feedback in the button instead of toast - setShowSuccess(true); - // Verify the copy worked by reading back - try { - const readBack = await navigator.clipboard.readText(); - if (readBack !== text) { - // Copy worked but verification failed - still show success - console.log('Copy verification failed but copy likely worked'); - } - } catch (e) { - // Verification failed but copy likely worked - console.log('Copy verification unavailable but copy likely worked'); - } - } else { + + if (!copySuccess) { toaster.toast({ title: "Copy Failed", - body: "Unable to copy to clipboard" + body: "Unable to copy to clipboard", }); + return; } + setShowSuccess(true); } catch (error) { toaster.toast({ title: "Copy Failed", - body: `Error: ${String(error)}` + body: `Error: ${String(error)}`, }); } finally { setIsLoading(false); @@ -122,28 +71,16 @@ export function SmartClipboardButton({ return ( - +
{showSuccess ? ( - + ) : isLoading ? ( - + ) : ( )} -
+
{showSuccess ? "Copied to clipboard" : isLoading ? "Copying..." : buttonText}
diff --git a/src/components/index.ts b/src/components/index.ts index cd599ba..18ca2b4 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,3 @@ -// Component exports for cleaner imports export { OptiScalerControls } from './OptiScalerControls'; export { InstallationStatus } from './InstallationStatus'; export { OptiScalerHeader } from './OptiScalerHeader'; @@ -8,4 +7,4 @@ export { OptiScalerWiki } from './OptiScalerWiki'; export { UninstallButton } from './UninstallButton'; export { SmartClipboardButton } from './SmartClipboardButton'; export { ResultDisplay } from './ResultDisplay'; -export { ManualPatchControls } from './CustomPathOverride'; +export { InstalledGamesSection } from './InstalledGamesSection'; -- cgit v1.2.3