diff options
Diffstat (limited to 'src/hooks')
| -rw-r--r-- | src/hooks/useFlatpakConfiguration.ts | 160 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 386 | ||||
| -rw-r--r-- | src/hooks/useLsfgHooks.ts | 9 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 102 |
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) { |
