diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 20:45:20 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 20:45:20 -0400 |
| commit | fb4d053213bdbda271a54b517a11a89c4780f80a (patch) | |
| tree | cf63819208ceb75c49c0ad08de8edd1b2b81082c /src | |
| parent | cc1e6f47dd9838b066822162a607d2859c043aff (diff) | |
| download | decky-lsfg-vk-fb4d053213bdbda271a54b517a11a89c4780f80a.tar.gz decky-lsfg-vk-fb4d053213bdbda271a54b517a11a89c4780f80a.zip | |
fix: restore profile and Flatpak controls
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/lsfgApi.ts | 15 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 3 | ||||
| -rw-r--r-- | src/components/Content.tsx | 11 | ||||
| -rw-r--r-- | src/components/GameConfigurationSelector.tsx | 40 | ||||
| -rw-r--r-- | src/components/SetupTab.tsx | 81 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 65 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 24 |
8 files changed, 221 insertions, 19 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index f487dff..16b6eab 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -137,6 +137,17 @@ export interface FlatpakCleanupResult { ownership_uncertain: boolean; } +export interface FlatpakExtensionToggleResult { + success: boolean; + message: string; + error?: string | null; + runtime_branch: string; + enabled: boolean; + installed: boolean; + owned_by_plugin: boolean; + preserved: boolean; +} + // API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); @@ -147,6 +158,10 @@ export const getConfigFileContent = callable<[], FileContentResult>("get_config_ export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); +export const setFlatpakExtensionEnabled = callable< + [string, boolean], + FlatpakExtensionToggleResult +>("set_flatpak_extension_enabled"); export const removePluginOwnedFlatpakExtensions = callable< [], FlatpakCleanupResult diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 90c1a7b..8f8690c 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -14,6 +14,7 @@ interface ConfigurationTabProps { onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; onEnable: (appid: string) => Promise<boolean>; + onEnableAll: () => Promise<void>; onRepair: (appid: string) => Promise<boolean>; onReset: () => Promise<void>; onResetAll: () => Promise<void>; @@ -26,6 +27,7 @@ export function ConfigurationTab({ onSelect, onConfigChange, onEnable, + onEnableAll, onRepair, onReset, onResetAll, @@ -84,6 +86,7 @@ export function ConfigurationTab({ onSelect(appid); setDetailAppId(appid); }} + onEnableAll={onEnableAll} onResetAll={onResetAll} focusConfiguredToggle={focusConfiguredToggle} onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index ff2376b..58512d5 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,18 +1,20 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; -import { FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { useInstallationStatus } from "../hooks/useLsfgHooks"; import { ConfigurationTab } from "./ConfigurationTab"; +import { ConfigFileTab } from "./ConfigFileTab"; import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; const tabIcons = { nowPlaying: <FaGamepad size={18} />, games: <FaList size={18} />, + configFile: <FaFileAlt size={18} />, setup: <FaTools size={18} />, }; @@ -34,6 +36,7 @@ export function Content() { setSelectedAppId, save, enable, + enableAll, repair, resetSelected, resetAll, @@ -130,12 +133,18 @@ export function Content() { onSelect={setSelectedAppId} onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} onEnable={enable} + onEnableAll={enableAll} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> ), }, + { + id: "ConfigFile", + title: tabIcons.configFile, + content: <ConfigFileTab />, + }, { id: "Setup", title: tabIcons.setup, content: setupContent }, ] : [ diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 2a92c67..92eacba 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -7,6 +7,7 @@ interface Props { targets: GameTarget[]; runningGame: GameTarget | null; onSelect: (appid: string) => void; + onEnableAll: () => Promise<void>; onResetAll: () => Promise<void>; focusConfiguredToggle?: boolean; onConfiguredToggleFocused?: () => void; @@ -95,6 +96,7 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, + onEnableAll, onResetAll, focusConfiguredToggle = false, onConfiguredToggleFocused, @@ -131,8 +133,39 @@ export function GameConfigurationSelector({ ); }; + const confirmEnableAll = () => { + showModal( + <ConfirmModal + strTitle="Enable all available games?" + strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults. Flatpak targets will be provisioned as needed." + strOKButtonText="Enable all" + strCancelButtonText="Cancel" + onOK={() => void onEnableAll()} + onCancel={() => {}} + />, + ); + }; + return ( <> + <style> + {` + .LSFG_GameGroupCollapseButton_Container > div > div > div > button, + .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_GameGroupCollapseButton_Container svg { + display: block; + margin: 0; + } + `} + </style> {targets.length === 0 && ( <PanelSectionRow> <Field label="No installed games" description="Steam has not reported any eligible games" /> @@ -153,6 +186,13 @@ export function GameConfigurationSelector({ onToggle={toggleAvailable} onSelect={onSelect} /> + {availableGames.length > 0 && ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={confirmEnableAll}> + Enable all available games + </ButtonItem> + </PanelSectionRow> + )} <PanelSectionRow> <ButtonItem layout="below" diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 9c854e1..df43c5e 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,13 +1,15 @@ -import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, showModal } from "@decky/ui"; +import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, ToggleField, showModal } from "@decky/ui"; import { useEffect, useState } from "react"; import { getFlatpakSupportStatus, removePluginOwnedFlatpakExtensions, + setFlatpakExtensionEnabled, type FlatpakExtensionStatus, type SteamBranchStatus, } from "../api/lsfgApi"; import { InstallationButton } from "./InstallationButton"; import { StatusDisplay } from "./StatusDisplay"; +import { showErrorToast } from "../utils/toastUtils"; interface SetupTabProps { isInstalled: boolean; @@ -25,7 +27,7 @@ interface SetupTabProps { function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null); const [advanced, setAdvanced] = useState(false); - const [busy, setBusy] = useState(false); + const [operation, setOperation] = useState<string | null>(null); const refresh = async () => { try { @@ -51,6 +53,51 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { if (!relevant || !status?.available) return null; + const runExtensionOperation = async (version: string, enabled: boolean) => { + const operationKey = `${enabled ? "enable" : "disable"}-${version}`; + setOperation(operationKey); + try { + const result = await setFlatpakExtensionEnabled(version, enabled); + if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); + await refresh(); + } catch (error) { + showErrorToast("Flatpak runtime update failed", String(error)); + } finally { + setOperation(null); + } + }; + + const confirmDisable = (version: string) => { + showModal( + <ConfirmModal + strTitle={`Disable Flatpak runtime ${version}?`} + strDescription="Only runtime extensions installed by this plugin can be removed. Pre-existing extensions are preserved." + strOKButtonText="Disable" + strCancelButtonText="Cancel" + onOK={() => void runExtensionOperation(version, false)} + onCancel={() => {}} + />, + ); + }; + + const handleExtensionToggle = (version: string, enabled: boolean) => { + const installed = status.installed_branches.includes(version); + const owned = status.owned_branches.includes(version); + if (!enabled && installed && !owned) { + showErrorToast( + "Flatpak runtime preserved", + `${version} was not installed by this plugin, so it will remain installed.`, + ); + void refresh(); + return; + } + if (!enabled && installed && owned) { + confirmDisable(version); + return; + } + void runExtensionOperation(version, enabled); + }; + const confirmCleanup = () => { showModal( <ConfirmModal @@ -59,12 +106,15 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { strOKButtonText="Remove extensions" strCancelButtonText="Cancel" onOK={async () => { - setBusy(true); + setOperation("cleanup"); try { - await removePluginOwnedFlatpakExtensions(); + const result = await removePluginOwnedFlatpakExtensions(); + if (!result.success) throw new Error(result.error || result.message || "Flatpak cleanup failed"); await refresh(); + } catch (error) { + showErrorToast("Flatpak cleanup failed", String(error)); } finally { - setBusy(false); + setOperation(null); } }} onCancel={() => {}} @@ -89,13 +139,22 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { <> {status.supported_branches.map((branch) => ( <PanelSectionRow key={branch}> - <Field + <ToggleField label={branch} description={ - status.installed_branches.includes(branch) - ? "Installed" + (status.owned_branches.includes(branch) ? " · plugin-owned" : "") - : "Not installed" + operation === `enable-${branch}` + ? "Installing..." + : operation === `disable-${branch}` + ? "Uninstalling..." + : status.installed_branches.includes(branch) + ? status.owned_branches.includes(branch) + ? "Installed · plugin-owned" + : "Installed · pre-existing (preserved)" + : "Not installed" } + checked={status.installed_branches.includes(branch)} + onChange={(enabled) => handleExtensionToggle(branch, enabled)} + disabled={operation !== null || status.ownership_uncertain} /> </PanelSectionRow> ))} @@ -107,10 +166,10 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { <PanelSectionRow> <ButtonItem layout="below" - disabled={busy || status.ownership_uncertain || status.owned_branches.length === 0} + disabled={operation !== null || status.ownership_uncertain || status.owned_branches.length === 0} onClick={confirmCleanup} > - {busy ? "Removing..." : "Remove plugin-installed extensions"} + {operation === "cleanup" ? "Removing..." : "Remove plugin-installed extensions"} </ButtonItem> </PanelSectionRow> </> diff --git a/src/components/index.ts b/src/components/index.ts index 7c3ee0a..6856e76 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; +export { ConfigFileTab } from "./ConfigFileTab"; export { SetupTab } from "./SetupTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index b59d592..c70d589 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -40,6 +40,23 @@ 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; + + // Steam's app-details API can report a Flatpak Target as just "flatpak" + // even when the shortcut's canonical VDF executable is /usr/bin/flatpak. + // Keep the stored original executable absolute so SetShortcutExe and the + // generated dispatcher agree on the same direct transport. + if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; + return candidates.map((candidate) => candidate?.trim()).find(Boolean); +} + const DEFAULT_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: true, @@ -151,7 +168,14 @@ export function useGameConfiguration() { throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); } const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = target.nonSteam ? (oldShortcutExe || current.target) : undefined; + const originalExecutable = target.nonSteam + ? selectShortcutExecutable( + target, + oldShortcutExe, + target.transport.kind === "flatpak" ? target.executable : undefined, + current.target, + ) + : undefined; const initialIntegration = target.nonSteam ? current.target === wrapperPath : hasWrapperLaunchIntegration(current.options, wrapperPath); @@ -170,7 +194,14 @@ export function useGameConfiguration() { const finalStateResult = await setWorkaroundState( target.appid, state, - target.nonSteam ? (integration.originalExecutable || originalExecutable || null) : null, + target.nonSteam + ? (selectShortcutExecutable( + target, + integration.originalExecutable, + originalExecutable, + target.transport.kind === "flatpak" ? target.executable : undefined, + ) || null) + : null, integration.commandTokenAdded, target.transport, ); @@ -184,7 +215,14 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - target.nonSteam ? (integration?.originalExecutable || originalExecutable) : undefined, + target.nonSteam + ? (selectShortcutExecutable( + target, + integration?.originalExecutable, + originalExecutable, + target.transport.kind === "flatpak" ? target.executable : undefined, + ) || undefined) + : undefined, integration?.commandTokenAdded ?? oldCommandTokenAdded, ); } catch (rollbackError) { @@ -264,6 +302,25 @@ export function useGameConfiguration() { 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; + + 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; + } + } + await load(); + }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); const repair = useCallback(async (appid: string): Promise<boolean> => { const target = targets.find((item) => item.appid === appid); if (!target) return false; @@ -306,5 +363,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, repair, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index c7413b0..e9e44e1 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -61,6 +61,18 @@ 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 integrationIsInstalled( steam: SteamLaunchOptionsSnapshot, nonSteam: boolean, @@ -101,7 +113,9 @@ async function adoptWorkaroundState( if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); } - const originalExecutable = nonSteam ? steam.target : null; + const originalExecutable = nonSteam + ? selectShortcutExecutable(transport, steam.target) + : null; const initial = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, @@ -120,7 +134,9 @@ async function adoptWorkaroundState( const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - nonSteam ? (integration.originalExecutable || originalExecutable) : null, + nonSteam + ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) + : null, integration.commandTokenAdded, transport, ); @@ -134,7 +150,9 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, - nonSteam ? (integration?.originalExecutable || originalExecutable || undefined) : undefined, + nonSteam + ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) + : undefined, integration?.commandTokenAdded ?? false, ); } catch { |
