diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/index.ts | 23 | ||||
| -rw-r--r-- | src/components/FGModInstallerSection.tsx | 103 | ||||
| -rw-r--r-- | src/components/InstalledGamesSection.tsx | 123 | ||||
| -rw-r--r-- | src/components/ResultDisplay.tsx | 37 | ||||
| -rw-r--r-- | src/exports.ts | 14 | ||||
| -rw-r--r--[-rwxr-xr-x] | src/index.tsx | 303 | ||||
| -rw-r--r-- | src/types/index.ts | 26 | ||||
| -rw-r--r-- | src/utils/constants.ts | 42 | ||||
| -rw-r--r-- | src/utils/index.ts | 30 |
9 files changed, 401 insertions, 300 deletions
diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..11e4213 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,23 @@ +import { callable } from "@decky/api"; + +export const runInstallFGMod = callable< + [], + { status: string; message?: string; output?: string } +>("run_install_fgmod"); + +export const runUninstallFGMod = callable< + [], + { status: string; message?: string; output?: string } +>("run_uninstall_fgmod"); + +export const checkFGModPath = callable< + [], + { exists: boolean } +>("check_fgmod_path"); + +export const listInstalledGames = callable< + [], + { status: string; games: { appid: string; name: string }[] } +>("list_installed_games"); + +export const logError = callable<[string], void>("log_error"); diff --git a/src/components/FGModInstallerSection.tsx b/src/components/FGModInstallerSection.tsx new file mode 100644 index 0000000..f9f905e --- /dev/null +++ b/src/components/FGModInstallerSection.tsx @@ -0,0 +1,103 @@ +import { useState, useEffect } from "react"; +import { PanelSection, PanelSectionRow, ButtonItem } from "@decky/ui"; +import { checkFGModPath, runInstallFGMod, runUninstallFGMod } from "../api"; +import { ResultDisplay, OperationResult } from "./ResultDisplay"; +import { createAutoCleanupTimer, safeAsyncOperation } from "../utils"; +import { TIMEOUTS, MESSAGES } from "../utils/constants"; + +export function FGModInstallerSection() { + const [installing, setInstalling] = useState(false); + const [uninstalling, setUninstalling] = useState(false); + const [installResult, setInstallResult] = useState<OperationResult | null>(null); + const [uninstallResult, setUninstallResult] = useState<OperationResult | null>(null); + const [pathExists, setPathExists] = useState<boolean | null>(null); + + useEffect(() => { + const checkPath = async () => { + const result = await safeAsyncOperation( + async () => await checkFGModPath(), + 'useEffect -> checkPath' + ); + if (result) setPathExists(result.exists); + }; + + checkPath(); // Initial check + const intervalId = setInterval(checkPath, TIMEOUTS.pathCheck); // Check every 3 seconds + return () => clearInterval(intervalId); // Cleanup interval on component unmount + }, []); + + useEffect(() => { + if (installResult) { + return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); + } + return () => {}; // Ensure a cleanup function is always returned + }, [installResult]); + + useEffect(() => { + if (uninstallResult) { + return createAutoCleanupTimer(() => setUninstallResult(null), TIMEOUTS.resultDisplay); + } + return () => {}; // Ensure a cleanup function is always returned + }, [uninstallResult]); + + const handleInstallClick = async () => { + try { + setInstalling(true); + const result = await runInstallFGMod(); + setInstallResult(result); + } catch (e) { + console.error(e); + } finally { + setInstalling(false); + } + }; + + const handleUninstallClick = async () => { + try { + setUninstalling(true); + const result = await runUninstallFGMod(); + setUninstallResult(result); + } catch (e) { + console.error(e); + } finally { + setUninstalling(false); + } + }; + + return ( + <PanelSection> + {pathExists !== null ? ( + <PanelSectionRow> + <div style={{ color: pathExists ? "green" : "red" }}> + {pathExists ? MESSAGES.modInstalled : MESSAGES.modNotInstalled} + </div> + </PanelSectionRow> + ) : null} + + {pathExists === false ? ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={handleInstallClick} disabled={installing}> + {installing ? MESSAGES.installing : MESSAGES.installButton} + </ButtonItem> + </PanelSectionRow> + ) : null} + + {pathExists === true ? ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={handleUninstallClick} disabled={uninstalling}> + {uninstalling ? MESSAGES.uninstalling : MESSAGES.uninstallButton} + </ButtonItem> + </PanelSectionRow> + ) : null} + + <ResultDisplay result={installResult} /> + <ResultDisplay result={uninstallResult} /> + + <PanelSectionRow> + <div> + {MESSAGES.instructionText} + </div> + </PanelSectionRow> + </PanelSection> + ); +} diff --git a/src/components/InstalledGamesSection.tsx b/src/components/InstalledGamesSection.tsx new file mode 100644 index 0000000..30ca2a4 --- /dev/null +++ b/src/components/InstalledGamesSection.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect } from "react"; +import { PanelSection, PanelSectionRow, ButtonItem, DropdownItem, ConfirmModal, showModal } from "@decky/ui"; +import { listInstalledGames, logError } from "../api"; +import { safeAsyncOperation } from "../utils"; +import { STYLES } from "../utils/constants"; +import { GameInfo } from "../types/index"; + +export function InstalledGamesSection() { + const [games, setGames] = useState<GameInfo[]>([]); + const [selectedGame, setSelectedGame] = useState<GameInfo | null>(null); + const [result, setResult] = useState<string>(''); + + useEffect(() => { + const fetchGames = async () => { + const response = await safeAsyncOperation( + async () => await listInstalledGames(), + 'fetchGames' + ); + + if (response?.status === "success") { + const sortedGames = [...response.games] + .map(game => ({ + ...game, + appid: parseInt(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)); + } + }; + + fetchGames(); + }, []); + + const handlePatchClick = async () => { + if (!selectedGame) return; + + // Show confirmation modal + showModal( + <ConfirmModal + strTitle={`Patch ${selectedGame.name}?`} + strDescription={ + "WARNING: Decky Framegen does not unpatch games when uninstalled. Be sure to unpatch the game or verify the integrity of your game files if you choose to uninstall the plugin or the game has issues." + } + strOKButtonText="Yeah man, I wanna do it" + strCancelButtonText="Cancel" + onOK={async () => { + try { + await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod %COMMAND%'); + setResult(`Launch options set for ${selectedGame.name}. You can now select DLSS in the game's menu, and access OptiScaler with Insert key.`); + } catch (error) { + logError('handlePatchClick: ' + String(error)); + setResult(error instanceof Error ? `Error setting launch options: ${error.message}` : 'Error setting launch options'); + } + }} + /> + ); + }; + + const handleUnpatchClick = async () => { + if (!selectedGame) return; + + try { + await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod-uninstaller.sh %COMMAND%'); + setResult(`OptiScaler will uninstall on next launch of ${selectedGame.name}.`); + } catch (error) { + logError('handleUnpatchClick: ' + String(error)); + setResult(error instanceof Error ? `Error clearing launch options: ${error.message}` : 'Error clearing launch options'); + } + }; + + return ( + <PanelSection title="Select a game to patch:"> + <PanelSectionRow> + <DropdownItem + rgOptions={games.map(game => ({ + data: game.appid, + label: game.name + }))} + selectedOption={selectedGame?.appid} + onChange={(option) => { + const game = games.find(g => g.appid === option.data); + setSelectedGame(game || null); + setResult(''); + }} + strDefaultLabel="Select a game..." + menuLabel="Installed Games" + /> + </PanelSectionRow> + + {result ? ( + <PanelSectionRow> + <div style={STYLES.resultBox}> + {result} + </div> + </PanelSectionRow> + ) : null} + + {selectedGame ? ( + <> + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={handlePatchClick} + > + Patch + </ButtonItem> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={handleUnpatchClick} + > + Unpatch + </ButtonItem> + </PanelSectionRow> + </> + ) : null} + </PanelSection> + ); +} diff --git a/src/components/ResultDisplay.tsx b/src/components/ResultDisplay.tsx new file mode 100644 index 0000000..0f58f0e --- /dev/null +++ b/src/components/ResultDisplay.tsx @@ -0,0 +1,37 @@ +import { PanelSectionRow } from "@decky/ui"; +import { FC } from "react"; +import { STYLES } from "../utils/constants"; +import { ApiResponse } from "../types/index"; + +export type OperationResult = ApiResponse; + +interface ResultDisplayProps { + result: OperationResult | null; +} + +export const ResultDisplay: FC<ResultDisplayProps> = ({ result }) => { + if (!result) return null; + + return ( + <PanelSectionRow> + <div> + <strong>Status:</strong>{" "} + <span style={result.status === "success" ? STYLES.statusSuccess : STYLES.statusError}> + {result.status === "success" ? "Success" : "Error"} + </span> + <br /> + {result.output ? ( + <> + <strong>Output:</strong> + <pre style={STYLES.preWrap}>{result.output}</pre> + </> + ) : null} + {result.message ? ( + <> + <strong>Error:</strong> {result.message} + </> + ) : null} + </div> + </PanelSectionRow> + ); +}; diff --git a/src/exports.ts b/src/exports.ts new file mode 100644 index 0000000..a95f7b6 --- /dev/null +++ b/src/exports.ts @@ -0,0 +1,14 @@ +// Re-export components +export { FGModInstallerSection } from './components/FGModInstallerSection'; +export { InstalledGamesSection } from './components/InstalledGamesSection'; +export { ResultDisplay } from './components/ResultDisplay'; + +// Re-export utilities +export * from './utils'; +export * from './utils/constants'; + +// Re-export types +export * from './types/index'; + +// Re-export API +export * from './api'; diff --git a/src/index.tsx b/src/index.tsx index 64dabdb..12150d2 100755..100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,304 +1,7 @@ -import { useState, useEffect } from "react"; -import { - PanelSection, - PanelSectionRow, - ButtonItem, - DropdownItem, - ConfirmModal, - showModal -} from "@decky/ui"; -import { definePlugin, callable } from "@decky/api"; +import { definePlugin } from "@decky/api"; import { RiAiGenerate } from "react-icons/ri"; - -const runInstallFGMod = callable< - [], - { status: string; message?: string; output?: string } ->("run_install_fgmod"); - -const runUninstallFGMod = callable< - [], - { status: string; message?: string; output?: string } ->("run_uninstall_fgmod"); - -const checkFGModPath = callable< - [], - { exists: boolean } ->("check_fgmod_path"); - -const listInstalledGames = callable< - [], - { status: string; games: { appid: string; name: string }[] } ->("list_installed_games"); - -const logError = callable<[string], void>("log_error"); - -function FGModInstallerSection() { - const [installing, setInstalling] = useState(false); - const [uninstalling, setUninstalling] = useState(false); - const [installResult, setInstallResult] = useState<{ - status: string; - output?: string; - message?: string; - } | null>(null); - const [uninstallResult, setUninstallResult] = useState<{ - status: string; - output?: string; - message?: string; - } | null>(null); - const [pathExists, setPathExists] = useState<boolean | null>(null); - - useEffect(() => { - const checkPath = async () => { - try { - const result = await checkFGModPath(); - setPathExists(result.exists); - } catch (e) { - logError('useEffect -> checkPath' + String(e)); - console.error(e); - } - }; - checkPath(); // Initial check - const intervalId = setInterval(checkPath, 3000); // Check every 3 seconds - return () => clearInterval(intervalId); // Cleanup interval on component unmount - }, []); - - useEffect(() => { - if (installResult) { - const timer = setTimeout(() => { - setInstallResult(null); - }, 5000); - return () => clearTimeout(timer); - } - return () => {}; // Ensure a cleanup function is always returned - }, [installResult]); - - useEffect(() => { - if (uninstallResult) { - const timer = setTimeout(() => { - setUninstallResult(null); - }, 5000); - return () => clearTimeout(timer); - } - return () => {}; - }, [uninstallResult]); - - const handleInstallClick = async () => { - try { - setInstalling(true); - const result = await runInstallFGMod(); - setInstalling(false); - setInstallResult(result); - } catch (e) { - logError('handleInstallClick: ' + String(e)); - console.error(e) - } - }; - - const handleUninstallClick = async () => { - try { - setUninstalling(true); - const result = await runUninstallFGMod(); - setUninstalling(false); - setUninstallResult(result); - } catch (e) { - logError('handleUninstallClick' + String(e)); - console.error(e) - } - }; - - return ( - <PanelSection> - {pathExists !== null ? ( - <PanelSectionRow> - <div style={{ color: pathExists ? "green" : "red" }}> - {pathExists ? "OptiScaler Mod Is Installed" : "OptiScaler Mod Not Installed"} - </div> - </PanelSectionRow> - ) : null} - {pathExists === false ? ( - <PanelSectionRow> - <ButtonItem layout="below" onClick={handleInstallClick} disabled={installing}> - {installing ? "Installing..." : "Install OptiScaler FG Mod"} - </ButtonItem> - </PanelSectionRow> - ) : null} - {pathExists === true ? ( - <PanelSectionRow> - <ButtonItem layout="below" onClick={handleUninstallClick} disabled={uninstalling}> - {uninstalling ? "Uninstalling..." : "Uninstall OptiScaler FG Mod"} - </ButtonItem> - </PanelSectionRow> - ) : null} - {installResult ? ( - <PanelSectionRow> - <div> - <strong>Status:</strong>{" "} - {installResult.status === "success" ? "Success" : "Error"} - <br /> - {installResult.output ? ( - <> - <strong>Output:</strong> - <pre style={{ whiteSpace: "pre-wrap" }}>{installResult.output}</pre> - </> - ) : null} - {installResult.message ? ( - <> - <strong>Error:</strong> {installResult.message} - </> - ) : null} - </div> - </PanelSectionRow> - ) : null} - {uninstallResult ? ( - <PanelSectionRow> - <div> - <strong>Status:</strong>{" "} - {uninstallResult.status === "success" ? "Success" : "Error"} - <br /> - {uninstallResult.output ? ( - <> - <strong>Output:</strong> - <pre style={{ whiteSpace: "pre-wrap" }}>{uninstallResult.output}</pre> - </> - ) : null} - {uninstallResult.message ? ( - <> - <strong>Error:</strong> {uninstallResult.message} - </> - ) : null} - </div> - </PanelSectionRow> - ) : null} - <PanelSectionRow> - <div> - Install the OptiScaler-based mod above, then select and patch a game below to enable DLSS replacement with FSR Frame Generation. Map a button to "insert" key to bring up the OptiScaler menu in-game. - </div> - </PanelSectionRow> - </PanelSection> - ); -} - -function InstalledGamesSection() { - const [games, setGames] = useState<{ appid: number; name: string }[]>([]); - const [selectedGame, setSelectedGame] = useState<{ appid: number; name: string } | null>(null); - const [result, setResult] = useState<string>(''); - - useEffect(() => { - const fetchGames = async () => { - try { - const response = await listInstalledGames(); - if (response.status === "success") { - const sortedGames = [...response.games] - .map(game => ({ - ...game, - appid: parseInt(game.appid, 10), - })) - .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); - setGames(sortedGames); - } else { - logError('fetchGames: ' + JSON.stringify(response)); - console.error('fetchGames: ' + JSON.stringify(response)); - } - } catch (error) { - logError("Error fetching games:" + String(error)); - console.error("Error fetching games:", String(error)); - } - }; - fetchGames(); - }, []); - - const handlePatchClick = async () => { - if (!selectedGame) return; - - // Show confirmation modal - showModal( - <ConfirmModal - strTitle={`Patch ${selectedGame.name}?`} - strDescription={ - "WARNING: Decky Framegen does not unpatch games when uninstalled. Be sure to unpatch the game or verify the integrity of your game files if you choose to uninstall the plugin or the game has issues." - } - strOKButtonText="Yeah man, I wanna do it" - strCancelButtonText="Cancel" - onOK={async () => { - try { - await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod %COMMAND%'); - setResult(`Launch options set for ${selectedGame.name}. You can now select DLSS in the game's menu, and access OptiScaler with Insert key.`); - } catch (error) { - logError('handlePatchClick: ' + String(error)); - setResult(error instanceof Error ? `Error setting launch options: ${error.message}` : 'Error setting launch options'); - } - }} - /> - ); - }; - - const handleUnpatchClick = async () => { - if (!selectedGame) return; - - try { - await SteamClient.Apps.SetAppLaunchOptions(selectedGame.appid, '~/fgmod/fgmod-uninstaller.sh %COMMAND%'); - setResult(`OptiScaler will uninstall on next launch of ${selectedGame.name}.`); - } catch (error) { - logError('handleUnpatchClick: ' + String(error)); - setResult(error instanceof Error ? `Error clearing launch options: ${error.message}` : 'Error clearing launch options'); - } - }; - - return ( - <PanelSection title="Select a game to patch:"> - <PanelSectionRow> - <DropdownItem - rgOptions={games.map(game => ({ - data: game.appid, - label: game.name - }))} - selectedOption={selectedGame?.appid} - onChange={(option) => { - const game = games.find(g => g.appid === option.data); - setSelectedGame(game || null); - setResult(''); - }} - strDefaultLabel="Select a game..." - menuLabel="Installed Games" - /> - </PanelSectionRow> - - {result ? ( - <PanelSectionRow> - <div style={{ - padding: '12px', - marginTop: '16px', - backgroundColor: 'var(--decky-selected-ui-bg)', - borderRadius: '4px' - }}> - {result} - </div> - </PanelSectionRow> - ) : null} - - {selectedGame ? ( - <> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={handlePatchClick} - > - Patch - </ButtonItem> - </PanelSectionRow> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={handleUnpatchClick} - > - Unpatch - </ButtonItem> - </PanelSectionRow> - </> - ) : null} - </PanelSection> - ); -} +import { FGModInstallerSection } from "./components/FGModInstallerSection"; +import { InstalledGamesSection } from "./components/InstalledGamesSection"; export default definePlugin(() => ({ name: "Framegen Plugin", diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..c810754 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,26 @@ +// Common types used throughout the application + +export interface ApiResponse { + status: string; + message?: string; + output?: string; +} + +export interface GameInfo { + appid: string | number; + name: string; +} + +export interface LaunchOptions { + command: string; + arguments?: string[]; +} + +export interface ModInstallationConfig { + files: string[]; + paths: { + fgmod: string; + assets: string; + bin: string; + }; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..fe78dd0 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,42 @@ +// Common types for the application + +export interface ResultType { + status: string; + message?: string; + output?: string; +} + +export interface GameType { + appid: number; + name: string; +} + +// Common style definitions +export const STYLES = { + resultBox: { + padding: '12px', + marginTop: '16px', + backgroundColor: 'var(--decky-selected-ui-bg)', + borderRadius: '4px' + }, + statusSuccess: { color: "green" }, + statusError: { color: "red" }, + preWrap: { whiteSpace: "pre-wrap" as const } +}; + +// Common timeout values +export const TIMEOUTS = { + resultDisplay: 5000, // 5 seconds + pathCheck: 3000 // 3 seconds +}; + +// Message strings +export const MESSAGES = { + modInstalled: "OptiScaler Mod Is Installed", + modNotInstalled: "OptiScaler Mod Not Installed", + installing: "Installing...", + installButton: "Install OptiScaler FG Mod", + uninstalling: "Uninstalling...", + uninstallButton: "Uninstall OptiScaler FG Mod", + instructionText: "Install the OptiScaler-based mod above, then select and patch a game below to enable DLSS replacement with FSR Frame Generation. Map a button to \"insert\" key to bring up the OptiScaler menu in-game." +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..d969cb6 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,30 @@ +import { logError } from "../api"; + +/** + * Utility for creating a timer that automatically clears after specified timeout + * @param callback Function to call when timer completes + * @param timeout Timeout in milliseconds + * @returns Cleanup function that can be used in useEffect + */ +export const createAutoCleanupTimer = (callback: () => void, timeout: number): (() => void) => { + const timer = setTimeout(callback, timeout); + return () => clearTimeout(timer); +}; + +/** + * Safe wrapper for async operations to handle errors consistently + * @param operation Async operation to perform + * @param errorContext Context string for error logging + */ +export const safeAsyncOperation = async <T,>( + operation: () => Promise<T>, + errorContext: string +): Promise<T | undefined> => { + try { + return await operation(); + } catch (e) { + logError(`${errorContext}: ${String(e)}`); + console.error(e); + return undefined; + } +}; |
