diff options
Diffstat (limited to 'src/hooks')
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 109 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 173 |
2 files changed, 273 insertions, 9 deletions
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index d279a8c..6d1fe6a 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,10 +1,37 @@ 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 { ConfigurationData, getDefaults } from "../config/configSchema"; +import { applyWorkaroundState, cleanupLegacySteamLaunchOptions, cleanupSteamLaunchOptions, getDefaultWorkaroundState, updateSteamLaunchOptions } from "../utils/steamLaunchOptions"; +import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } +async function getSteamShortcuts(): Promise<InstalledGame[]> { + const apps = (globalThis as any).SteamClient?.Apps; + if (typeof apps?.GetAllShortcuts !== "function") return []; + + try { + const shortcuts = await apps.GetAllShortcuts(); + if (!Array.isArray(shortcuts)) return []; + return shortcuts.flatMap((shortcut: any) => { + 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 }]; + }); + } catch { + return []; + } +} + +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); + return Array.from(games.values()); +} + export function useGameConfiguration() { const [games, setGames] = useState<GameConfigEntry[]>([]); const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false }); @@ -13,18 +40,25 @@ export function useGameConfiguration() { const [selectedAppId, setSelectedAppId] = useState(""); const [runningGame, setRunningGame] = useState<GameTarget | null>(null); const previousRunningAppId = useRef<string | null>(null); + const previousQuickAccessVisible = useRef<boolean | null>(null); + const quickAccessVisible = useQuickAccessVisible(); const load = useCallback(async () => { - const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]); + const [result, installed, shortcuts] = await Promise.all([getGameConfigs(), getInstalledGames(), getSteamShortcuts()]); if (result.success) { setGlobalConfig(result.global_config || { dll: "", no_fp16: false }); setGames(result.games || []); } - if (installed.success) setInstalledGames(installed.games || []); + setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts)); setConfigsLoaded(true); }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + const initialLoad = previousQuickAccessVisible.current === null; + const becameVisible = quickAccessVisible && previousQuickAccessVisible.current === false; + previousQuickAccessVisible.current = quickAccessVisible; + if (initialLoad || becameVisible) void load(); + }, [load, quickAccessVisible]); useEffect(() => { const poll = () => { if (!configsLoaded) return; @@ -61,23 +95,77 @@ export function useGameConfiguration() { const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; - const save = useCallback(async (next: ConfigurationData) => { + const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await cleanupLegacySteamLaunchOptions(Number(target.appid), target.nonSteam); + return true; + } catch (error) { + showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + + const removeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); + return true; + } catch (error) { + showErrorToast("Could not clean up Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + + const initializeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await updateSteamLaunchOptions( + Number(target.appid), + target.nonSteam, + (options) => applyWorkaroundState(options, getDefaultWorkaroundState()), + ); + return true; + } catch (error) { + showErrorToast("Could not initialize Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + + 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; const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); - }, [load, selectedAppId, targets]); + }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; + if (!(await initializeTargetLaunchOptions(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); return result.success; - }, [load, targets, template]); + }, [initializeTargetLaunchOptions, load, 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 initializeTargetLaunchOptions(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"); + return; + } + } + await load(); + }, [initializeTargetLaunchOptions, load, targets, template]); const resetSelected = useCallback(async () => { if (selectedAppId) { + const selectedTarget = targets.find((target) => target.appid === selectedAppId); + if (selectedTarget && !(await removeTargetLaunchOptions(selectedTarget))) return; const result = await resetGameConfig(selectedAppId); if (result.success) { setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); @@ -85,15 +173,18 @@ export function useGameConfiguration() { await load(); } } - }, [load, selectedAppId]); + }, [load, removeTargetLaunchOptions, selectedAppId, targets]); const resetAll = useCallback(async () => { + for (const target of targets.filter((item) => item.configured)) { + if (!(await removeTargetLaunchOptions(target))) return; + } const result = await resetAllGameConfigs(); if (result.success) { setRunningGame((current) => current ? { ...current, configured: false } : current); setSelectedAppId(""); await load(); } - }, [load]); + }, [load, removeTargetLaunchOptions, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts new file mode 100644 index 0000000..a937780 --- /dev/null +++ b/src/hooks/usePerAppWorkarounds.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + applyWorkaroundChange, + parseWorkaroundOptions, + readSteamLaunchOptions, + subscribeSteamLaunchOptions, + updateSteamLaunchOptions, + type ParsedWorkaroundOptions, + type SteamLaunchOptionsSnapshot, + type WorkaroundField, +} from "../utils/steamLaunchOptions"; +import { showErrorToast } from "../utils/toastUtils"; + +export type WorkaroundLoadStatus = "loading" | "ready" | "error"; + +const SLIDER_DEBOUNCE_MS = 250; + +interface PendingSliderUpdate { + timer: number; + value: number; + waiters: Array<(success: boolean) => void>; +} + +interface WorkaroundSnapshot { + steam: SteamLaunchOptionsSnapshot; + parsed: ParsedWorkaroundOptions; +} + +interface PerAppWorkarounds { + status: WorkaroundLoadStatus; + snapshot: WorkaroundSnapshot | null; + refresh: () => Promise<void>; + update: (field: WorkaroundField, value: boolean | number) => Promise<boolean>; + error: string | null; +} + +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) }; +} + +export function usePerAppWorkarounds(appId: string, nonSteam: boolean): 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)); + setStatus("ready"); + setError(null); + }, []); + + const refresh = useCallback(async () => { + setStatus("loading"); + setError(null); + try { + applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam)); + } catch (refreshError) { + const nextError = asError(refreshError); + setStatus("error"); + setError(nextError.message); + } + }, [applySnapshot, nonSteam, numericAppId]); + + 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, + ); + } catch (subscriptionError) { + handleSubscriptionError(asError(subscriptionError)); + } + + void readSteamLaunchOptions(numericAppId, nonSteam) + .then((nextSnapshot) => { + if (active) applySnapshot(nextSnapshot); + }) + .catch((readError) => { + if (active) handleSubscriptionError(asError(readError)); + }); + + return () => { + active = false; + unsubscribe(); + }; + }, [applySnapshot, nonSteam, numericAppId]); + + const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => { + setError(null); + try { + const nextSnapshot = await updateSteamLaunchOptions( + numericAppId, + nonSteam, + (options) => applyWorkaroundChange(options, field, value), + ); + applySnapshot(nextSnapshot); + return true; + } catch (updateError) { + const nextError = asError(updateError); + setStatus("error"); + setError(nextError.message); + showErrorToast("Workaround update failed", nextError.message); + return false; + } + }, [applySnapshot, nonSteam, numericAppId]); + + const flushSliderUpdate = useCallback(async (): Promise<boolean> => { + const pending = pendingSliderUpdate.current; + if (!pending) return true; + + pendingSliderUpdate.current = null; + window.clearTimeout(pending.timer); + const success = await persistUpdate("dxvkFrameRate", pending.value); + pending.waiters.forEach((resolve) => resolve(success)); + return success; + }, [persistUpdate]); + + const update = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => { + if (field === "dxvkFrameRate") { + setError(null); + return new Promise<boolean>((resolve) => { + 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); + 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)); + }; + }, [numericAppId, nonSteam]); + + return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]); +} |
