summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-10 07:24:05 -0400
committerKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-10 07:24:05 -0400
commit450d3e5e6612d467a00bb937538fded640c66ecb (patch)
tree3c07ceac1474e8e2211f64928a01f6a3aaa67bda /src
parent28ebc17785a8cca52f289b74261d1f310fd35904 (diff)
downloaddecky-lsfg-vk-450d3e5e6612d467a00bb937538fded640c66ecb.tar.gz
decky-lsfg-vk-450d3e5e6612d467a00bb937538fded640c66ecb.zip
refactor: simplify migration implementation
Diffstat (limited to 'src')
-rw-r--r--src/api/lsfgApi.ts101
-rw-r--r--src/components/Content.tsx73
-rw-r--r--src/components/InstallationButton.tsx38
-rw-r--r--src/components/SetupTab.tsx145
-rw-r--r--src/components/StatusDisplay.tsx41
-rw-r--r--src/components/index.ts2
-rw-r--r--src/hooks/useInstallationActions.ts84
-rw-r--r--src/hooks/useLsfgHooks.ts87
-rw-r--r--src/utils/steamLaunchOptions.ts566
-rw-r--r--src/utils/toastUtils.ts77
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);
-}