summaryrefslogtreecommitdiff
path: root/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/hooks')
-rw-r--r--src/hooks/useFlatpakConfiguration.ts160
-rw-r--r--src/hooks/useGameConfiguration.ts386
-rw-r--r--src/hooks/useLsfgHooks.ts9
-rw-r--r--src/hooks/usePerAppWorkarounds.ts102
4 files changed, 376 insertions, 281 deletions
diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts
new file mode 100644
index 0000000..f936271
--- /dev/null
+++ b/src/hooks/useFlatpakConfiguration.ts
@@ -0,0 +1,160 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ enableFlatpakApp,
+ getFlatpakApps,
+ getRunningFlatpakApps,
+ removeFlatpakApp,
+ setFlatpakWorkaroundState,
+ updateFlatpakConfig,
+ type FlatpakApp,
+ type LsfgConfig,
+ type RunningFlatpakApp,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import { selectMostRecentRunningFlatpak } from "../utils/nowPlaying";
+import { showErrorToast } from "../utils/toastUtils";
+
+type FlatpakOperationResult = {
+ success: boolean;
+ error?: string | null;
+ config?: LsfgConfig | null;
+ state?: WorkaroundState | null;
+};
+
+export function useFlatpakConfiguration(enabled: boolean) {
+ const [apps, setApps] = useState<FlatpakApp[]>([]);
+ const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [busyAppId, setBusyAppId] = useState("");
+
+ const reload = useCallback(async () => {
+ if (!enabled) {
+ setApps([]);
+ return;
+ }
+ setLoading(true);
+ try {
+ const result = await getFlatpakApps();
+ if (!result.success) throw new Error(result.error || "Could not list Flatpak applications");
+ setApps(result.apps || []);
+ } catch (error) {
+ showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error));
+ } finally {
+ setLoading(false);
+ }
+ }, [enabled]);
+
+ const pollRunning = useCallback(async () => {
+ if (!enabled) {
+ setRunningApps([]);
+ return;
+ }
+ try {
+ const result = await getRunningFlatpakApps();
+ if (result.success) setRunningApps(result.apps || []);
+ } catch {}
+ }, [enabled]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ useEffect(() => {
+ void pollRunning();
+ if (!enabled) return;
+ const interval = window.setInterval(() => void pollRunning(), 2000);
+ return () => window.clearInterval(interval);
+ }, [enabled, pollRunning]);
+
+ const operate = useCallback(async (
+ appId: string,
+ operation: () => Promise<FlatpakOperationResult>,
+ refresh = true,
+ ): Promise<FlatpakOperationResult> => {
+ if (busyAppId) return { success: false };
+ setBusyAppId(appId);
+ try {
+ const result = await operation();
+ if (!result.success) throw new Error(result.error || "Flatpak operation failed");
+ if (refresh) {
+ await reload();
+ await pollRunning();
+ }
+ return result;
+ } catch (error) {
+ showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error));
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ } finally {
+ setBusyAppId("");
+ }
+ }, [busyAppId, pollRunning, reload]);
+
+ const enableApp = useCallback(async (appId: string) => (
+ await operate(appId, () => enableFlatpakApp(appId))
+ ).success, [operate]);
+ const removeApp = useCallback(async (appId: string) => (
+ await operate(appId, () => removeFlatpakApp(appId))
+ ).success, [operate]);
+ const enableAll = useCallback(async (): Promise<void> => {
+ if (busyAppId) return;
+ const available = apps.filter((app) => (
+ !app.enabled && !(app.prepared && !app.owned) && !app.error
+ ));
+ for (const app of available) {
+ const result = await operate(app.app_id, () => enableFlatpakApp(app.app_id), false);
+ if (!result.success) break;
+ }
+ await reload();
+ await pollRunning();
+ }, [apps, busyAppId, operate, pollRunning, reload]);
+ const removeAll = useCallback(async (): Promise<void> => {
+ if (busyAppId) return;
+ for (const app of apps.filter((item) => item.enabled)) {
+ const result = await operate(app.app_id, () => removeFlatpakApp(app.app_id), false);
+ if (!result.success) break;
+ }
+ await reload();
+ await pollRunning();
+ }, [apps, busyAppId, operate, pollRunning, reload]);
+ const updateConfig = useCallback(
+ async (appId: string, config: LsfgConfig) => {
+ const result = await operate(appId, () => updateFlatpakConfig(appId, config), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, config: result.config || config } : app
+ )));
+ }
+ return result.success;
+ },
+ [operate],
+ );
+ const updateWorkarounds = useCallback(
+ async (appId: string, state: WorkaroundState) => {
+ const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, workarounds: result.state || state } : app
+ )));
+ }
+ return result.success;
+ },
+ [operate],
+ );
+
+ const runningApp = useMemo(() => selectMostRecentRunningFlatpak(apps, runningApps), [apps, runningApps]);
+
+ return {
+ apps,
+ runningApps,
+ runningApp,
+ loading,
+ busyAppId,
+ reload,
+ enableApp,
+ enableAll,
+ removeApp,
+ removeAll,
+ updateConfig,
+ updateWorkarounds,
+ };
+}
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index e120426..deb68ff 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
-import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
+import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundApp, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions";
+import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions";
+import { getTargetSource, mergeGameTargets, type GameTarget, type KnownGameSource } from "../utils/gameTargets";
import { showErrorToast } from "../utils/toastUtils";
-export interface GameTarget extends InstalledGame { configured: boolean; }
+export type { GameSource, GameTarget, KnownGameSource } from "../utils/gameTargets";
async function getSteamShortcuts(): Promise<InstalledGame[]> {
const apps = (globalThis as any).SteamClient?.Apps;
@@ -23,7 +24,6 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
appid: String(appid >>> 0),
name,
nonSteam: true,
- transport: { kind: "host" },
}];
});
} catch {
@@ -40,18 +40,6 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta
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;
- 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,
@@ -69,20 +57,29 @@ export function useGameConfiguration() {
const [games, setGames] = useState<GameConfigEntry[]>([]);
const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false });
const [installedGames, setInstalledGames] = useState<InstalledGame[]>([]);
+ const [workaroundApps, setWorkaroundApps] = useState<WorkaroundApp[]>([]);
const [configsLoaded, setConfigsLoaded] = useState(false);
const [selectedAppId, setSelectedAppId] = useState("");
const [runningGame, setRunningGame] = useState<GameTarget | null>(null);
+ const [bulkOperationBusy, setBulkOperationBusy] = useState(false);
+ const bulkOperationLock = useRef(false);
const previousRunningAppId = useRef<string | null>(null);
const previousQuickAccessVisible = useRef<boolean | null>(null);
const quickAccessVisible = useQuickAccessVisible();
const load = useCallback(async () => {
- const [result, installed, shortcuts] = await Promise.all([getGameConfigs(), getInstalledGames(), getSteamShortcuts()]);
+ const [result, installed, shortcuts, workaroundResult] = await Promise.all([
+ getGameConfigs(),
+ getInstalledGames(),
+ getSteamShortcuts(),
+ getWorkaroundApps(),
+ ]);
if (result.success) {
setGlobalConfig(result.global_config || { dll: "", no_fp16: false });
setGames(result.games || []);
}
setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts));
+ setWorkaroundApps(workaroundResult.success ? workaroundResult.apps || [] : []);
setConfigsLoaded(true);
}, []);
@@ -92,6 +89,7 @@ export function useGameConfiguration() {
previousQuickAccessVisible.current = quickAccessVisible;
if (initialLoad || becameVisible) void load();
}, [load, quickAccessVisible]);
+
useEffect(() => {
const poll = () => {
if (!configsLoaded) return;
@@ -101,16 +99,29 @@ export function useGameConfiguration() {
const installed = installedGames.find((game) => game.appid === appid);
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
- setRunningGame((current) => current?.appid === appid ? current : {
- ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }),
+ const source = getTargetSource(appid, installedGames, workaroundApps);
+ const next: GameTarget = {
+ ...(installed || { appid, name, nonSteam: source === "nonSteam" }),
name,
+ nonSteam: source === "nonSteam",
+ source,
configured: games.some((game) => game.appid === appid),
- });
+ };
+ setRunningGame((current) => (
+ current?.appid === next.appid
+ && current.name === next.name
+ && current.nonSteam === next.nonSteam
+ && current.source === next.source
+ && current.configured === next.configured
+ ? current
+ : next
+ ));
};
poll();
const interval = window.setInterval(poll, 2000);
return () => window.clearInterval(interval);
- }, [configsLoaded, games, installedGames]);
+ }, [configsLoaded, games, installedGames, workaroundApps]);
+
useEffect(() => {
const appid = runningGame?.appid || null;
if (appid !== previousRunningAppId.current) {
@@ -120,162 +131,89 @@ export function useGameConfiguration() {
}, [runningGame?.appid]);
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, transport: { kind: "host" }, configured: true });
- if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame);
- return configured;
- }, [games, installedGames, runningGame]);
+ return mergeGameTargets(games, installedGames, workaroundApps, runningGame);
+ }, [games, installedGames, runningGame, workaroundApps]);
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
+ const runningConfig = runningGame
+ ? games.find((game) => game.appid === runningGame.appid)?.config || template
+ : template;
- 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",
- );
+ const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (target.source === "unknown") {
+ showErrorToast("Could not initialize workarounds", "The target source is unknown; re-discover the game before enabling it");
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);
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ let newState = false;
+ let stateWriteAttempted = false;
+ let wrapperPath = getDefaultWrapperPath();
try {
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;
- const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak";
- if (usesShortcutTarget && oldState && current.target === wrapperPath && !oldShortcutExe) {
- throw new Error("Managed shortcut Target has no saved original executable");
- }
- if (usesShortcutTarget && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) {
- throw new Error("Shortcut Target changed externally; refusing to replace it");
- }
- if (usesShortcutTarget && !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 = usesShortcutTarget
- ? selectShortcutExecutable(
- target,
- oldShortcutExe,
- target.executable,
- current.target,
- )
- : undefined;
- const initialIntegration = usesShortcutTarget
- ? current.target === wrapperPath
- : hasWrapperLaunchIntegration(current.options, wrapperPath);
- const initialStateResult = await setWorkaroundState(
+ wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ const state = existing.state || { ...DEFAULT_WORKAROUND_STATE };
+ const commandTokenAdded = existing.command_token_added === true;
+ newState = !existing.state;
+ integration = await installWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ commandTokenAdded,
+ );
+ stateWriteAttempted = true;
+ const saved = await setWorkaroundState(
target.appid,
state,
- originalExecutable || null,
- oldCommandTokenAdded,
- target.transport,
+ integration.commandTokenAdded,
+ target.nonSteam,
);
- 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,
- target.transport.kind,
- );
- const finalStateResult = await setWorkaroundState(
- target.appid,
- state,
- usesShortcutTarget
- ? (selectShortcutExecutable(
- target,
- integration.originalExecutable,
- originalExecutable,
- target.executable,
- ) || 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,
- usesShortcutTarget
- ? (selectShortcutExecutable(
- target,
- integration?.originalExecutable,
- originalExecutable,
- target.executable,
- ) || undefined)
- : undefined,
- integration?.commandTokenAdded ?? oldCommandTokenAdded,
- target.transport.kind,
- );
- } catch (rollbackError) {
- showErrorToast("Workaround rollback failed", asError(rollbackError).message);
- rollbackSucceeded = false;
- }
+ if (!saved.success) throw new Error(saved.error || "Could not save workaround state");
+ return true;
+ } catch (error) {
+ let rollbackSucceeded = true;
+ if (integration?.changed) {
+ try {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ integration.commandTokenAdded,
+ );
+ } 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");
+ }
+ if (rollbackSucceeded && newState && stateWriteAttempted) {
+ const restored = await removeWorkaroundState(target.appid);
+ if (!restored.success) {
+ showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state");
+ rollbackSucceeded = false;
}
- 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 installed = installedGames.some((game) => game.appid === target.appid);
+ if (target.source === "unknown" && installed) 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();
- const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak";
- if (existing.state) {
+ if (installed) {
await removeWrapperIntegration(
appId,
target.nonSteam,
wrapperPath,
- existing.shortcut_exe || undefined,
existing.command_token_added === true,
- target.transport.kind,
);
- } else {
- const current = await readSteamLaunchOptions(appId, target.nonSteam);
- if (usesShortcutTarget && (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");
@@ -286,59 +224,113 @@ export function useGameConfiguration() {
}
}, [installedGames]);
- const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => {
- const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (!selectedTarget?.name) 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);
+ const acquireBulkOperation = useCallback(() => {
+ if (bulkOperationLock.current) return false;
+ bulkOperationLock.current = true;
+ setBulkOperationBusy(true);
+ return true;
+ }, []);
+
+ const releaseBulkOperation = useCallback(() => {
+ bulkOperationLock.current = false;
+ setBulkOperationBusy(false);
+ }, []);
+
+ const cleanupAllWorkarounds = useCallback(async (): Promise<boolean> => {
+ try {
+ const result = await getWorkaroundApps();
+ if (!result.success) throw new Error(result.error || "Could not read workaround state");
+ const cleaned = new Set<string>();
+ const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
+
+ for (const entry of result.apps || []) {
+ await removeWrapperIntegration(
+ Number(entry.appid),
+ entry.non_steam,
+ wrapperPath,
+ entry.command_token_added,
+ );
+ const removed = await removeWorkaroundState(entry.appid);
+ if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
+ cleaned.add(entry.appid);
+ }
+
+ // Also clean configured targets whose sidecar entry was lost. This
+ // removes an old wrapper and only the plugin-managed launch pieces.
+ for (const target of targets.filter((item) => item.configured && installedGames.some((game) => game.appid === item.appid))) {
+ if (!cleaned.has(target.appid) && !(await removeTargetWorkarounds(target))) return false;
+ }
+ return true;
+ } catch (error) {
+ showErrorToast("Could not clean up game launch options", asError(error).message);
+ return false;
+ }
+ }, [installedGames, removeTargetWorkarounds, targets]);
+
+ const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target?.name) return false;
+ if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false;
+ const result = await updateGameConfig(appid, target.name, next);
if (result.success) await load();
- }, [ensureTargetWorkarounds, load, selectedAppId, targets]);
+ return result.success;
+ }, [ensureTargetWorkarounds, load, targets]);
+
+ const save = useCallback(
+ async (next: ConfigurationData, cleanupLaunchOptions = false) => {
+ if (!selectedAppId) return false;
+ return saveFor(selectedAppId, next, cleanupLaunchOptions);
+ },
+ [saveFor, selectedAppId],
+ );
+
+ const updateGlobal = useCallback(async (next: GlobalConfig): Promise<boolean> => {
+ const result = await saveGlobalConfig(next);
+ if (!result.success) return false;
+ setGlobalConfig(result.global_config || next);
+ return true;
+ }, []);
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) 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;
- }, [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;
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
- for (const target of available) {
- if (!(await ensureTargetFlatpakSupport(target))) return;
- if (!(await ensureTargetWorkarounds(target))) return;
- const result = await updateGameConfig(target.appid, target.name, template);
- if (!result.success) {
- await removeTargetWorkarounds(target);
- showErrorToast(
- "Could not enable all games",
- result.error || `Could not create a profile for ${target.name}`,
- );
- return;
+ const enableAll = useCallback(async (source: KnownGameSource): Promise<void> => {
+ if (!acquireBulkOperation()) return;
+ try {
+ const available = targets.filter((target) => target.source === source && !target.configured && target.name);
+ if (available.length === 0) return;
+ for (const target of available) {
+ if (!(await ensureTargetWorkarounds(target))) {
+ await load();
+ return;
+ }
+ const result = await updateGameConfig(target.appid, target.name, template);
+ if (!result.success) {
+ await removeTargetWorkarounds(target);
+ showErrorToast(
+ "Could not enable all games",
+ result.error || `Could not create a profile for ${target.name}`,
+ );
+ await load();
+ return;
+ }
}
+ await load();
+ } finally {
+ releaseBulkOperation();
}
- await load();
- }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ }, [acquireBulkOperation, ensureTargetWorkarounds, load, removeTargetWorkarounds, releaseBulkOperation, 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;
@@ -356,17 +348,31 @@ export function useGameConfiguration() {
}
}
}, [load, removeTargetWorkarounds, selectedAppId, targets]);
- const resetAll = useCallback(async () => {
- for (const target of targets.filter((item) => item.configured)) {
- if (!(await removeTargetWorkarounds(target))) return;
- }
- const result = await resetAllGameConfigs();
- if (result.success) {
- setRunningGame((current) => current ? { ...current, configured: false } : current);
+
+ const resetAll = useCallback(async (source: KnownGameSource) => {
+ if (!acquireBulkOperation()) return;
+ try {
+ const selectedTargets = targets.filter((item) => item.configured && item.source === source);
+ if (selectedTargets.length === 0) return;
+ for (const target of selectedTargets) {
+ if (!(await removeTargetWorkarounds(target))) {
+ await load();
+ return;
+ }
+ }
+ const result = await resetGameConfigs(selectedTargets.map((target) => target.appid));
+ if (!result.success) {
+ showErrorToast("Could not remove all profiles", result.error || "Could not remove the selected profiles");
+ await load();
+ return;
+ }
+ setRunningGame((current) => current?.source === source ? { ...current, configured: false } : current);
setSelectedAppId("");
await load();
+ } finally {
+ releaseBulkOperation();
}
- }, [load, removeTargetWorkarounds, targets]);
+ }, [acquireBulkOperation, load, removeTargetWorkarounds, releaseBulkOperation, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load };
+ return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, bulkOperationBusy, cleanupAllWorkarounds, reload: load };
}
diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts
index 9beb749..0f51e90 100644
--- a/src/hooks/useLsfgHooks.ts
+++ b/src/hooks/useLsfgHooks.ts
@@ -13,7 +13,10 @@ import {
showUninstallSuccessToast,
} from "../utils/toastUtils";
-export function useInstallation(reloadConfig?: () => Promise<void>) {
+export function useInstallation(
+ reloadConfig?: () => Promise<void>,
+ beforeUninstall?: () => Promise<boolean>,
+) {
const [isInstalled, setIsInstalled] = useState(false);
const [installationStatus, setInstallationStatus] = useState("");
const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false);
@@ -77,6 +80,10 @@ export function useInstallation(reloadConfig?: () => Promise<void>) {
setIsUninstalling(true);
setInstallationStatus("Uninstalling lsfg-vk...");
try {
+ if (beforeUninstall && !(await beforeUninstall())) {
+ setInstallationStatus("Uninstallation cancelled: could not clean up launch options");
+ return;
+ }
const result = await uninstallLsfgVk();
if (!result.success) {
setInstallationStatus(`Uninstallation failed: ${result.error}`);
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index ebb12e2..ab33bb2 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -3,14 +3,12 @@ import {
getWorkaroundState,
removeWorkaroundState,
setWorkaroundState,
- type TargetTransport,
type WorkaroundState,
} from "../api/lsfgApi";
import {
getDefaultWrapperPath,
- hasWrapperLaunchIntegration,
installWrapperIntegration,
- isLegacyWrapperToken,
+ isWrapperIntegrationInstalled,
readSteamLaunchOptions,
removeWrapperIntegration,
subscribeSteamLaunchOptions,
@@ -45,8 +43,6 @@ export interface WorkaroundSnapshot {
wrapperOwned: boolean;
integrationInstalled: boolean;
commandTokenAdded: boolean;
- shortcutExe?: string | null;
- transport: TargetTransport;
}
interface PerAppWorkarounds {
@@ -61,115 +57,50 @@ function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
-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);
-}
-
-function usesShortcutTarget(nonSteam: boolean, transport: TargetTransport): boolean {
- return nonSteam && transport.kind === "flatpak";
-}
-
-function integrationIsInstalled(
- steam: SteamLaunchOptionsSnapshot,
- nonSteam: boolean,
- transport: TargetTransport,
- wrapperPath: string,
-): boolean {
- return usesShortcutTarget(nonSteam, transport)
- ? steam.target === wrapperPath
- : hasWrapperLaunchIntegration(steam.options, wrapperPath);
-}
-
function makeSnapshot(
steam: SteamLaunchOptionsSnapshot,
result: Awaited<ReturnType<typeof getWorkaroundState>>,
nonSteam: boolean,
- transport: TargetTransport,
): WorkaroundSnapshot {
if (!result.state) throw new Error("Workaround state is not initialized for this profile");
const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
- const selectedTransport = result.transport || transport;
- if (usesShortcutTarget(nonSteam, selectedTransport) && 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, selectedTransport, wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath),
commandTokenAdded: result.command_token_added === true,
- shortcutExe: result.shortcut_exe,
- transport: selectedTransport,
};
}
async function adoptWorkaroundState(
appId: string,
nonSteam: boolean,
- transport: TargetTransport,
- steam: SteamLaunchOptionsSnapshot,
wrapperPath: string,
): Promise<WorkaroundSnapshot> {
- const shortcutTarget = usesShortcutTarget(nonSteam, transport);
- if (shortcutTarget && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) {
- throw new Error("Shortcut Target is a wrapper but its original Target is unknown");
- }
- const originalExecutable = shortcutTarget
- ? 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,
- false,
- transport.kind,
- );
+ integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false);
const finalized = await setWorkaroundState(
appId,
DEFAULT_WORKAROUND_STATE,
- shortcutTarget
- ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null)
- : null,
integration.commandTokenAdded,
- transport,
+ nonSteam,
);
if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state");
- return makeSnapshot(integration.snapshot, finalized, nonSteam, transport);
+ return makeSnapshot(integration.snapshot, finalized, nonSteam);
} catch (error) {
let rollbackSucceeded = true;
- if (integration) {
+ if (integration?.changed) {
try {
await removeWrapperIntegration(
Number(appId),
nonSteam,
wrapperPath,
- shortcutTarget
- ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined)
- : undefined,
- integration?.commandTokenAdded ?? false,
- transport.kind,
+ integration.commandTokenAdded,
);
} catch {
- // Leave the owned integration in place rather than guessing at cleanup.
rollbackSucceeded = false;
}
}
@@ -181,11 +112,7 @@ async function adoptWorkaroundState(
}
}
-export function usePerAppWorkarounds(
- appId: string,
- nonSteam: boolean,
- transport: TargetTransport = { kind: "host" },
-): PerAppWorkarounds {
+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);
@@ -202,13 +129,11 @@ export function usePerAppWorkarounds(
return adoptWorkaroundState(
appId,
nonSteam,
- transport,
- steam,
result.wrapper_path || getDefaultWrapperPath(),
);
}
- return makeSnapshot(steam, result, nonSteam, transport);
- }, [appId, nonSteam, numericAppId, transport]);
+ return makeSnapshot(steam, result, nonSteam);
+ }, [appId, nonSteam, numericAppId]);
const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
setSnapshot(next);
@@ -243,7 +168,7 @@ export function usePerAppWorkarounds(
setSnapshot((current) => current ? {
...current,
steam,
- integrationInstalled: integrationIsInstalled(steam, nonSteam, current.transport, current.wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath),
} : current);
},
(subscriptionError) => {
@@ -281,9 +206,8 @@ export function usePerAppWorkarounds(
const result = await setWorkaroundState(
appId,
nextState,
- current.shortcutExe ?? null,
current.commandTokenAdded,
- current.transport,
+ nonSteam,
);
if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
applySnapshot({
@@ -291,9 +215,7 @@ export function usePerAppWorkarounds(
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) {