diff options
| author | Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> | 2026-09-10 07:24:05 -0400 |
|---|---|---|
| committer | Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> | 2026-09-10 07:24:05 -0400 |
| commit | 450d3e5e6612d467a00bb937538fded640c66ecb (patch) | |
| tree | 3c07ceac1474e8e2211f64928a01f6a3aaa67bda /src | |
| parent | 28ebc17785a8cca52f289b74261d1f310fd35904 (diff) | |
| download | decky-lsfg-vk-450d3e5e6612d467a00bb937538fded640c66ecb.tar.gz decky-lsfg-vk-450d3e5e6612d467a00bb937538fded640c66ecb.zip | |
refactor: simplify migration implementation
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/lsfgApi.ts | 101 | ||||
| -rw-r--r-- | src/components/Content.tsx | 73 | ||||
| -rw-r--r-- | src/components/InstallationButton.tsx | 38 | ||||
| -rw-r--r-- | src/components/SetupTab.tsx | 145 | ||||
| -rw-r--r-- | src/components/StatusDisplay.tsx | 41 | ||||
| -rw-r--r-- | src/components/index.ts | 2 | ||||
| -rw-r--r-- | src/hooks/useInstallationActions.ts | 84 | ||||
| -rw-r--r-- | src/hooks/useLsfgHooks.ts | 87 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 566 | ||||
| -rw-r--r-- | src/utils/toastUtils.ts | 77 |
10 files changed, 434 insertions, 780 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 8179ef9..b96acec 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -1,11 +1,13 @@ import { callable } from "@decky/api"; import { ConfigurationData } from "../config/configSchema"; -// Type definitions for API responses -export interface InstallationResult { +interface ApiResult { success: boolean; - error?: string; message?: string; + error?: string | null; +} + +export interface InstallationResult extends ApiResult { removed_files?: string[]; } @@ -16,10 +18,8 @@ export interface InstallationStatus { error?: string; } -export interface SteamBranchStatus { - success: boolean; +export interface SteamBranchStatus extends ApiResult { message: string; - error?: string; installed: boolean; manifest_path?: string; selected_branch?: string; @@ -29,36 +29,16 @@ export interface SteamBranchStatus { restart_required: boolean; } -// Use centralized configuration data type export type LsfgConfig = ConfigurationData; - -export interface ConfigUpdateResult { - success: boolean; - message?: string; - error?: string; -} - -export interface GameConfigEntry { - appid: string; - profile: string; - config: LsfgConfig; -} export type TargetTransport = | { kind: "host" } | { kind: "flatpak"; flatpakAppId: string }; - export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; -export interface FlatpakTargetSupport { - success: boolean; - message?: string; - error?: string | null; - flatpak_app_id?: string; - runtime?: string | null; - runtime_branch?: string | null; - support_status: FlatpakTargetSupportStatus; - extension_installed: boolean; - installed_branches: string[]; +export interface GameConfigEntry { + appid: string; + profile: string; + config: LsfgConfig; } export interface InstalledGame { @@ -71,20 +51,19 @@ export interface InstalledGame { startDir?: string; flatpakSupport?: FlatpakTargetSupport; } -export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } -export interface GlobalConfig { dll: string; no_fp16: boolean; } -export interface GameConfigsResult { - success: boolean; - global_config?: GlobalConfig; - games?: GameConfigEntry[]; - error?: string; +export interface GlobalConfig { + dll: string; + no_fp16: boolean; } -export interface GameConfigResult extends ConfigUpdateResult { - appid?: string; - exists?: boolean; - config?: LsfgConfig; +export interface FlatpakTargetSupport extends ApiResult { + flatpak_app_id?: string; + runtime?: string | null; + runtime_branch?: string | null; + support_status: FlatpakTargetSupportStatus; + extension_installed: boolean; + installed_branches: string[]; } export interface WorkaroundState { @@ -96,10 +75,7 @@ export interface WorkaroundState { enableZink: boolean; } -export interface WorkaroundStateResult { - success: boolean; - message?: string; - error?: string; +export interface WorkaroundStateResult extends ApiResult { appid?: string; state?: WorkaroundState | null; wrapper_path?: string; @@ -109,47 +85,50 @@ export interface WorkaroundStateResult { transport?: TargetTransport | null; } -export interface FileContentResult { - success: boolean; +export interface GameConfigsResult extends ApiResult { + global_config?: GlobalConfig; + games?: GameConfigEntry[]; +} + +export interface GameConfigResult extends ApiResult { + appid?: string; + exists?: boolean; + config?: LsfgConfig; +} + +export interface InstalledGamesResult extends ApiResult { + games?: InstalledGame[]; +} + +export interface FileContentResult extends ApiResult { content?: string; path?: string; - error?: string; } -export interface FlatpakExtensionStatus { - success: boolean; +export interface FlatpakExtensionStatus extends ApiResult { message: string; - error?: string | null; available: boolean; extension_id: string; supported_branches: string[]; installed_branches: string[]; } -export interface FlatpakExtensionToggleResult { - success: boolean; +export interface FlatpakExtensionToggleResult extends ApiResult { message: string; - error?: string | null; runtime_branch: string; enabled: boolean; installed: boolean; } -// API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); - export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); -export const setFlatpakExtensionEnabled = callable< - [string, boolean], - FlatpakExtensionToggleResult ->("set_flatpak_extension_enabled"); - +export const setFlatpakExtensionEnabled = callable<[string, boolean], FlatpakExtensionToggleResult>("set_flatpak_extension_enabled"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); diff --git a/src/components/Content.tsx b/src/components/Content.tsx index d4f54e9..d92ef86 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -2,12 +2,11 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; -import { useInstallationActions } from "../hooks/useInstallationActions"; -import { useInstallationStatus } from "../hooks/useLsfgHooks"; -import { ConfigurationTab } from "./ConfigurationTab"; +import { useInstallation } from "../hooks/useLsfgHooks"; +import { tabStyles } from "../styles"; import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigurationTab } from "./ConfigurationTab"; import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; @@ -20,16 +19,6 @@ const tabIcons = { export function Content() { const { - isInstalled, - installationStatus, - setIsInstalled, - setInstallationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - checkInstallation, - } = useInstallationStatus(); - const { config, targets, runningGame, @@ -42,15 +31,25 @@ export function Content() { resetAll, reload, } = useGameConfiguration(); - const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + install, + uninstall, + } = useInstallation(reload); const [tab, setTab] = useState("Setup"); + const previousRunningAppId = useRef<string | null>(null); const setupComplete = isInstalled && losslessScalingInstalled && steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; - const previousRunningAppId = useRef<string | null>(null); useEffect(() => { if (!setupComplete) { @@ -65,10 +64,9 @@ export function Content() { const appid = runningGame?.appid || null; const previous = previousRunningAppId.current; previousRunningAppId.current = appid; - if (appid && appid !== previous) { - setTab("NowPlaying"); - } else if (!appid && previous) { - setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab); + if (appid && appid !== previous) setTab("NowPlaying"); + else if (!appid && previous) { + setTab((current) => current === "NowPlaying" ? "Games" : current); } }, [runningGame?.appid, runningGame?.configured, setupComplete]); @@ -80,19 +78,9 @@ export function Content() { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], cleanupLaunchOptions = false, - ) => { - await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); - }; - - const onInstall = () => { - void handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); - }; - - const onUninstall = () => { - void handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); - }; + ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions); - const setupContent = ( + const setup = ( <SetupTab isInstalled={isInstalled} installationStatus={installationStatus} @@ -101,8 +89,9 @@ export function Content() { steamBranchStatus={steamBranchStatus} isInstalling={isInstalling} isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} + onInstall={() => void install()} + onUninstall={() => void uninstall()} + flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")} /> ); @@ -115,7 +104,7 @@ export function Content() { <NowPlayingTab game={runningGame} config={config} - onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)} + onConfigChange={(field, value) => handleConfigChange(field, value)} onEnable={enable} onRepair={repair} /> @@ -130,7 +119,7 @@ export function Content() { targets={targets} runningGame={runningGame} onSelect={setSelectedAppId} - onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} + onConfigChange={(field, value) => handleConfigChange(field, value, true)} onEnable={enable} onEnableAll={enableAll} onRepair={repair} @@ -139,16 +128,10 @@ export function Content() { /> ), }, - { - id: "ConfigFile", - title: tabIcons.configFile, - content: <ConfigFileTab />, - }, - { id: "Setup", title: tabIcons.setup, content: setupContent }, + { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, + { id: "Setup", title: tabIcons.setup, content: setup }, ] - : [ - { id: "Setup", title: tabIcons.setup, content: setupContent }, - ]; + : [{ id: "Setup", title: tabIcons.setup, content: setup }]; return ( <div diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx deleted file mode 100644 index 1bf10ac..0000000 --- a/src/components/InstallationButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ButtonItem, PanelSectionRow } from "@decky/ui"; -import t from '../i18n/i18n'; - -interface InstallationButtonProps { - isInstalled: boolean; - isInstalling: boolean; - isUninstalling: boolean; - onInstall: () => void; - onUninstall: () => void; -} - -export function InstallationButton({ - isInstalled, - isInstalling, - isUninstalling, - onInstall, - onUninstall -}: InstallationButtonProps) { - const label = isInstalling - ? t('INSTALL_INSTALLING', 'Installing...') - : isUninstalling - ? t('INSTALL_UNINSTALLING', 'Uninstalling...') - : isInstalled - ? t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK') - : t('INSTALL_INSTALL_BTN', 'Install LSFG-VK'); - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {label} - </ButtonItem> - </PanelSectionRow> - ); -} diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 6bf1fad..e936049 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,4 +1,4 @@ -import { Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { getFlatpakSupportStatus, @@ -6,8 +6,7 @@ import { type FlatpakExtensionStatus, type SteamBranchStatus, } from "../api/lsfgApi"; -import { InstallationButton } from "./InstallationButton"; -import { StatusDisplay } from "./StatusDisplay"; +import t from "../i18n/i18n"; import { showErrorToast } from "../utils/toastUtils"; interface SetupTabProps { @@ -20,10 +19,12 @@ interface SetupTabProps { isUninstalling: boolean; onInstall: () => void; onUninstall: () => void; + flatpakRelevant: boolean; } -function FlatpakSupportDiagnostics() { +function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null); + const [advanced, setAdvanced] = useState(false); const [operation, setOperation] = useState<string | null>(null); const refresh = async () => { @@ -43,16 +44,15 @@ function FlatpakSupportDiagnostics() { }; useEffect(() => { - void refresh(); - }, []); + if (relevant) void refresh(); + }, [relevant]); - if (!status?.available) return null; + if (!relevant || !status?.available) return null; - const runExtensionOperation = async (version: string, enabled: boolean) => { - const operationKey = `${enabled ? "enable" : "disable"}-${version}`; - setOperation(operationKey); + const setEnabled = async (branch: string, enabled: boolean) => { + setOperation(`${enabled ? "enable" : "disable"}-${branch}`); try { - const result = await setFlatpakExtensionEnabled(version, enabled); + const result = await setFlatpakExtensionEnabled(branch, enabled); if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); await refresh(); } catch (error) { @@ -62,70 +62,91 @@ function FlatpakSupportDiagnostics() { } }; - const handleExtensionToggle = (version: string, enabled: boolean) => { - void runExtensionOperation(version, enabled); - }; - return ( - <PanelSection title="Flatpak runtimes"> + <PanelSection title="Flatpak support"> <PanelSectionRow> <Field - label="LSFG-VK runtime extensions" - description={status.message || "Toggle a branch to install or uninstall it."} + label="Runtime extension support" + description={status.message || "Flatpak is available for classified targets."} /> </PanelSectionRow> - {status.supported_branches.map((branch) => ( - <PanelSectionRow key={branch}> - <ToggleField - label={branch} - description={ - operation === `enable-${branch}` - ? "Installing..." - : operation === `disable-${branch}` - ? "Uninstalling..." - : status.installed_branches.includes(branch) - ? "Installed" - : "Not installed" - } - checked={status.installed_branches.includes(branch)} - onChange={(enabled) => handleExtensionToggle(branch, enabled)} - disabled={operation !== null} - /> - </PanelSectionRow> - ))} + <PanelSectionRow> + <ButtonItem layout="below" onClick={() => setAdvanced((value) => !value)}> + {advanced ? "Hide runtime details" : "Show runtime details"} + </ButtonItem> + </PanelSectionRow> + {advanced && status.supported_branches.map((branch) => { + const installed = status.installed_branches.includes(branch); + const pending = operation?.endsWith(`-${branch}`); + return ( + <PanelSectionRow key={branch}> + <ToggleField + label={branch} + description={pending ? (operation?.startsWith("enable") ? "Installing..." : "Uninstalling...") : installed ? "Installed" : "Not installed"} + checked={installed} + onChange={(enabled) => void setEnabled(branch, enabled)} + disabled={operation !== null} + /> + </PanelSectionRow> + ); + })} </PanelSection> ); } -export function SetupTab({ - isInstalled, - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - isInstalling, - isUninstalling, - onInstall, - onUninstall, -}: SetupTabProps) { +export function SetupTab(props: SetupTabProps) { + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + onInstall, + onUninstall, + flatpakRelevant, + } = props; + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + const buttonLabel = isInstalling + ? t("INSTALL_INSTALLING", "Installing...") + : isUninstalling + ? t("INSTALL_UNINSTALLING", "Uninstalling...") + : isInstalled + ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") + : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); + return ( <> <PanelSection title="Setup"> - <StatusDisplay - installationStatus={installationStatus} - losslessScalingInstalled={losslessScalingInstalled} - losslessScalingStatus={losslessScalingStatus} - steamBranchStatus={steamBranchStatus} - /> - <InstallationButton - isInstalled={isInstalled} - isInstalling={isInstalling} - isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - /> + <PanelSectionRow> + <Field + label="Lossless Scaling" + description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} + /> + </PanelSectionRow> + <PanelSectionRow> + <Field label="LSFG-VK" description={installationStatus} /> + </PanelSectionRow> + {steamBranchStatus?.installed && ( + <PanelSectionRow> + <Field + label="Steam branch" + description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} + /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={isInstalled ? onUninstall : onInstall} + disabled={isInstalling || isUninstalling} + > + {buttonLabel} + </ButtonItem> + </PanelSectionRow> </PanelSection> - <FlatpakSupportDiagnostics /> + <FlatpakSupportDiagnostics relevant={flatpakRelevant} /> </> ); } diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx deleted file mode 100644 index b1a98e5..0000000 --- a/src/components/StatusDisplay.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Field, PanelSectionRow } from "@decky/ui"; -import type { SteamBranchStatus } from "../api/lsfgApi"; - -interface StatusDisplayProps { - installationStatus: string; - losslessScalingInstalled: boolean; - losslessScalingStatus: string; - steamBranchStatus: SteamBranchStatus | null; -} - -export function StatusDisplay({ - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus -}: StatusDisplayProps) { - const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; - - return ( - <> - <PanelSectionRow> - <Field - label="Lossless Scaling" - description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} - /> - </PanelSectionRow> - <PanelSectionRow> - <Field label="LSFG-VK" description={installationStatus} /> - </PanelSectionRow> - - {steamBranchStatus?.installed && ( - <PanelSectionRow> - <Field - label="Steam branch" - description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} - /> - </PanelSectionRow> - )} - </> - ); -} diff --git a/src/components/index.ts b/src/components/index.ts index 6856e76..bca6f6f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,4 @@ export { Content } from "./Content"; -export { StatusDisplay } from "./StatusDisplay"; -export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts deleted file mode 100644 index 41189bd..0000000 --- a/src/hooks/useInstallationActions.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { useState } from "react"; -import { installLsfgVk, uninstallLsfgVk } from "../api/lsfgApi"; -import { - showInstallSuccessToast, - showInstallErrorToast, - showUninstallSuccessToast, - showUninstallErrorToast -} from "../utils/toastUtils"; - -export function useInstallationActions() { - const [isInstalling, setIsInstalling] = useState<boolean>(false); - const [isUninstalling, setIsUninstalling] = useState<boolean>(false); - - const handleInstall = async ( - setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void, - reloadConfig?: () => Promise<void>, - reloadStatus?: () => Promise<boolean> - ) => { - setIsInstalling(true); - setInstallationStatus("Installing lsfg-vk..."); - - try { - const result = await installLsfgVk(); - if (result.success) { - setIsInstalled(true); - setInstallationStatus("lsfg-vk installed"); - showInstallSuccessToast(); - - // Reload lsfg config after installation - if (reloadConfig) { - await reloadConfig(); - } - if (reloadStatus) { - await reloadStatus(); - } - } else { - setInstallationStatus(`Installation failed: ${result.error}`); - showInstallErrorToast(result.error); - } - } catch (error) { - setInstallationStatus(`Installation failed: ${error}`); - showInstallErrorToast(String(error)); - } finally { - setIsInstalling(false); - } - }; - - const handleUninstall = async ( - setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void, - reloadStatus?: () => Promise<boolean> - ) => { - setIsUninstalling(true); - setInstallationStatus("Uninstalling lsfg-vk..."); - - try { - const result = await uninstallLsfgVk(); - if (result.success) { - setIsInstalled(false); - setInstallationStatus("lsfg-vk uninstalled successfully!"); - if (reloadStatus) { - await reloadStatus(); - } - showUninstallSuccessToast(); - } else { - setInstallationStatus(`Uninstallation failed: ${result.error}`); - showUninstallErrorToast(result.error); - } - } catch (error) { - setInstallationStatus(`Uninstallation failed: ${error}`); - showUninstallErrorToast(String(error)); - } finally { - setIsUninstalling(false); - } - }; - - return { - isInstalling, - isUninstalling, - handleInstall, - handleUninstall - }; -} diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index 0b71ee9..73cfa0c 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -1,16 +1,26 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { checkLsfgVkInstalled, getLosslessScalingBranchStatus, - type SteamBranchStatus + installLsfgVk, + uninstallLsfgVk, + type SteamBranchStatus, } from "../api/lsfgApi"; +import { + showInstallErrorToast, + showInstallSuccessToast, + showUninstallErrorToast, + showUninstallSuccessToast, +} from "../utils/toastUtils"; -export function useInstallationStatus() { - const [isInstalled, setIsInstalled] = useState<boolean>(false); - const [installationStatus, setInstallationStatus] = useState<string>(""); - const [losslessScalingInstalled, setLosslessScalingInstalled] = useState<boolean>(false); - const [losslessScalingStatus, setLosslessScalingStatus] = useState<string>(""); +export function useInstallation(reloadConfig?: () => Promise<void>) { + const [isInstalled, setIsInstalled] = useState(false); + const [installationStatus, setInstallationStatus] = useState(""); + const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); + const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); const [steamBranchStatus, setSteamBranchStatus] = useState<SteamBranchStatus | null>(null); + const [isInstalling, setIsInstalling] = useState(false); + const [isUninstalling, setIsUninstalling] = useState(false); const checkInstallation = async () => { try { @@ -25,13 +35,9 @@ export function useInstallationStatus() { setIsInstalled(status.installed); setLosslessScalingInstalled(status.lossless_scaling_installed); setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed"); - if (status.installed) { - setInstallationStatus("lsfg-vk Installed"); - } else { - setInstallationStatus("lsfg-vk Not Installed"); - } + setInstallationStatus(status.installed ? "lsfg-vk Installed" : "lsfg-vk Not Installed"); return status.installed; - } catch (error) { + } catch { setSteamBranchStatus(null); setLosslessScalingInstalled(false); setLosslessScalingStatus("Lossless Scaling Not Installed"); @@ -41,17 +47,64 @@ export function useInstallationStatus() { }; useEffect(() => { - checkInstallation(); + void checkInstallation(); }, []); + const install = async () => { + setIsInstalling(true); + setInstallationStatus("Installing lsfg-vk..."); + try { + const result = await installLsfgVk(); + if (!result.success) { + setInstallationStatus(`Installation failed: ${result.error}`); + showInstallErrorToast(result.error); + return; + } + setIsInstalled(true); + setInstallationStatus("lsfg-vk installed"); + showInstallSuccessToast(); + await reloadConfig?.(); + await checkInstallation(); + } catch (error) { + setInstallationStatus(`Installation failed: ${error}`); + showInstallErrorToast(String(error)); + } finally { + setIsInstalling(false); + } + }; + + const uninstall = async () => { + setIsUninstalling(true); + setInstallationStatus("Uninstalling lsfg-vk..."); + try { + const result = await uninstallLsfgVk(); + if (!result.success) { + setInstallationStatus(`Uninstallation failed: ${result.error}`); + showUninstallErrorToast(result.error); + return; + } + setIsInstalled(false); + setInstallationStatus("lsfg-vk uninstalled successfully!"); + await checkInstallation(); + showUninstallSuccessToast(); + } catch (error) { + setInstallationStatus(`Uninstallation failed: ${error}`); + showUninstallErrorToast(String(error)); + } finally { + setIsUninstalling(false); + } + }; + return { isInstalled, installationStatus, - setIsInstalled, - setInstallationStatus, losslessScalingInstalled, losslessScalingStatus, steamBranchStatus, - checkInstallation + isInstalling, + isUninstalling, + install, + uninstall, + checkInstallation, }; } diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index e00b32d..0af1bbf 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -12,27 +12,14 @@ export const LEGACY_WRAPPER_TOKENS = new Set([ ]); const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/; - const MANAGED_ENV_KEYS = new Set([ - "ENABLE_GAMESCOPE_WSI", - "DISABLE_GAMESCOPE_WSI", - "DXVK_HDR", - "SteamDeck", - "DISABLE_VKBASALT", - "ENABLE_VKBASALT", - "MESA_LOADER_DRIVER_OVERRIDE", - "__GLX_VENDOR_LIBRARY_NAME", - "GALLIUM_DRIVER", - "DXVK_FRAME_RATE", + "ENABLE_GAMESCOPE_WSI", "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck", + "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE", + "__GLX_VENDOR_LIBRARY_NAME", "GALLIUM_DRIVER", "DXVK_FRAME_RATE", ]); - const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i; -interface LaunchToken { - raw: string; - value: string; -} - +interface LaunchToken { raw: string; value: string; } export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; @@ -40,42 +27,33 @@ export interface SteamLaunchOptionsSnapshot { target: string; details: SteamAppDetails; } - export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; originalExecutable?: string; commandTokenAdded: boolean; } -function validateAppId(appId: number): void { - if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); } -function getSteamApps(): Partial<SteamApps> | undefined { - return (globalThis as typeof globalThis & { - SteamClient?: { Apps?: Partial<SteamApps> }; - }).SteamClient?.Apps; +function apps(): Partial<SteamApps> | undefined { + return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial<SteamApps> } }).SteamClient?.Apps; } -interface TimerHost { - setTimeout(handler: () => void, timeout: number): number; - clearTimeout(timeout: number): void; +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } -function timerHost(): TimerHost { - if (typeof window !== "undefined") { - return { - setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), - clearTimeout: (timeout) => window.clearTimeout(timeout), - }; - } +function timer() { + const host = typeof window !== "undefined" ? window : globalThis; return { - setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, - clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number, + clear: (id: number) => host.clearTimeout(id), }; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { +function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { return { appId, nonSteam, @@ -85,73 +63,39 @@ function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamApp }; } -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function registerSteamAppDetails( - appId: number, - onDetails: (details: SteamAppDetails) => boolean | void, -): () => void { +function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void { validateAppId(appId); - const apps = getSteamApps(); - const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); - + const register = apps()?.RegisterForAppDetails; + if (!register) throw new Error("Steam app-details API is unavailable"); let active = true; - let unregisterPending = false; let registration: SteamAppDetailsRegistration | undefined; const unsubscribe = () => { active = false; - if (!registration) { - unregisterPending = true; - return; - } - try { - registration.unregister(); - } catch { - // Steam can invalidate a registration while details are refreshing. - } + try { registration?.unregister(); } catch {} }; - - try { - registration = registerForAppDetails.call(apps, appId, (details) => { - if (!active) return; - if (onDetails(details || {}) === false && active) unsubscribe(); - }); - if (unregisterPending) { - try { - registration.unregister(); - } catch { - // A synchronous callback can invalidate the registration before return. - } - } - } catch (error) { - throw asError(error); - } + registration = register.call(apps(), appId, (details) => { + if (active && onDetails(details || {}) === false) unsubscribe(); + }); + if (!active) unsubscribe(); return unsubscribe; } export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> { return new Promise((resolve, reject) => { - let settled = false; - let timeout: number | undefined; + let done = false; let unsubscribe = () => {}; + const clock = timer(); + const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000); const finish = (error?: unknown, details?: SteamAppDetails) => { - if (settled) return; - settled = true; - if (timeout !== undefined) timerHost().clearTimeout(timeout); + if (done) return; + done = true; + clock.clear(timeout); unsubscribe(); - if (error) { - reject(asError(error)); - return; - } - resolve(snapshotFromDetails(appId, nonSteam, details || {})); + if (error) reject(asError(error)); + else resolve(snapshot(appId, nonSteam, details || {})); }; - - timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { - unsubscribe = registerSteamAppDetails(appId, (details) => { + unsubscribe = registerDetails(appId, (details) => { finish(undefined, details); return false; }); @@ -164,34 +108,24 @@ export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): export function subscribeSteamLaunchOptions( appId: number, nonSteam: boolean, - onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void, + onSnapshot: (value: SteamLaunchOptionsSnapshot) => void, onError: (error: Error) => void, ): () => void { - return registerSteamAppDetails(appId, (details) => { - try { - onSnapshot(snapshotFromDetails(appId, nonSteam, details)); - } catch (error) { - onError(asError(error)); - } + return registerDetails(appId, (details) => { + try { onSnapshot(snapshot(appId, nonSteam, details)); } + catch (error) { onError(asError(error)); } }); } function decodeToken(raw: string): string { let value = ""; let quote: "'" | '"' | null = null; - for (let index = 0; index < raw.length; index += 1) { - const character = raw[index]; - if (character === "\\" && quote !== "'" && index + 1 < raw.length) { - value += raw[index + 1]; - index += 1; - } else if (quote !== null) { - if (character === quote) quote = null; - else value += character; - } else if (character === "'" || character === '"') { - quote = character; - } else { - value += character; - } + for (let i = 0; i < raw.length; i++) { + const c = raw[i]; + if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i]; + else if (quote) { if (c === quote) quote = null; else value += c; } + else if (c === "'" || c === '"') quote = c; + else value += c; } return value; } @@ -201,299 +135,184 @@ function tokenize(options: string): LaunchToken[] { let start = -1; let quote: "'" | '"' | null = null; let escaped = false; - for (let index = 0; index < options.length; index += 1) { - const character = options[index]; - if (start < 0) { - if (/\s/.test(character)) continue; - start = index; - } - if (escaped) escaped = false; - else if (character === "\\" && quote !== "'") escaped = true; - else if (quote !== null) { - if (character === quote) quote = null; - } else if (character === "'" || character === '"') quote = character; - else if (/\s/.test(character)) { - const raw = options.slice(start, index); - tokens.push({ raw, value: decodeToken(raw) }); - start = -1; - } - } - if (start >= 0) { - const raw = options.slice(start); + const push = (end: number) => { + if (start < 0) return; + const raw = options.slice(start, end); tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + }; + for (let i = 0; i < options.length; i++) { + const c = options[i]; + if (start < 0) { if (/\s/.test(c)) continue; start = i; } + if (escaped) escaped = false; + else if (c === "\\" && quote !== "'") escaped = true; + else if (quote) { if (c === quote) quote = null; } + else if (c === "'" || c === '"') quote = c; + else if (/\s/.test(c)) push(i); } + push(options.length); return tokens; } -function serialize(tokens: readonly LaunchToken[]): string { - return tokens.map((token) => token.raw).join(" "); -} - -export function normalizeLaunchOptions(options: string): string { - return serialize(tokenize(options)); -} - -function isCommandToken(token: LaunchToken): boolean { - return token.raw.toLowerCase() === COMMAND_TOKEN; -} +const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); +const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); +const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -function commandIndex(tokens: readonly LaunchToken[]): number { - return tokens.findIndex(isCommandToken); -} +export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); +export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); -function isAssignment(token: LaunchToken): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); -} - -function isLegacyToken(value: string): boolean { - return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); -} - -export function isLegacyWrapperToken(value: string): boolean { - return isLegacyToken(decodeToken(value)); -} - -function isWrapperToken(value: string, wrapperPath: string): boolean { - return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -} - -function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean { - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); +function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean { + const command = commandIndex(tokens); + const prefixEnd = command >= 0 ? command : tokens.length; + const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value)); + if (kept.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...kept); return true; } -function removeLegacyTokens(tokens: LaunchToken[]): boolean { - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -function leadingAssignments(tokens: readonly LaunchToken[]): number { - let count = 0; - while (count < tokens.length && isAssignment(tokens[count])) count += 1; - return count; -} - -function wrapperToken(wrapperPath: string): LaunchToken { - return { raw: wrapperPath, value: wrapperPath }; -} - -export interface LaunchOptionRewrite { - options: string; - commandTokenAdded: boolean; -} - -/** Add one exact wrapper token immediately before Steam's command macro. */ -export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite { +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { const tokens = tokenize(options); - removeLegacyTokens(tokens); - let index = commandIndex(tokens); - if (index >= 0) { - const currentWrapper = tokens[index - 1]; - if (currentWrapper && currentWrapper.value === wrapperPath) { - return { options: serialize(tokens), commandTokenAdded: false }; - } - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath); - tokens.splice(0, tokens.length, ...retained); - index = commandIndex(tokens); - tokens.splice(index, 0, wrapperToken(wrapperPath)); + removeMatchingWrappers(tokens, isLegacyToken); + let command = commandIndex(tokens); + if (command >= 0) { + if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false }; + removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath); + command = commandIndex(tokens); + tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath }); return { options: serialize(tokens), commandTokenAdded: false }; } - - const insertion = leadingAssignments(tokens); - const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); - if (tokens.length !== insertion && !argumentsOnly) { + let insertion = 0; + while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; + if (insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } - tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + tokens.splice(insertion, 0, + { raw: wrapperPath, value: wrapperPath }, + { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, + ); return { options: serialize(tokens), commandTokenAdded: true }; } -/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */ export function removeWrapperLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, commandTokenAdded = false, ): string { const tokens = tokenize(options); - const removed = removeWrapperTokens(tokens, wrapperPath); - if (removed && commandTokenAdded) { - const index = commandIndex(tokens); - if (index >= 0) tokens.splice(index, 1); + if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) { + const command = commandIndex(tokens); + if (command >= 0) tokens.splice(command, 1); } return serialize(tokens); } function encodeAssignmentValue(value: string): string { - if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function cleanDxvkConfigValue(value: string): string | null { - const retained = value - .split(";") - .map((segment) => segment.trim()) - .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment)); - return retained.length > 0 ? retained.join("; ") : null; + return /^[A-Za-z0-9_./:+,%=-]+$/.test(value) + ? value + : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */ export function cleanupPluginAssignments(options: string): string { const tokens = tokenize(options); - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained: LaunchToken[] = []; - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { - const token = tokens[tokenIndex]; - if (tokenIndex >= prefixEnd || !isAssignment(token)) { - retained.push(token); - continue; - } - const separator = token.value.indexOf("="); - const key = token.value.slice(0, separator); + const command = commandIndex(tokens); + const prefixEnd = command >= 0 ? command : tokens.length; + return serialize(tokens.flatMap((token, i) => { + if (i >= prefixEnd || !isAssignment(token)) return [token]; + const split = token.value.indexOf("="); + const key = token.value.slice(0, split); if (key === "DXVK_CONFIG") { - const cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); - if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); - continue; + const value = token.value.slice(split + 1).split(";").map((part) => part.trim()) + .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; "); + return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : []; } - if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); - } - return serialize(retained); + return MANAGED_ENV_KEYS.has(key) ? [] : [token]; + })); } export function cleanupLegacyLaunchOptions(options: string): string { const tokens = tokenize(options); - removeLegacyTokens(tokens); + removeMatchingWrappers(tokens, isLegacyToken); return serialize(tokens); } - -export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { - const tokens = tokenize(options); - removeWrapperTokens(tokens, wrapperPath); - return cleanupPluginAssignments(serialize(tokens)); -} - -export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { - return cleanupPluginLaunchOptions(options, wrapperPath); -} - +export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) => + cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath)); +export const cleanupLegacyWrapper = cleanupPluginLaunchOptions; export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { const tokens = tokenize(options); - const index = commandIndex(tokens); - return index > 0 && tokens[index - 1].value === wrapperPath; -} - -function delay(milliseconds: number): Promise<void> { - return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); -} - -async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> { - const apps = getSteamApps(); - const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; - if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`); - await Promise.resolve(setter.call(apps, appId, options)); + const command = commandIndex(tokens); + return command > 0 && tokens[command - 1].value === wrapperPath; } -async function setShortcutExecutable(appId: number, executable: string): Promise<void> { - const apps = getSteamApps(); - if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); - await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); +const queues = new Map<string, Promise<unknown>>(); +function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; + const previous = queues.get(key) || Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const cleanup = current.then( + () => { if (queues.get(key) === cleanup) queues.delete(key); }, + () => { if (queues.get(key) === cleanup) queues.delete(key); }, + ); + queues.set(key, cleanup); + return current; } -async function waitForSnapshot( +async function waitFor( appId: number, nonSteam: boolean, - matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean, + matches: (value: SteamLaunchOptionsSnapshot) => boolean, message: string, ): Promise<SteamLaunchOptionsSnapshot> { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { - const snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (matches(snapshot)) return snapshot; - } catch (error) { - lastError = asError(error); - } - if (Date.now() >= deadline) break; - await delay(100); + const value = await readSteamLaunchOptions(appId, nonSteam); + if (matches(value)) return value; + } catch (error) { lastError = asError(error); } + if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100)); } - if (lastError) throw new Error(`${message}: ${lastError.message}`); - throw new Error(`${message} before the readback timeout`); + throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`); } -async function writeLaunchOptionsAndVerify( +async function writeVerified( appId: number, nonSteam: boolean, previous: string, next: string, + write: (value: string) => Promise<void>, + read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise<SteamLaunchOptionsSnapshot> { + const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; try { - await setSteamLaunchOptions(appId, nonSteam, next); - return await waitForSnapshot( - appId, - nonSteam, - (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next), - message, - ); - } catch (error) { - const failure = asError(error); - try { - await setSteamLaunchOptions(appId, nonSteam, previous); - await waitForSnapshot( - appId, - nonSteam, - (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous), - "Steam did not restore the previous launch options", - ); - } catch (rollbackError) { - throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); - } - throw failure; - } -} - -async function writeShortcutExecutableAndVerify( - appId: number, - previous: string, - next: string, - message: string, -): Promise<SteamLaunchOptionsSnapshot> { - try { - await setShortcutExecutable(appId, next); - return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + await write(next); + return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); } catch (error) { const failure = asError(error); try { - await setShortcutExecutable(appId, previous); - await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target"); - } catch (rollbackError) { - throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + await write(previous); + await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + } catch (rollback) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); } throw failure; } } -const operationQueues = new Map<string, Promise<unknown>>(); +const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; +const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; -function queueSteamOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { - const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; - const previous = operationQueues.get(key) || Promise.resolve(); - const queued = previous.catch(() => undefined).then(operation); - const cleanup = queued.then( - () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, - () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, - ); - operationQueues.set(key, cleanup); - return queued; +function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> { + const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions; + if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`)); + return Promise.resolve(setter.call(apps(), appId, value)); +} +function writeTarget(appId: number, value: string): Promise<void> { + const setter = apps()?.SetShortcutExe; + if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); + return Promise.resolve(setter.call(apps(), appId, value)); } export function updateSteamLaunchOptions( @@ -501,11 +320,14 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamOperation(appId, nonSteam, async () => { + return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); + return next === current.options ? current : writeVerified( + appId, nonSteam, current.options, next, + (value) => writeOptions(appId, nonSteam, value), readOptions, + "Steam did not accept the launch options", + ); }); } @@ -515,33 +337,41 @@ export function installWrapperIntegration( wrapperPath: string, commandTokenAdded = false, ): Promise<WrapperIntegrationResult> { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); + return queued(appId, nonSteam, async () => { + let current = await readSteamLaunchOptions(appId, nonSteam); if (nonSteam) { if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); } - const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleanedOptions !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options"); - } - if (current.target === wrapperPath) { - return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false }; + const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleaned !== current.options) { + current = await writeVerified( + appId, true, current.options, cleaned, + (value) => writeOptions(appId, true, value), readOptions, + "Steam did not accept shortcut launch options", + ); } + if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false }; const originalExecutable = current.target; - const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target"); - return { snapshot, originalExecutable, commandTokenAdded: false }; + const value = await writeVerified( + appId, true, originalExecutable, wrapperPath, + (target) => writeTarget(appId, target), readTarget, + "Steam did not accept the shortcut Target", + ); + return { snapshot: value, originalExecutable, commandTokenAdded: false }; } const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); - if (rewrite.options === current.options) { - return { snapshot: current, commandTokenAdded }; - } - const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options"); - return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + const value = await writeVerified( + appId, false, current.options, rewrite.options, + (options) => writeOptions(appId, false, options), readOptions, + "Steam did not accept the launch options", + ); + return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; }); } @@ -552,8 +382,8 @@ export function removeWrapperIntegration( originalExecutable?: string, commandTokenAdded = false, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); + return queued(appId, nonSteam, async () => { + let current = await readSteamLaunchOptions(appId, nonSteam); if (nonSteam) { if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); @@ -563,34 +393,32 @@ export function removeWrapperIntegration( } const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); + current = await writeVerified( + appId, true, current.options, cleaned, + (value) => writeOptions(appId, true, value), readOptions, + "Steam did not clean shortcut launch options", + ); } - if (current.target === originalExecutable) { - return readSteamLaunchOptions(appId, true); - } - return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target"); + if (current.target === originalExecutable) return current; + return writeVerified( + appId, true, wrapperPath, originalExecutable, + (target) => writeTarget(appId, target), readTarget, + "Steam did not restore the shortcut Target", + ); } - - const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); - const next = cleanupPluginAssignments(withoutWrapper); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); + const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); + return next === current.options ? current : writeVerified( + appId, false, current.options, next, + (options) => writeOptions(appId, false, options), readOptions, + "Steam did not clean the launch options", + ); }); } -export function cleanupLegacySteamLaunchOptions( +export const cleanupLegacySteamLaunchOptions = ( appId: number, nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, -): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); - }); -} +) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath)); -export function getDefaultWrapperPath(): string { - return DEFAULT_WRAPPER_PATH; -} +export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH; diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index cbbbc55..3468064 100644 --- a/src/utils/toastUtils.ts +++ b/src/utils/toastUtils.ts @@ -1,8 +1,3 @@ -/** - * Centralized toast notification utilities - * Provides consistent success/error messaging patterns - */ - import { toaster } from "@decky/api"; export interface ToastOptions { @@ -10,84 +5,44 @@ export interface ToastOptions { body: string; } -/** - * Show a success toast notification - */ -export function showSuccessToast(title: string, body: string): void { - toaster.toast({ - title, - body - }); -} +const showToast = (title: string, body: string): void => toaster.toast({ title, body }); +export const showSuccessToast = showToast; +export const showErrorToast = showToast; -/** - * Show an error toast notification - */ -export function showErrorToast(title: string, body: string): void { - toaster.toast({ - title, - body - }); -} - -/** - * Standard success messages for common operations - */ export const ToastMessages = { INSTALL_SUCCESS: { title: "Installation Complete", - body: "lsfg-vk has been installed successfully" + body: "lsfg-vk has been installed successfully", }, INSTALL_ERROR: { title: "Installation Failed", - body: "Unknown error occurred" + body: "Unknown error occurred", }, UNINSTALL_SUCCESS: { - title: "Uninstallation Complete", - body: "lsfg-vk has been uninstalled successfully" + title: "Uninstallation Complete", + body: "lsfg-vk has been uninstalled successfully", }, UNINSTALL_ERROR: { title: "Uninstallation Failed", - body: "Unknown error occurred" + body: "Unknown error occurred", }, CONFIG_UPDATE_ERROR: { title: "Update Failed", - body: "Failed to update configuration" - } + body: "Failed to update configuration", + }, } as const; -/** - * Show a toast with dynamic error message - */ -export function showErrorToastWithMessage(title: string, error: unknown): void { - const errorMessage = error instanceof Error ? error.message : String(error); - showErrorToast(title, errorMessage); -} +export const showErrorToastWithMessage = (title: string, error: unknown): void => + showErrorToast(title, error instanceof Error ? error.message : String(error)); -/** - * Show installation success toast - */ -export function showInstallSuccessToast(): void { +export const showInstallSuccessToast = (): void => showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body); -} -/** - * Show installation error toast - */ -export function showInstallErrorToast(error?: string): void { +export const showInstallErrorToast = (error?: string): void => showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body); -} -/** - * Show uninstallation success toast - */ -export function showUninstallSuccessToast(): void { +export const showUninstallSuccessToast = (): void => showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body); -} -/** - * Show uninstallation error toast - */ -export function showUninstallErrorToast(error?: string): void { +export const showUninstallErrorToast = (error?: string): void => showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body); -} |
