summaryrefslogtreecommitdiff
path: root/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/hooks')
-rw-r--r--src/hooks/useGameConfiguration.ts109
-rw-r--r--src/hooks/usePerAppWorkarounds.ts173
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]);
+}