diff options
Diffstat (limited to 'src/hooks')
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 245 | ||||
| -rw-r--r-- | src/hooks/useInstallationActions.ts | 84 | ||||
| -rw-r--r-- | src/hooks/useLsfgHooks.ts | 87 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 272 |
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]); |
