summaryrefslogtreecommitdiff
path: root/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/hooks')
-rw-r--r--src/hooks/useGameConfiguration.ts245
-rw-r--r--src/hooks/useInstallationActions.ts84
-rw-r--r--src/hooks/useLsfgHooks.ts87
-rw-r--r--src/hooks/usePerAppWorkarounds.ts272
4 files changed, 509 insertions, 179 deletions
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index 46607cb..c70d589 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
-import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi";
+import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions";
+import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
@@ -19,7 +19,12 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
const appid = Number(shortcut?.appid);
const name = shortcut?.data?.strAppName;
if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return [];
- return [{ appid: String(appid >>> 0), name, nonSteam: true }];
+ return [{
+ appid: String(appid >>> 0),
+ name,
+ nonSteam: true,
+ transport: { kind: "host" },
+ }];
});
} catch {
return [];
@@ -28,10 +33,43 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) {
const games = new Map(backendGames.map((game) => [game.appid, game]));
- for (const game of shortcutGames) games.set(game.appid, game);
+ for (const game of shortcutGames) {
+ const existing = games.get(game.appid);
+ games.set(game.appid, existing ? { ...existing, name: game.name, nonSteam: true } : game);
+ }
return Array.from(games.values());
}
+function selectShortcutExecutable(
+ target: GameTarget,
+ ...candidates: Array<string | null | undefined>
+): string | undefined {
+ const absolute = candidates
+ .map((candidate) => candidate?.trim())
+ .find((candidate) => candidate && candidate.startsWith("/"));
+ if (absolute) return absolute;
+
+ // Steam's app-details API can report a Flatpak Target as just "flatpak"
+ // even when the shortcut's canonical VDF executable is /usr/bin/flatpak.
+ // Keep the stored original executable absolute so SetShortcutExe and the
+ // generated dispatcher agree on the same direct transport.
+ if (target.transport.kind === "flatpak") return "/usr/bin/flatpak";
+ return candidates.map((candidate) => candidate?.trim()).find(Boolean);
+}
+
+const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
+ dxvkFrameRate: 0,
+ disableGamescopeWsi: true,
+ disableHdr: true,
+ disableSteamdeckMode: false,
+ disableVkbasalt: false,
+ enableZink: false,
+};
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
+
export function useGameConfiguration() {
const [games, setGames] = useState<GameConfigEntry[]>([]);
const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false });
@@ -69,7 +107,7 @@ export function useGameConfiguration() {
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
setRunningGame((current) => current?.appid === appid ? current : {
- ...(installed || { appid, name, nonSteam: false }),
+ ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }),
name,
configured: games.some((game) => game.appid === appid),
});
@@ -88,20 +126,157 @@ export function useGameConfiguration() {
const targets = useMemo<GameTarget[]>(() => {
const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) }));
- for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true });
+ for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true });
if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame);
return configured;
}, [games, installedGames, runningGame]);
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
- const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => {
+ const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (target.transport.kind !== "flatpak") return true;
+ const result = await ensureFlatpakSupport(target.transport.flatpakAppId);
+ if (!result.success || result.support_status !== "ready") {
+ showErrorToast(
+ "Flatpak support unavailable",
+ result.error || result.message || "The required Flatpak runtime extension is not ready",
+ );
+ return false;
+ }
+ return true;
+ }, []);
+
+ const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
try {
- await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam);
+ const existing = await getWorkaroundState(target.appid);
+ if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
+ const current = await readSteamLaunchOptions(appId, target.nonSteam);
+ const wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ const oldState = existing.state;
+ const oldShortcutExe = existing.shortcut_exe || undefined;
+ const oldCommandTokenAdded = existing.command_token_added === true;
+ const oldTransport = existing.transport || target.transport;
+ if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) {
+ throw new Error("Managed shortcut Target has no saved original executable");
+ }
+ if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) {
+ throw new Error("Shortcut Target changed externally; refusing to replace it");
+ }
+ if (target.nonSteam && !oldState && current.target === wrapperPath) {
+ throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown");
+ }
+ const state = oldState || { ...DEFAULT_WORKAROUND_STATE };
+ const originalExecutable = target.nonSteam
+ ? selectShortcutExecutable(
+ target,
+ oldShortcutExe,
+ target.transport.kind === "flatpak" ? target.executable : undefined,
+ current.target,
+ )
+ : undefined;
+ const initialIntegration = target.nonSteam
+ ? current.target === wrapperPath
+ : hasWrapperLaunchIntegration(current.options, wrapperPath);
+ const initialStateResult = await setWorkaroundState(
+ target.appid,
+ state,
+ originalExecutable || null,
+ oldCommandTokenAdded,
+ target.transport,
+ );
+ if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state");
+
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ try {
+ integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded);
+ const finalStateResult = await setWorkaroundState(
+ target.appid,
+ state,
+ target.nonSteam
+ ? (selectShortcutExecutable(
+ target,
+ integration.originalExecutable,
+ originalExecutable,
+ target.transport.kind === "flatpak" ? target.executable : undefined,
+ ) || null)
+ : null,
+ integration.commandTokenAdded,
+ target.transport,
+ );
+ if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state");
+ return true;
+ } catch (error) {
+ let rollbackSucceeded = true;
+ if (!initialIntegration && integration) {
+ try {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ target.nonSteam
+ ? (selectShortcutExecutable(
+ target,
+ integration?.originalExecutable,
+ originalExecutable,
+ target.transport.kind === "flatpak" ? target.executable : undefined,
+ ) || undefined)
+ : undefined,
+ integration?.commandTokenAdded ?? oldCommandTokenAdded,
+ );
+ } catch (rollbackError) {
+ showErrorToast("Workaround rollback failed", asError(rollbackError).message);
+ rollbackSucceeded = false;
+ }
+ }
+ if (rollbackSucceeded) {
+ const restored = oldState
+ ? await setWorkaroundState(
+ target.appid,
+ oldState,
+ oldShortcutExe || null,
+ oldCommandTokenAdded,
+ oldTransport,
+ )
+ : await removeWorkaroundState(target.appid);
+ if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state");
+ }
+ throw error;
+ }
+ } catch (error) {
+ showErrorToast("Could not initialize workarounds", asError(error).message);
+ return false;
+ }
+ }, [installedGames]);
+
+ const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
+ try {
+ const existing = await getWorkaroundState(target.appid);
+ if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
+ const wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ if (existing.state) {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ existing.shortcut_exe || undefined,
+ existing.command_token_added === true,
+ );
+ } else {
+ const current = await readSteamLaunchOptions(appId, target.nonSteam);
+ if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) {
+ throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown");
+ }
+ await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath);
+ }
+ const removed = await removeWorkaroundState(target.appid);
+ if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
return true;
} catch (error) {
- showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error));
+ showErrorToast("Could not clean up game workarounds", asError(error).message);
return false;
}
}, [installedGames]);
@@ -109,37 +284,65 @@ export function useGameConfiguration() {
const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
if (!selectedTarget?.name) return;
- if (cleanupLaunchOptions && !(await cleanupTargetLaunchOptions(selectedTarget))) return;
+ // The profile owns its wrapper integration. Keep this check on every
+ // configuration save so an external edit is detected before the profile
+ // is changed; toggles update the sidecar only.
+ if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return;
const result = await updateGameConfig(selectedAppId, selectedTarget.name, next);
if (result.success) await load();
- }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
+ }, [ensureTargetWorkarounds, load, selectedAppId, targets]);
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
- if (!(await cleanupTargetLaunchOptions(target))) return false;
+ if (!(await ensureTargetFlatpakSupport(target))) return false;
+ if (!(await ensureTargetWorkarounds(target))) return false;
const result = await updateGameConfig(appid, target.name, template);
if (result.success) await load();
+ else await removeTargetWorkarounds(target);
return result.success;
- }, [cleanupTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
const enableAll = useCallback(async (): Promise<void> => {
const available = targets.filter((target) => !target.configured && target.name);
if (available.length === 0) return;
+
for (const target of available) {
- if (!(await cleanupTargetLaunchOptions(target))) return;
+ if (!(await ensureTargetFlatpakSupport(target))) return;
+ if (!(await ensureTargetWorkarounds(target))) return;
const result = await updateGameConfig(target.appid, target.name, template);
if (!result.success) {
- showErrorToast("Could not enable all games", result.error || "A game profile could not be created");
+ await removeTargetWorkarounds(target);
+ showErrorToast(
+ "Could not enable all games",
+ result.error || `Could not create a profile for ${target.name}`,
+ );
return;
}
}
await load();
- }, [cleanupTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ const repair = useCallback(async (appid: string): Promise<boolean> => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target) return false;
+ if (target.transport.kind === "flatpak") {
+ const support = await repairFlatpakSupport(target.transport.flatpakAppId);
+ if (!support.success || support.support_status !== "ready") {
+ showErrorToast(
+ "Flatpak support unavailable",
+ support.error || support.message || "The required Flatpak runtime extension is not ready",
+ );
+ return false;
+ }
+ }
+ const success = await ensureTargetWorkarounds(target);
+ if (success) await load();
+ return success;
+ }, [ensureTargetWorkarounds, load, targets]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return;
+ if (selectedTarget && !(await removeTargetWorkarounds(selectedTarget))) return;
const result = await resetGameConfig(selectedAppId);
if (result.success) {
setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current);
@@ -147,10 +350,10 @@ export function useGameConfiguration() {
await load();
}
}
- }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
+ }, [load, removeTargetWorkarounds, selectedAppId, targets]);
const resetAll = useCallback(async () => {
for (const target of targets.filter((item) => item.configured)) {
- if (!(await cleanupTargetLaunchOptions(target))) return;
+ if (!(await removeTargetWorkarounds(target))) return;
}
const result = await resetAllGameConfigs();
if (result.success) {
@@ -158,7 +361,7 @@ export function useGameConfiguration() {
setSelectedAppId("");
await load();
}
- }, [cleanupTargetLaunchOptions, load, targets]);
+ }, [load, removeTargetWorkarounds, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load };
+ return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load };
}
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..9beb749 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 ?? undefined);
+ 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 ?? undefined);
+ 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/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index a937780..e9e44e1 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -1,29 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- applyWorkaroundChange,
- parseWorkaroundOptions,
+ getWorkaroundState,
+ removeWorkaroundState,
+ setWorkaroundState,
+ type TargetTransport,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import {
+ getDefaultWrapperPath,
+ hasWrapperLaunchIntegration,
+ installWrapperIntegration,
+ isLegacyWrapperToken,
readSteamLaunchOptions,
+ removeWrapperIntegration,
subscribeSteamLaunchOptions,
- updateSteamLaunchOptions,
- type ParsedWorkaroundOptions,
type SteamLaunchOptionsSnapshot,
- type WorkaroundField,
} from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
+export type WorkaroundField = keyof WorkaroundState;
export type WorkaroundLoadStatus = "loading" | "ready" | "error";
const SLIDER_DEBOUNCE_MS = 250;
+const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
+ dxvkFrameRate: 0,
+ disableGamescopeWsi: true,
+ disableHdr: true,
+ disableSteamdeckMode: false,
+ disableVkbasalt: false,
+ enableZink: false,
+};
+
interface PendingSliderUpdate {
timer: number;
value: number;
waiters: Array<(success: boolean) => void>;
}
-interface WorkaroundSnapshot {
+export interface WorkaroundSnapshot {
steam: SteamLaunchOptionsSnapshot;
- parsed: ParsedWorkaroundOptions;
+ state: WorkaroundState;
+ wrapperPath: string;
+ wrapperOwned: boolean;
+ integrationInstalled: boolean;
+ commandTokenAdded: boolean;
+ shortcutExe?: string | null;
+ transport: TargetTransport;
}
interface PerAppWorkarounds {
@@ -38,19 +61,144 @@ function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
-function makeSnapshot(steam: SteamLaunchOptionsSnapshot): WorkaroundSnapshot {
- return { steam, parsed: parseWorkaroundOptions(steam.options) };
+function selectShortcutExecutable(
+ transport: TargetTransport,
+ ...candidates: Array<string | null | undefined>
+): string | undefined {
+ const absolute = candidates
+ .map((candidate) => candidate?.trim())
+ .find((candidate) => candidate && candidate.startsWith("/"));
+ if (absolute) return absolute;
+ if (transport.kind === "flatpak") return "/usr/bin/flatpak";
+ return candidates.map((candidate) => candidate?.trim()).find(Boolean);
}
-export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds {
+function integrationIsInstalled(
+ steam: SteamLaunchOptionsSnapshot,
+ nonSteam: boolean,
+ wrapperPath: string,
+): boolean {
+ return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath);
+}
+
+function makeSnapshot(
+ steam: SteamLaunchOptionsSnapshot,
+ result: Awaited<ReturnType<typeof getWorkaroundState>>,
+ nonSteam: boolean,
+): WorkaroundSnapshot {
+ if (!result.state) throw new Error("Workaround state is not initialized for this profile");
+ const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
+ if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) {
+ throw new Error("Managed shortcut Target has no saved original executable");
+ }
+ return {
+ steam,
+ state: result.state,
+ wrapperPath,
+ wrapperOwned: result.wrapper_owned === true,
+ integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath),
+ commandTokenAdded: result.command_token_added === true,
+ shortcutExe: result.shortcut_exe,
+ transport: result.transport || { kind: "host" },
+ };
+}
+
+async function adoptWorkaroundState(
+ appId: string,
+ nonSteam: boolean,
+ transport: TargetTransport,
+ steam: SteamLaunchOptionsSnapshot,
+ wrapperPath: string,
+): Promise<WorkaroundSnapshot> {
+ if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) {
+ throw new Error("Shortcut Target is a wrapper but its original Target is unknown");
+ }
+ const originalExecutable = nonSteam
+ ? selectShortcutExecutable(transport, steam.target)
+ : null;
+ const initial = await setWorkaroundState(
+ appId,
+ DEFAULT_WORKAROUND_STATE,
+ originalExecutable,
+ false,
+ transport,
+ );
+ if (!initial.success) throw new Error(initial.error || "Could not create workaround state");
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ try {
+ integration = await installWrapperIntegration(
+ Number(appId),
+ nonSteam,
+ wrapperPath,
+ );
+ const finalized = await setWorkaroundState(
+ appId,
+ DEFAULT_WORKAROUND_STATE,
+ nonSteam
+ ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null)
+ : null,
+ integration.commandTokenAdded,
+ transport,
+ );
+ if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state");
+ return makeSnapshot(integration.snapshot, finalized, nonSteam);
+ } catch (error) {
+ let rollbackSucceeded = true;
+ if (integration) {
+ try {
+ await removeWrapperIntegration(
+ Number(appId),
+ nonSteam,
+ wrapperPath,
+ nonSteam
+ ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined)
+ : undefined,
+ integration?.commandTokenAdded ?? false,
+ );
+ } catch {
+ // Leave the owned integration in place rather than guessing at cleanup.
+ rollbackSucceeded = false;
+ }
+ }
+ if (rollbackSucceeded) {
+ const removed = await removeWorkaroundState(appId);
+ if (!removed.success) throw new Error(removed.error || "Could not roll back workaround state");
+ }
+ throw error;
+ }
+}
+
+export function usePerAppWorkarounds(
+ appId: string,
+ nonSteam: boolean,
+ transport: TargetTransport = { kind: "host" },
+): PerAppWorkarounds {
const [status, setStatus] = useState<WorkaroundLoadStatus>("loading");
const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const pendingSliderUpdate = useRef<PendingSliderUpdate | null>(null);
const numericAppId = Number(appId);
- const applySnapshot = useCallback((steam: SteamLaunchOptionsSnapshot) => {
- setSnapshot(makeSnapshot(steam));
+ const loadSnapshot = useCallback(async () => {
+ const [result, steam] = await Promise.all([
+ getWorkaroundState(appId),
+ readSteamLaunchOptions(numericAppId, nonSteam),
+ ]);
+ if (!result.success) throw new Error(result.error || "Could not read workaround state");
+ if (!result.state) {
+ return adoptWorkaroundState(
+ appId,
+ nonSteam,
+ transport,
+ steam,
+ result.wrapper_path || getDefaultWrapperPath(),
+ );
+ }
+ return makeSnapshot(steam, result, nonSteam);
+ }, [appId, nonSteam, numericAppId, transport]);
+
+ const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
+ setSnapshot(next);
setStatus("ready");
setError(null);
}, []);
@@ -59,65 +207,81 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
setStatus("loading");
setError(null);
try {
- applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam));
+ applySnapshot(await loadSnapshot());
} catch (refreshError) {
const nextError = asError(refreshError);
setStatus("error");
setError(nextError.message);
}
- }, [applySnapshot, nonSteam, numericAppId]);
+ }, [applySnapshot, loadSnapshot]);
useEffect(() => {
let active = true;
setStatus("loading");
setSnapshot(null);
setError(null);
-
- const handleSnapshot = (nextSnapshot: SteamLaunchOptionsSnapshot) => {
- if (!active) return;
- applySnapshot(nextSnapshot);
- };
- const handleSubscriptionError = (subscriptionError: Error) => {
- if (!active) return;
- setStatus("error");
- setError(subscriptionError.message);
- };
-
let unsubscribe = () => {};
try {
unsubscribe = subscribeSteamLaunchOptions(
numericAppId,
nonSteam,
- handleSnapshot,
- handleSubscriptionError,
+ (steam) => {
+ if (!active) return;
+ setSnapshot((current) => current ? {
+ ...current,
+ steam,
+ integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath),
+ } : current);
+ },
+ (subscriptionError) => {
+ if (!active) return;
+ setStatus("error");
+ setError(subscriptionError.message);
+ },
);
} catch (subscriptionError) {
- handleSubscriptionError(asError(subscriptionError));
+ if (active) {
+ setStatus("error");
+ setError(asError(subscriptionError).message);
+ }
}
-
- void readSteamLaunchOptions(numericAppId, nonSteam)
- .then((nextSnapshot) => {
- if (active) applySnapshot(nextSnapshot);
- })
+ void loadSnapshot()
+ .then((next) => { if (active) applySnapshot(next); })
.catch((readError) => {
- if (active) handleSubscriptionError(asError(readError));
+ if (active) {
+ setStatus("error");
+ setError(asError(readError).message);
+ }
});
-
return () => {
active = false;
unsubscribe();
};
- }, [applySnapshot, nonSteam, numericAppId]);
+ }, [applySnapshot, loadSnapshot, nonSteam, numericAppId]);
const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => {
+ const current = snapshot;
+ if (!current) return false;
setError(null);
+ const nextState = { ...current.state, [field]: value } as WorkaroundState;
try {
- const nextSnapshot = await updateSteamLaunchOptions(
- numericAppId,
- nonSteam,
- (options) => applyWorkaroundChange(options, field, value),
+ const result = await setWorkaroundState(
+ appId,
+ nextState,
+ current.shortcutExe ?? null,
+ current.commandTokenAdded,
+ current.transport,
);
- applySnapshot(nextSnapshot);
+ if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
+ applySnapshot({
+ ...current,
+ state: result.state,
+ wrapperPath: result.wrapper_path || current.wrapperPath,
+ wrapperOwned: result.wrapper_owned === true,
+ shortcutExe: result.shortcut_exe,
+ commandTokenAdded: result.command_token_added === true,
+ transport: result.transport || current.transport,
+ });
return true;
} catch (updateError) {
const nextError = asError(updateError);
@@ -126,14 +290,13 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
showErrorToast("Workaround update failed", nextError.message);
return false;
}
- }, [applySnapshot, nonSteam, numericAppId]);
+ }, [appId, applySnapshot, snapshot]);
const flushSliderUpdate = useCallback(async (): Promise<boolean> => {
const pending = pendingSliderUpdate.current;
if (!pending) return true;
-
pendingSliderUpdate.current = null;
- window.clearTimeout(pending.timer);
+ clearTimeout(pending.timer);
const success = await persistUpdate("dxvkFrameRate", pending.value);
pending.waiters.forEach((resolve) => resolve(success));
return success;
@@ -143,30 +306,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
if (field === "dxvkFrameRate") {
setError(null);
return new Promise<boolean>((resolve) => {
- const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] };
+ const pending = pendingSliderUpdate.current || { timer: 0, value: 0, waiters: [] };
window.clearTimeout(pending.timer);
pending.value = Number(value);
pending.waiters.push(resolve);
- pending.timer = window.setTimeout(() => {
- void flushSliderUpdate();
- }, SLIDER_DEBOUNCE_MS);
+ pending.timer = window.setTimeout(() => { void flushSliderUpdate(); }, SLIDER_DEBOUNCE_MS);
pendingSliderUpdate.current = pending;
});
}
-
const sliderSuccess = await flushSliderUpdate();
if (!sliderSuccess) return false;
return persistUpdate(field, value);
}, [flushSliderUpdate, persistUpdate]);
- useEffect(() => {
- return () => {
- const pending = pendingSliderUpdate.current;
- if (!pending) return;
- window.clearTimeout(pending.timer);
- pendingSliderUpdate.current = null;
- pending.waiters.forEach((resolve) => resolve(false));
- };
+ useEffect(() => () => {
+ const pending = pendingSliderUpdate.current;
+ if (!pending) return;
+ window.clearTimeout(pending.timer);
+ pendingSliderUpdate.current = null;
+ pending.waiters.forEach((resolve) => resolve(false));
}, [numericAppId, nonSteam]);
return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]);