summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 20:45:20 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 20:45:20 -0400
commitfb4d053213bdbda271a54b517a11a89c4780f80a (patch)
treecf63819208ceb75c49c0ad08de8edd1b2b81082c /src
parentcc1e6f47dd9838b066822162a607d2859c043aff (diff)
downloaddecky-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.ts15
-rw-r--r--src/components/ConfigurationTab.tsx3
-rw-r--r--src/components/Content.tsx11
-rw-r--r--src/components/GameConfigurationSelector.tsx40
-rw-r--r--src/components/SetupTab.tsx81
-rw-r--r--src/components/index.ts1
-rw-r--r--src/hooks/useGameConfiguration.ts65
-rw-r--r--src/hooks/usePerAppWorkarounds.ts24
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 {