summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 19:16:30 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 20:20:58 -0400
commitcc1e6f47dd9838b066822162a607d2859c043aff (patch)
tree532915c1e98c068ab8324d71a0dab160bb53de68 /src/components
parent102a4a0f0ce4af303a12e7f6f1a4454f4e572a17 (diff)
downloaddecky-lsfg-vk-cc1e6f47dd9838b066822162a607d2859c043aff.tar.gz
decky-lsfg-vk-cc1e6f47dd9838b066822162a607d2859c043aff.zip
refactor: unify flatpak targets with steam profiles
Diffstat (limited to 'src/components')
-rw-r--r--src/components/ConfigurationTab.tsx22
-rw-r--r--src/components/Content.tsx41
-rw-r--r--src/components/FlatpaksTab.tsx196
-rw-r--r--src/components/GameConfigurationControls.tsx3
-rw-r--r--src/components/GameConfigurationSelector.tsx101
-rw-r--r--src/components/NowPlayingTab.tsx93
-rw-r--r--src/components/SetupTab.tsx142
-rw-r--r--src/components/WorkaroundsSection.tsx6
-rw-r--r--src/components/index.ts2
9 files changed, 274 insertions, 332 deletions
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 62a971b..90c1a7b 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -14,7 +14,6 @@ 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>;
@@ -27,7 +26,6 @@ export function ConfigurationTab({
onSelect,
onConfigChange,
onEnable,
- onEnableAll,
onRepair,
onReset,
onResetAll,
@@ -86,7 +84,6 @@ export function ConfigurationTab({
onSelect(appid);
setDetailAppId(appid);
}}
- onEnableAll={onEnableAll}
onResetAll={onResetAll}
focusConfiguredToggle={focusConfiguredToggle}
onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
@@ -96,8 +93,13 @@ export function ConfigurationTab({
}
const profileLabel = selectedTarget?.name || "Game profile";
+ const profileTransport = selectedTarget
+ ? selectedTarget.transport.kind === "flatpak"
+ ? "Non-Steam · Flatpak"
+ : selectedTarget.nonSteam ? "Non-Steam" : "Steam"
+ : "Game";
const profileDescription = selectedTarget
- ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
+ ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
const handleProfileAction = async () => {
if (selectedTarget?.configured) {
@@ -151,6 +153,18 @@ export function ConfigurationTab({
</PanelSectionRow>
)}
</PanelSection>
+ {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && (
+ <PanelSection>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={() => void onRepair(selectedTarget.appid)}
+ >
+ Repair Flatpak support
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
{selectedTarget?.configured && (
<GameConfigurationControls
config={config}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 59dc514..ff2376b 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,22 +1,18 @@
import { Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
-import { FaFileAlt, FaGamepad, FaLayerGroup, FaList, FaTools } from "react-icons/fa";
+import { 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 { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
-import { FlatpaksTab } from "./FlatpaksTab";
import { NowPlayingTab } from "./NowPlayingTab";
import { SetupTab } from "./SetupTab";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
- configuration: <FaList size={18} />,
- flatpak: <FaLayerGroup size={18} />,
- configFile: <FaFileAlt size={18} />,
+ games: <FaList size={18} />,
setup: <FaTools size={18} />,
};
@@ -38,7 +34,6 @@ export function Content() {
setSelectedAppId,
save,
enable,
- enableAll,
repair,
resetSelected,
resetAll,
@@ -52,25 +47,25 @@ export function Content() {
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
- const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null);
+ const previousRunningAppId = useRef<string | null>(null);
useEffect(() => {
if (!setupComplete) {
setTab("Setup");
return;
}
- setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current);
- }, [runningGame?.configured, setupComplete]);
+ setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current);
+ }, [runningGame?.appid, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
- const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null;
- const previous = previousRunningState.current;
- previousRunningState.current = current;
- if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) {
- setTab(current.configured ? "NowPlaying" : "Configuration");
- } else if (!current && previous) {
- setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab);
+ const appid = runningGame?.appid || null;
+ const previous = previousRunningAppId.current;
+ previousRunningAppId.current = appid;
+ if (appid && appid !== previous) {
+ setTab("NowPlaying");
+ } else if (!appid && previous) {
+ setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab);
}
}, [runningGame?.appid, runningGame?.configured, setupComplete]);
@@ -105,12 +100,13 @@ export function Content() {
isUninstalling={isUninstalling}
onInstall={onInstall}
onUninstall={onUninstall}
+ flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")}
/>
);
const tabs = setupComplete
? [
- ...(runningGame?.configured ? [{
+ ...(runningGame ? [{
id: "NowPlaying",
title: tabIcons.nowPlaying,
content: (
@@ -118,12 +114,14 @@ export function Content() {
game={runningGame}
config={config}
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)}
+ onEnable={enable}
+ onRepair={repair}
/>
),
}] : []),
{
- id: "Configuration",
- title: tabIcons.configuration,
+ id: "Games",
+ title: tabIcons.games,
content: (
<ConfigurationTab
config={config}
@@ -132,15 +130,12 @@ export function Content() {
onSelect={setSelectedAppId}
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)}
onEnable={enable}
- onEnableAll={enableAll}
onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
/>
),
},
- { id: "Flatpak", title: tabIcons.flatpak, content: <FlatpaksTab /> },
- { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, // comment out for prod
{ id: "Setup", title: tabIcons.setup, content: setupContent },
]
: [
diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx
deleted file mode 100644
index 8aab940..0000000
--- a/src/components/FlatpaksTab.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useEffect, useState } from "react";
-import {
- ConfirmModal,
- Field,
- PanelSection,
- PanelSectionRow,
- ToggleField,
- showModal,
-} from "@decky/ui";
-import {
- checkFlatpakExtensionStatus,
- FlatpakApp,
- FlatpakAppInfo,
- FlatpakExtensionStatus,
- getFlatpakApps,
- installFlatpakExtension,
- removeFlatpakAppOverride,
- setFlatpakAppOverride,
- uninstallFlatpakExtension,
-} from "../api/lsfgApi";
-import { showErrorToast } from "../utils/toastUtils";
-import t from "../i18n/i18n";
-
-const runtimeVersions = [
- { version: "23.08", key: "installed_23_08" },
- { version: "24.08", key: "installed_24_08" },
- { version: "25.08", key: "installed_25_08" },
-] as const;
-
-interface RuntimeRowProps {
- version: string;
- installed: boolean;
- busy: boolean;
- onAction: () => void;
-}
-
-function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) {
- return (
- <PanelSectionRow>
- <ToggleField
- label={`Runtime ${version}`}
- description={busy ? "Updating..." : installed ? t("FLATPAK_INSTALLED", "Installed") : t("FLATPAK_NOT_INSTALLED", "Not installed")}
- checked={installed}
- onChange={() => onAction()}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-interface AppRowProps {
- app: FlatpakApp;
- runtimeReady: boolean;
- busy: boolean;
- onToggle: () => void;
-}
-
-function AppRow({ app, runtimeReady, busy, onToggle }: AppRowProps) {
- const configured = app.has_filesystem_override && app.has_env_override;
- const partial = app.has_filesystem_override || app.has_env_override;
- const status = configured
- ? runtimeReady
- ? t("FLATPAK_STATUS_READY", "Ready")
- : t("FLATPAK_STATUS_RUNTIME_MISSING", "Runtime missing")
- : partial
- ? t("FLATPAK_STATUS_PARTIAL", "Partial")
- : t("FLATPAK_STATUS_NOT_ENABLED", "Not enabled");
-
- return (
- <PanelSectionRow>
- <ToggleField
- label={app.app_name || app.app_id}
- description={`${app.app_id} - ${status}`}
- checked={configured}
- onChange={onToggle}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-export function FlatpaksTab() {
- const [extensionStatus, setExtensionStatus] = useState<FlatpakExtensionStatus | null>(null);
- const [apps, setApps] = useState<FlatpakAppInfo | null>(null);
- const [loading, setLoading] = useState(true);
- const [operation, setOperation] = useState<string | null>(null);
- const [error, setError] = useState<string | null>(null);
- const runtimeReady = extensionStatus?.success === true
- && runtimeVersions.some(({ key }) => extensionStatus[key]);
-
- const load = async () => {
- setLoading(true);
- try {
- const [nextStatus, nextApps] = await Promise.all([
- checkFlatpakExtensionStatus(),
- getFlatpakApps(),
- ]);
- setExtensionStatus(nextStatus);
- setApps(nextApps);
- } catch (loadError) {
- setError(String(loadError));
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- void load();
- }, []);
-
- const runExtensionOperation = async (version: string, installed: boolean) => {
- const action = installed ? "uninstall" : "install";
- setOperation(`${action}-${version}`);
- setError(null);
- try {
- const result = installed
- ? await uninstallFlatpakExtension(version)
- : await installFlatpakExtension(version);
- if (!result.success) throw new Error(result.error || result.message);
- setExtensionStatus(await checkFlatpakExtensionStatus());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak operation failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- const confirmExtensionOperation = (version: string, installed: boolean) => {
- if (!installed) {
- void runExtensionOperation(version, false);
- return;
- }
- showModal(
- <ConfirmModal
- strTitle={t("FLATPAK_UNINSTALL_TITLE", "Uninstall Runtime Extension")}
- strDescription={`${t("FLATPAK_UNINSTALL_CONFIRM_PREFIX", "Are you sure you want to uninstall the")} ${version} ${t("FLATPAK_UNINSTALL_CONFIRM_SUFFIX", "runtime extension?")}`}
- onOK={() => void runExtensionOperation(version, true)}
- onCancel={() => {}}
- />,
- );
- };
-
- const toggleApp = async (app: FlatpakApp) => {
- const configured = app.has_filesystem_override && app.has_env_override;
- setOperation(`app-${app.app_id}`);
- setError(null);
- try {
- const result = configured
- ? await removeFlatpakAppOverride(app.app_id)
- : await setFlatpakAppOverride(app.app_id);
- if (!result.success) throw new Error(result.error || result.message);
- setApps(await getFlatpakApps());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak override failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- if (loading) {
- return <PanelSection title="Flatpak Runtimes" spinner />;
- }
-
- return (
- <>
- <PanelSection title="Flatpak Runtimes">
- {error && <PanelSectionRow><Field label={t("FLATPAK_OPERATION_ERROR", "Operation failed")} description={error} /></PanelSectionRow>}
- {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => (
- <RuntimeRow
- key={version}
- version={version}
- installed={extensionStatus[key]}
- busy={operation === `${extensionStatus[key] ? "uninstall" : "install"}-${version}`}
- onAction={() => confirmExtensionOperation(version, extensionStatus[key])}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={extensionStatus?.error || error || t("FLATPAK_ERROR_STATUS", "Failed to check extension status")} /></PanelSectionRow>}
- </PanelSection>
-
- <PanelSection title="Applications">
- {apps?.success ? apps.apps.length ? apps.apps.map((app) => (
- <AppRow
- key={app.app_id}
- app={app}
- runtimeReady={runtimeReady}
- busy={operation === `app-${app.app_id}`}
- onToggle={() => void toggleApp(app)}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_NO_APPS", "No Flatpak Apps Found")} description={t("FLATPAK_NO_APPS_DESC", "No Flatpak applications are currently installed")} /></PanelSectionRow> : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={apps?.error || error || t("FLATPAK_ERROR_APPS", "Failed to load Flatpak applications")} /></PanelSectionRow>}
- </PanelSection>
- </>
- );
-}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 7025f78..6bea2e0 100644
--- a/src/components/GameConfigurationControls.tsx
+++ b/src/components/GameConfigurationControls.tsx
@@ -10,7 +10,7 @@ interface Props {
autoFocusFpsMultiplier?: boolean;
onFpsMultiplierFocused?: () => void;
showWorkarounds?: boolean;
- workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">;
+ workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">;
onRepairWorkaround?: () => Promise<boolean>;
}
@@ -36,6 +36,7 @@ export function GameConfigurationControls({
<WorkaroundsSection
appId={workaroundTarget.appid}
nonSteam={workaroundTarget.nonSteam}
+ transport={workaroundTarget.transport}
onRepair={onRepairWorkaround}
/>
)}
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index df0c3e9..2a92c67 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -7,14 +7,13 @@ interface Props {
targets: GameTarget[];
runningGame: GameTarget | null;
onSelect: (appid: string) => void;
- onEnableAll: () => Promise<void>;
onResetAll: () => Promise<void>;
focusConfiguredToggle?: boolean;
onConfiguredToggleFocused?: () => void;
}
-const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3";
-const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v2";
+const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4";
+const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3";
function usePersistentCollapsed(key: string) {
const [collapsed, setCollapsed] = useState(() => {
@@ -36,6 +35,11 @@ function usePersistentCollapsed(key: string) {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
+function targetDescription(game: GameTarget): string {
+ if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
function GameGroup({
title,
games,
@@ -56,7 +60,7 @@ function GameGroup({
return (
<>
<PanelSectionRow>
- <Field label={`${title} (${games.length})`} bottomSeparator="none" />
+ <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
</PanelSectionRow>
<PanelSectionRow>
<div
@@ -69,11 +73,7 @@ function GameGroup({
bottomSeparator={collapsed ? "standard" : "none"}
onClick={onToggle}
>
- {collapsed ? (
- <RiArrowDownSFill />
- ) : (
- <RiArrowUpSFill />
- )}
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
</ButtonItem>
</div>
</PanelSectionRow>
@@ -81,7 +81,7 @@ function GameGroup({
<PanelSectionRow key={game.appid}>
<Field
label={game.name}
- description={game.nonSteam ? "Non-Steam" : "Steam"}
+ description={targetDescription(game)}
onActivate={() => onSelect(game.appid)}
highlightOnFocus
/>
@@ -95,7 +95,6 @@ export function GameConfigurationSelector({
targets,
runningGame,
onSelect,
- onEnableAll,
onResetAll,
focusConfiguredToggle = false,
onConfiguredToggleFocused,
@@ -105,26 +104,20 @@ export function GameConfigurationSelector({
if (b.appid === runningGame?.appid) return 1;
return a.name.localeCompare(b.name);
});
- const configuredGames = sortGames(targets.filter((game) => game.configured));
+ const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
- const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam);
- const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam);
- const availableSteamGames = availableGames.filter((game) => !game.nonSteam);
- const availableNonSteamGames = availableGames.filter((game) => game.nonSteam);
- const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY);
- const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
- const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`);
- const configuredToggleRef = useRef<HTMLDivElement>(null);
+ const enabledToggleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!focusConfiguredToggle) return;
const frame = requestAnimationFrame(() => {
- configuredToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus();
+ enabledToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus();
onConfiguredToggleFocused?.();
});
return () => cancelAnimationFrame(frame);
- }, [configuredGames.length, focusConfiguredToggle, onConfiguredToggleFocused]);
+ }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]);
const confirmResetAll = () => {
showModal(
@@ -137,79 +130,29 @@ 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."
- 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;
- 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" />
</PanelSectionRow>
)}
<GameGroup
- title="LSFG-VK Enabled"
- games={configuredSteamGames}
- collapsed={configuredCollapsed}
- onToggle={toggleConfigured}
+ title="Enabled"
+ games={enabledGames}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
onSelect={onSelect}
- toggleRef={configuredToggleRef}
+ toggleRef={enabledToggleRef}
/>
<GameGroup
- title="LSFG-VK Enabled (Non-Steam)"
- games={configuredNonSteamGames}
- collapsed={configuredNonSteamCollapsed}
- onToggle={toggleConfiguredNonSteam}
- onSelect={onSelect}
- />
- <GameGroup
- title="Available games"
- games={availableSteamGames}
+ title="Available"
+ games={availableGames}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
/>
- <GameGroup
- title="Available games (Non-Steam)"
- games={availableNonSteamGames}
- collapsed={availableNonSteamCollapsed}
- onToggle={toggleAvailableNonSteam}
- 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/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 188c56a..5bce5c4 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,4 +1,5 @@
-import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { useState } from "react";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
@@ -6,20 +7,96 @@ import { GameConfigurationControls } from "./GameConfigurationControls";
interface Props {
game: GameTarget;
config: ConfigurationData;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onConfigChange: (
+ fieldName: keyof ConfigurationData,
+ value: boolean | number | string | string[],
+ ) => Promise<void>;
+ onEnable: (appid: string) => Promise<boolean>;
+ onRepair: (appid: string) => Promise<boolean>;
}
-export function NowPlayingTab({ game, config, onConfigChange }: Props) {
+function targetDescription(game: GameTarget): string {
+ if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
+export function NowPlayingTab({
+ game,
+ config,
+ onConfigChange,
+ onEnable,
+ onRepair,
+}: Props) {
+ const [busy, setBusy] = useState(false);
+ const supportNeedsRepair =
+ game.configured &&
+ game.transport.kind === "flatpak" &&
+ game.flatpakSupport?.support_status !== "ready";
+
+ const handleEnable = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await onEnable(game.appid);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleRepair = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await onRepair(game.appid);
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
<Focusable>
- <PanelSection>
+ <PanelSection title="Now Playing">
<PanelSectionRow>
- <Field
- label={game.name}
- />
+ <Field label={game.name} description={targetDescription(game)} />
</PanelSectionRow>
</PanelSection>
- <GameConfigurationControls config={config} onConfigChange={onConfigChange} showWorkarounds={false} />
+ {!game.configured && (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field
+ label="LSFG-VK is available"
+ description="This target is not enabled yet. Create its AppID profile before the next launch."
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void handleEnable()}>
+ {busy ? "Enabling..." : "Enable LSFG-VK"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
+ {game.configured && supportNeedsRepair && (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field
+ label="Flatpak support needs repair"
+ description={game.flatpakSupport?.error || "The target runtime extension is not ready."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}>
+ {busy ? "Repairing..." : "Repair Flatpak support"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
+ {game.configured && (
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={onConfigChange}
+ showWorkarounds={false}
+ />
+ )}
</Focusable>
);
}
diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx
index 98d6e78..9c854e1 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SetupTab.tsx
@@ -1,5 +1,11 @@
-import { PanelSection } from "@decky/ui";
-import type { SteamBranchStatus } from "../api/lsfgApi";
+import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, showModal } from "@decky/ui";
+import { useEffect, useState } from "react";
+import {
+ getFlatpakSupportStatus,
+ removePluginOwnedFlatpakExtensions,
+ type FlatpakExtensionStatus,
+ type SteamBranchStatus,
+} from "../api/lsfgApi";
import { InstallationButton } from "./InstallationButton";
import { StatusDisplay } from "./StatusDisplay";
@@ -13,6 +19,104 @@ interface SetupTabProps {
isUninstalling: boolean;
onInstall: () => void;
onUninstall: () => void;
+ flatpakRelevant: boolean;
+}
+
+function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) {
+ const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null);
+ const [advanced, setAdvanced] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const refresh = async () => {
+ try {
+ setStatus(await getFlatpakSupportStatus());
+ } catch (error) {
+ setStatus({
+ success: false,
+ message: "",
+ error: String(error),
+ available: false,
+ extension_id: "",
+ supported_branches: [],
+ installed_branches: [],
+ owned_branches: [],
+ ownership_uncertain: false,
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (relevant) void refresh();
+ }, [relevant]);
+
+ if (!relevant || !status?.available) return null;
+
+ const confirmCleanup = () => {
+ showModal(
+ <ConfirmModal
+ strTitle="Remove plugin-installed Flatpak extensions?"
+ strDescription="Shared runtime branches recorded as installed by this plugin will be removed. Existing unowned branches are preserved."
+ strOKButtonText="Remove extensions"
+ strCancelButtonText="Cancel"
+ onOK={async () => {
+ setBusy(true);
+ try {
+ await removePluginOwnedFlatpakExtensions();
+ await refresh();
+ } finally {
+ setBusy(false);
+ }
+ }}
+ onCancel={() => {}}
+ />,
+ );
+ };
+
+ return (
+ <PanelSection title="Flatpak support">
+ <PanelSectionRow>
+ <Field
+ label="Runtime extension support"
+ description={status.message || "Flatpak is available for classified targets."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => setAdvanced((value) => !value)}>
+ {advanced ? "Hide runtime details" : "Show runtime details"}
+ </ButtonItem>
+ </PanelSectionRow>
+ {advanced && (
+ <>
+ {status.supported_branches.map((branch) => (
+ <PanelSectionRow key={branch}>
+ <Field
+ label={branch}
+ description={
+ status.installed_branches.includes(branch)
+ ? "Installed" + (status.owned_branches.includes(branch) ? " · plugin-owned" : "")
+ : "Not installed"
+ }
+ />
+ </PanelSectionRow>
+ ))}
+ {status.ownership_uncertain && (
+ <PanelSectionRow>
+ <Field label="Ownership metadata is uncertain" description="Cleanup is disabled until the metadata is repaired." />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy || status.ownership_uncertain || status.owned_branches.length === 0}
+ onClick={confirmCleanup}
+ >
+ {busy ? "Removing..." : "Remove plugin-installed extensions"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </>
+ )}
+ </PanelSection>
+ );
}
export function SetupTab({
@@ -25,22 +129,26 @@ export function SetupTab({
isUninstalling,
onInstall,
onUninstall,
+ flatpakRelevant,
}: SetupTabProps) {
return (
- <PanelSection title="Setup">
- <StatusDisplay
- installationStatus={installationStatus}
- losslessScalingInstalled={losslessScalingInstalled}
- losslessScalingStatus={losslessScalingStatus}
- steamBranchStatus={steamBranchStatus}
- />
- <InstallationButton
- isInstalled={isInstalled}
- isInstalling={isInstalling}
- isUninstalling={isUninstalling}
- onInstall={onInstall}
- onUninstall={onUninstall}
- />
- </PanelSection>
+ <>
+ <PanelSection title="Setup">
+ <StatusDisplay
+ installationStatus={installationStatus}
+ losslessScalingInstalled={losslessScalingInstalled}
+ losslessScalingStatus={losslessScalingStatus}
+ steamBranchStatus={steamBranchStatus}
+ />
+ <InstallationButton
+ isInstalled={isInstalled}
+ isInstalling={isInstalling}
+ isUninstalling={isUninstalling}
+ onInstall={onInstall}
+ onUninstall={onUninstall}
+ />
+ </PanelSection>
+ <FlatpakSupportDiagnostics relevant={flatpakRelevant} />
+ </>
);
}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index dbdd6a3..5587392 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -1,6 +1,7 @@
import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui";
import { useEffect, useState } from "react";
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import type { TargetTransport } from "../api/lsfgApi";
import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds";
import t from "../i18n/i18n";
import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
@@ -8,6 +9,7 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
interface WorkaroundsSectionProps {
appId: string;
nonSteam: boolean;
+ transport: TargetTransport;
onRepair?: () => Promise<boolean>;
}
@@ -79,9 +81,9 @@ function usePersistentCollapsed() {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) {
+export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) {
const [collapsed, toggleCollapsed] = usePersistentCollapsed();
- const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam);
+ const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport);
const [repairing, setRepairing] = useState(false);
const state = snapshot?.state;
const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true;
diff --git a/src/components/index.ts b/src/components/index.ts
index 37a8edb..7c3ee0a 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -5,8 +5,6 @@ export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
export { ConfigurationTab } from "./ConfigurationTab";
export { SetupTab } from "./SetupTab";
-export { ConfigFileTab } from "./ConfigFileTab";
-export { FlatpaksTab } from "./FlatpaksTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
export { GameConfigurationControls } from "./GameConfigurationControls";
export { NowPlayingTab } from "./NowPlayingTab";