summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-11 13:47:52 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-11 13:47:52 -0400
commitf3074fbe1427411dc3b5597d2e87918383299e3f (patch)
treebf96f3d6f3942327a8a6541b8a7d7a18906a748e /src
parentd1990a2b630f7161342a9c854bce0fcf7a967a8d (diff)
downloaddecky-lsfg-vk-f3074fbe1427411dc3b5597d2e87918383299e3f.tar.gz
decky-lsfg-vk-f3074fbe1427411dc3b5597d2e87918383299e3f.zip
feat: non steam tab, faster bulk actions
Diffstat (limited to 'src')
-rw-r--r--src/api/lsfgApi.ts1
-rw-r--r--src/components/ConfigFileTab.tsx4
-rw-r--r--src/components/ConfigurationTab.tsx56
-rw-r--r--src/components/Content.tsx98
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx4
-rw-r--r--src/components/GameConfigurationSelector.tsx47
-rw-r--r--src/components/NowPlayingTab.tsx5
-rw-r--r--src/components/SettingsTab.tsx (renamed from src/components/SetupTab.tsx)6
-rw-r--r--src/components/index.ts2
-rw-r--r--src/hooks/useGameConfiguration.ts139
-rw-r--r--src/utils/gameTargets.ts75
-rw-r--r--src/utils/nowPlaying.ts18
12 files changed, 332 insertions, 123 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index e050f62..883f56e 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -164,6 +164,7 @@ export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"
export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games");
export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config");
export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config");
+export const resetGameConfigs = callable<[string[]], GameConfigsResult>("reset_game_configs");
export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs");
export const updateGlobalConfig = callable<[GlobalConfig], GlobalConfigResult>("update_global_config");
export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state");
diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx
index 07408d7..fd6d716 100644
--- a/src/components/ConfigFileTab.tsx
+++ b/src/components/ConfigFileTab.tsx
@@ -80,7 +80,7 @@ export function ConfigFileTab() {
if (!result) {
return (
- <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
+ <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}>
<PanelSectionRow>
<Spinner />
</PanelSectionRow>
@@ -108,7 +108,7 @@ export function ConfigFileTab() {
}
`}
</style>
- <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
+ <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}>
{result.error && (
<PanelSectionRow>
<Field label="Error" description={result.error} />
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 37a7867..37555e0 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,26 +1,36 @@
-import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui";
+import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
-import { GameTarget } from "../hooks/useGameConfiguration";
+import type { GameTarget, KnownGameSource } from "../utils/gameTargets";
+import { sourceLabel } from "../utils/gameTargets";
import { GameConfigurationControls } from "./GameConfigurationControls";
import { GameConfigurationSelector } from "./GameConfigurationSelector";
import { ProfileDetails } from "./ProfileDetails";
interface ConfigurationTabProps {
+ title: string;
+ source: KnownGameSource;
config: ConfigurationData;
targets: GameTarget[];
runningGame: GameTarget | null;
onSelect: (appid: string) => void;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onConfigChange: (
+ fieldName: keyof ConfigurationData,
+ value: boolean | number | string | string[],
+ cleanupLaunchOptions?: boolean,
+ ) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
- onEnableAll: () => Promise<void>;
+ onEnableAll: (source: KnownGameSource) => Promise<void>;
+ bulkOperationBusy: boolean;
onRepair: (appid: string) => Promise<boolean>;
onReset: () => Promise<void>;
- onResetAll: () => Promise<void>;
+ onResetAll: (source: KnownGameSource) => Promise<void>;
}
export function ConfigurationTab({
+ title,
+ source,
config,
targets,
runningGame,
@@ -28,6 +38,7 @@ export function ConfigurationTab({
onConfigChange,
onEnable,
onEnableAll,
+ bulkOperationBusy,
onRepair,
onReset,
onResetAll,
@@ -64,10 +75,12 @@ export function ConfigurationTab({
if (detailAppId === null) {
return (
<>
- <PanelSection title="Games">
+ <PanelSection title={title}>
<GameConfigurationSelector
targets={targets}
runningGame={runningGame}
+ source={source}
+ bulkOperationBusy={bulkOperationBusy}
onSelect={(appid) => {
setFocusConfiguredToggle(false);
setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
@@ -85,9 +98,9 @@ export function ConfigurationTab({
}
const profileLabel = selectedTarget?.name || "Game profile";
- const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam";
+ const profileTransport = selectedTarget ? sourceLabel(selectedTarget.source) : sourceLabel(source);
const profileDescription = selectedTarget
- ? `${profileTransport} · 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"}${selectedTarget.source === "unknown" ? " · Bulk actions exclude this profile" : ""}`
: "Game is no longer available";
const enableProfile = async (appid: string, quitRunningGame = false) => {
if (!(await onEnable(appid))) return;
@@ -101,8 +114,8 @@ export function ConfigurationTab({
closeDetails();
} else if (detailAppId) {
const isRunningUnconfigured = runningGame?.appid === detailAppId
- && runningGame.nonSteam === false
- && selectedTarget?.nonSteam === false
+ && runningGame.source === "steam"
+ && selectedTarget?.source === "steam"
&& !runningGame.configured;
if (isRunningUnconfigured) {
showModal(
@@ -128,7 +141,7 @@ export function ConfigurationTab({
<div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<Focusable noFocusRing style={{ flex: "none" }}>
<DialogButton
- aria-label="Back to games"
+ aria-label={`Back to ${title}`}
onClick={closeDetails}
style={{
width: "48px",
@@ -156,21 +169,28 @@ export function ConfigurationTab({
<PanelSection>
{!selectedTarget?.configured && selectedTarget && (
<PanelSectionRow>
- <Focusable ref={enableRef} noFocusRing>
- <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem>
- </Focusable>
+ {selectedTarget.source === "unknown" ? (
+ <Field
+ label="Target source unavailable"
+ description="Refresh Steam and try again before enabling this target."
+ />
+ ) : (
+ <Focusable ref={enableRef} noFocusRing>
+ <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem>
+ </Focusable>
+ )}
</PanelSectionRow>
)}
</PanelSection>
{selectedTarget?.configured && (
<GameConfigurationControls
config={config}
- onConfigChange={onConfigChange}
+ onConfigChange={(field, value) => onConfigChange(field, value, selectedTarget?.source !== "unknown")}
autoFocusFpsMultiplier={focusFpsMultiplier}
onFpsMultiplierFocused={clearFpsFocusRequest}
- showWorkarounds
- workaroundTarget={selectedTarget || undefined}
- onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined}
+ showWorkarounds={selectedTarget.source !== "unknown"}
+ workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined}
+ onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined}
/>
)}
{selectedTarget?.configured && (
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 3f25320..470db95 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,28 +1,37 @@
import { Tabs } from "@decky/ui";
import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react";
-import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
+import { FaCube, FaExternalLinkAlt, FaFileAlt, FaGamepad, FaSteam, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallation } from "../hooks/useLsfgHooks";
import { tabStyles } from "../styles";
-import { resolveNowPlayingTarget } from "../utils/nowPlaying";
+import { targetsForSource } from "../utils/gameTargets";
+import { resolveNowPlayingTarget, type NowPlayingTarget } from "../utils/nowPlaying";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab";
import { FlatpakTab } from "./FlatpakTab";
import { NowPlayingTab } from "./NowPlayingTab";
-import { SetupTab } from "./SetupTab";
+import { SettingsTab } from "./SettingsTab";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
- games: <FaList size={18} />,
+ steam: <FaSteam size={18} />,
+ nonSteam: <FaExternalLinkAlt size={18} />,
flatpak: <FaCube size={18} />,
configFile: <FaFileAlt size={18} />,
- setup: <FaTools size={18} />,
+ settings: <FaTools size={18} />,
};
const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1";
+type GameTabId = "Steam" | "NonSteam" | "Flatpak";
+
+function tabForNowPlaying(target: NowPlayingTarget | null): GameTabId {
+ if (!target) return "Steam";
+ if (target.kind === "flatpak") return target.launcher?.source === "nonSteam" ? "NonSteam" : "Flatpak";
+ return target.game.source === "nonSteam" ? "NonSteam" : "Steam";
+}
function usePersistentBoolean(key: string, defaultValue: boolean) {
const [value, setValue] = useState(() => {
@@ -56,6 +65,7 @@ export function Content() {
updateGlobal,
enable,
enableAll,
+ bulkOperationBusy,
repair,
resetSelected,
resetAll,
@@ -80,34 +90,40 @@ export function Content() {
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
const flatpak = useFlatpakConfiguration(setupComplete);
- const [tab, setTab] = useState("Setup");
+ const [tab, setTab] = useState("Settings");
const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false);
const [contentFocused, setContentFocused] = useState(false);
const previousRunningWorkload = useRef<string | null>(null);
+ const previousNowPlayingTab = useRef<GameTabId>("Steam");
const runningFlatpak = flatpak.runningApp;
const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak);
const hasNowPlaying = Boolean(nowPlayingTarget);
const runningWorkload = nowPlayingTarget
? nowPlayingTarget.kind === "flatpak"
? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}`
- : `steam:${nowPlayingTarget.game.appid}`
+ : `${nowPlayingTarget.game.source}:${nowPlayingTarget.game.appid}`
: null;
+ const steamTargets = targetsForSource(targets, "steam");
+ const nonSteamTargets = targetsForSource(targets, "nonSteam");
useEffect(() => {
if (!setupComplete) {
- setTab("Setup");
+ setTab("Settings");
return;
}
- setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current);
+ setTab((current) => current === "Settings" ? (hasNowPlaying ? "NowPlaying" : "Steam") : current);
}, [hasNowPlaying, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
const previous = previousRunningWorkload.current;
previousRunningWorkload.current = runningWorkload;
- if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying");
+ if (runningWorkload && runningWorkload !== previous) {
+ previousNowPlayingTab.current = tabForNowPlaying(nowPlayingTarget);
+ setTab("NowPlaying");
+ }
else if (!runningWorkload && previous) {
- setTab((current) => current === "NowPlaying" ? "Games" : current);
+ setTab((current) => current === "NowPlaying" ? previousNowPlayingTab.current : current);
}
}, [runningWorkload, setupComplete]);
@@ -119,8 +135,8 @@ export function Content() {
}, [isInstalled, reload, flatpak.reload]);
useEffect(() => {
- if (!showDebugTab && tab === "ConfigFile") setTab("Games");
- }, [showDebugTab, tab]);
+ if (!showDebugTab && tab === "ConfigFile") setTab(setupComplete ? "Steam" : "Settings");
+ }, [setupComplete, showDebugTab, tab]);
const handleConfigChange = async (
fieldName: keyof ConfigurationData,
@@ -130,8 +146,8 @@ export function Content() {
await save({ ...config, [fieldName]: value }, cleanupLaunchOptions);
};
- const setup = (
- <SetupTab
+ const settings = (
+ <SettingsTab
isInstalled={isInstalled}
installationStatus={installationStatus}
losslessScalingInstalled={losslessScalingInstalled}
@@ -152,7 +168,13 @@ export function Content() {
<div className="lsfg-vk-tab-content">{content}</div>
);
- const nowPlaying = nowPlayingTarget?.kind === "steam" ? (
+ const nowPlaying = nowPlayingTarget?.kind === "flatpak" ? (
+ <FlatpakNowPlayingTab
+ app={nowPlayingTarget.app}
+ launcher={nowPlayingTarget.launcher}
+ onConfigChange={flatpak.updateConfig}
+ />
+ ) : nowPlayingTarget ? (
<NowPlayingTab
game={nowPlayingTarget.game}
config={runningConfig}
@@ -160,29 +182,26 @@ export function Content() {
await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true);
}}
/>
- ) : nowPlayingTarget?.kind === "flatpak" ? (
- <FlatpakNowPlayingTab
- app={nowPlayingTarget.app}
- launcher={nowPlayingTarget.launcher}
- onConfigChange={flatpak.updateConfig}
- />
) : null;
const tabs = setupComplete
? [
...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []),
{
- id: "Games",
- title: tabIcons.games,
+ id: "Steam",
+ title: tabIcons.steam,
content: tabContent(
<ConfigurationTab
+ title="Steam games"
+ source="steam"
config={config}
- targets={targets}
+ targets={steamTargets}
runningGame={runningGame}
onSelect={setSelectedAppId}
- onConfigChange={(field, value) => handleConfigChange(field, value, true)}
+ onConfigChange={handleConfigChange}
onEnable={enable}
onEnableAll={enableAll}
+ bulkOperationBusy={bulkOperationBusy}
onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
@@ -190,6 +209,27 @@ export function Content() {
),
},
{
+ id: "NonSteam",
+ title: tabIcons.nonSteam,
+ content: tabContent(
+ <ConfigurationTab
+ title="Non-Steam games"
+ source="nonSteam"
+ config={config}
+ targets={nonSteamTargets}
+ runningGame={runningGame}
+ onSelect={setSelectedAppId}
+ onConfigChange={handleConfigChange}
+ onEnable={enable}
+ onEnableAll={enableAll}
+ bulkOperationBusy={bulkOperationBusy}
+ onRepair={repair}
+ onReset={resetSelected}
+ onResetAll={resetAll}
+ />
+ ),
+ },
+ {
id: "Flatpak",
title: tabIcons.flatpak,
content: tabContent(
@@ -209,12 +249,12 @@ export function Content() {
),
},
...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []),
- { id: "Setup", title: tabIcons.setup, content: tabContent(setup) },
+ { id: "Settings", title: tabIcons.settings, content: tabContent(settings) },
]
- : [{ id: "Setup", title: tabIcons.setup, content: tabContent(setup) }];
+ : [{ id: "Settings", title: tabIcons.settings, content: tabContent(settings) }];
const availableTabIds = new Set(tabs.map(({ id }) => id));
- const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup";
+ const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Steam" : "Settings";
const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => {
const focusedElement = event.target as HTMLElement | null;
setContentFocused(!focusedElement?.closest?.('[role="tab"]'));
diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx
index 89f7f49..449e4b5 100644
--- a/src/components/FlatpakNowPlayingTab.tsx
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -1,6 +1,6 @@
import { Focusable } from "@decky/ui";
import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi";
-import type { GameTarget } from "../hooks/useGameConfiguration";
+import type { GameTarget } from "../utils/gameTargets";
import { ConfigurationSection } from "./ConfigurationSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
import { NowPlayingSummary } from "./NowPlayingSummary";
@@ -25,7 +25,7 @@ export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) {
<NowPlayingSummary
title={launcher?.name || app.app_name}
details={[
- launcher ? (launcher.nonSteam ? "Steam shortcut" : "Steam") : "Flatpak",
+ launcher ? (launcher.source === "nonSteam" ? "Steam shortcut" : "Steam") : "Flatpak",
launcher && launcher.name !== app.app_name ? `Running in ${app.app_name}` : null,
launcher ? "Flatpak" : null,
`Controls: ${app.app_name} profile`,
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 2c683ae..ad3537d 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,14 +1,17 @@
import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui";
import { useEffect, useRef } from "react";
-import { GameTarget } from "../hooks/useGameConfiguration";
+import type { GameTarget, KnownGameSource } from "../utils/gameTargets";
+import { sourceLabel } from "../utils/gameTargets";
import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup";
interface Props {
targets: GameTarget[];
runningGame: GameTarget | null;
+ source: KnownGameSource;
+ bulkOperationBusy: boolean;
onSelect: (appid: string) => void;
- onEnableAll: () => Promise<void>;
- onResetAll: () => Promise<void>;
+ onEnableAll: (source: KnownGameSource) => Promise<void>;
+ onResetAll: (source: KnownGameSource) => Promise<void>;
focusConfiguredToggle?: boolean;
onConfiguredToggleFocused?: () => void;
}
@@ -17,12 +20,16 @@ const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4";
const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3";
function targetDescription(game: GameTarget): string {
- return game.nonSteam ? "Non-Steam" : "Steam";
+ return game.source === "unknown"
+ ? "Unknown source · excluded from bulk actions"
+ : sourceLabel(game.source);
}
export function GameConfigurationSelector({
targets,
runningGame,
+ source,
+ bulkOperationBusy,
onSelect,
onEnableAll,
onResetAll,
@@ -36,13 +43,19 @@ export function GameConfigurationSelector({
});
const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
+ const enableableGames = availableGames.filter((game) => game.source === source);
+ const removableGames = enabledGames.filter((game) => game.source === source);
+ const sourceName = source === "nonSteam" ? "non-Steam shortcuts" : "Steam games";
+ const emptyDescription = source === "nonSteam"
+ ? "Steam has not reported any eligible non-Steam shortcuts"
+ : "Steam has not reported any eligible installed games";
const toItem = (game: GameTarget) => ({
id: game.appid,
label: game.name,
description: targetDescription(game),
});
- const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
- const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(`${ENABLED_COLLAPSED_KEY}-${source}`);
+ const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-${source}`);
const enabledToggleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -57,10 +70,10 @@ export function GameConfigurationSelector({
const confirmResetAll = () => {
showModal(
<ConfirmModal
- strTitle="Remove all profiles?"
+ strTitle={`Remove all ${sourceName} profiles?`}
strOKButtonText="Remove all"
strCancelButtonText="Cancel"
- onOK={() => void onResetAll()}
+ onOK={() => void onResetAll(source)}
onCancel={() => {}}
/>,
);
@@ -69,11 +82,11 @@ export function GameConfigurationSelector({
const confirmEnableAll = () => {
showModal(
<ConfirmModal
- strTitle="Enable all available games?"
- strDescription="Create individual LSFG-VK profiles for every available Steam game and non-Steam shortcut. Flatpak profiles are managed separately in the Flatpak tab."
+ strTitle={`Enable all available ${sourceName}?`}
+ strDescription={`Create individual LSFG-VK profiles for every available ${sourceName}. Unknown-source profiles are excluded. Flatpak profiles are managed separately in the Flatpak tab.`}
strOKButtonText="Enable all"
strCancelButtonText="Cancel"
- onOK={() => void onEnableAll()}
+ onOK={() => void onEnableAll(source)}
onCancel={() => {}}
/>,
);
@@ -86,7 +99,7 @@ export function GameConfigurationSelector({
</style>
{targets.length === 0 && (
<PanelSectionRow>
- <Field label="No installed games" description="Steam has not reported any eligible games" />
+ <Field label={`No ${sourceName} found`} description={emptyDescription} />
</PanelSectionRow>
)}
<CollapsibleItemGroup
@@ -104,10 +117,10 @@ export function GameConfigurationSelector({
onToggle={toggleAvailable}
onSelect={onSelect}
/>
- {availableGames.length > 0 && (
+ {enableableGames.length > 0 && (
<PanelSectionRow>
- <ButtonItem layout="below" onClick={confirmEnableAll}>
- Enable all available games
+ <ButtonItem layout="below" onClick={confirmEnableAll} disabled={bulkOperationBusy}>
+ {`Enable all ${sourceName}`}
</ButtonItem>
</PanelSectionRow>
)}
@@ -115,9 +128,9 @@ export function GameConfigurationSelector({
<ButtonItem
layout="below"
onClick={confirmResetAll}
- disabled={!targets.some((target) => target.configured)}
+ disabled={bulkOperationBusy || removableGames.length === 0}
>
- Remove all profiles
+ {`Remove all ${sourceLabel(source)} profiles`}
</ButtonItem>
</PanelSectionRow>
</>
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 4c22fb7..69e72d7 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,6 +1,7 @@
import { Focusable } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
-import { GameTarget } from "../hooks/useGameConfiguration";
+import type { GameTarget } from "../utils/gameTargets";
+import { sourceLabel } from "../utils/gameTargets";
import { GameConfigurationControls } from "./GameConfigurationControls";
import { NowPlayingSummary } from "./NowPlayingSummary";
@@ -14,7 +15,7 @@ interface Props {
}
function targetDescription(game: GameTarget): string {
- return game.nonSteam ? "Non-Steam" : "Steam";
+ return sourceLabel(game.source);
}
export function NowPlayingTab({
diff --git a/src/components/SetupTab.tsx b/src/components/SettingsTab.tsx
index ff072cb..35a74cc 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SettingsTab.tsx
@@ -2,7 +2,7 @@ import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@
import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi";
import t from "../i18n/i18n";
-interface SetupTabProps {
+interface SettingsTabProps {
isInstalled: boolean;
installationStatus: string;
losslessScalingInstalled: boolean;
@@ -18,7 +18,7 @@ interface SetupTabProps {
onUninstall: () => void;
}
-export function SetupTab(props: SetupTabProps) {
+export function SettingsTab(props: SettingsTabProps) {
const {
isInstalled,
installationStatus,
@@ -45,7 +45,7 @@ export function SetupTab(props: SetupTabProps) {
return (
<>
- <PanelSection title="Setup">
+ <PanelSection title="Settings">
<PanelSectionRow>
<Field
label="Lossless Scaling"
diff --git a/src/components/index.ts b/src/components/index.ts
index bca6f6f..5459974 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -3,7 +3,7 @@ export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
export { ConfigurationTab } from "./ConfigurationTab";
export { ConfigFileTab } from "./ConfigFileTab";
-export { SetupTab } from "./SetupTab";
+export { SettingsTab } from "./SettingsTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
export { GameConfigurationControls } from "./GameConfigurationControls";
export { NowPlayingTab } from "./NowPlayingTab";
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index fd8dfb1..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 { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, 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 { 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;
@@ -56,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);
}, []);
@@ -89,15 +99,19 @@ export function useGameConfiguration() {
const installed = installedGames.find((game) => game.appid === appid);
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
+ const source = getTargetSource(appid, installedGames, workaroundApps);
const next: GameTarget = {
- ...(installed || { appid, name, nonSteam: false }),
+ ...(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
@@ -106,7 +120,7 @@ export function useGameConfiguration() {
poll();
const interval = window.setInterval(poll, 2000);
return () => window.clearInterval(interval);
- }, [configsLoaded, games, installedGames]);
+ }, [configsLoaded, games, installedGames, workaroundApps]);
useEffect(() => {
const appid = runningGame?.appid || null;
@@ -117,11 +131,8 @@ 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, 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
@@ -129,6 +140,10 @@ export function useGameConfiguration() {
: template;
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;
+ }
if (!installedGames.some((game) => game.appid === target.appid)) return true;
const appId = Number(target.appid);
let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
@@ -185,18 +200,21 @@ export function useGameConfiguration() {
}, [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();
- await removeWrapperIntegration(
- appId,
- target.nonSteam,
- wrapperPath,
- existing.command_token_added === true,
- );
+ if (installed) {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ existing.command_token_added === true,
+ );
+ }
const removed = await removeWorkaroundState(target.appid);
if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
return true;
@@ -206,20 +224,29 @@ export function useGameConfiguration() {
}
}, [installedGames]);
+ 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 targetsByAppId = new Map(targets.map((target) => [target.appid, target]));
const cleaned = new Set<string>();
const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
for (const entry of result.apps || []) {
- const target = targetsByAppId.get(entry.appid);
- const nonSteam = target?.nonSteam ?? entry.non_steam;
await removeWrapperIntegration(
Number(entry.appid),
- nonSteam,
+ entry.non_steam,
wrapperPath,
entry.command_token_added,
);
@@ -274,23 +301,32 @@ export function useGameConfiguration() {
return result.success;
}, [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 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();
- }, [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);
@@ -313,17 +349,30 @@ 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, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, cleanupAllWorkarounds, 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/utils/gameTargets.ts b/src/utils/gameTargets.ts
new file mode 100644
index 0000000..8922e98
--- /dev/null
+++ b/src/utils/gameTargets.ts
@@ -0,0 +1,75 @@
+import type { GameConfigEntry, InstalledGame, WorkaroundApp } from "../api/lsfgApi";
+
+export type GameSource = "steam" | "nonSteam" | "unknown";
+export type KnownGameSource = Exclude<GameSource, "unknown">;
+
+export interface GameTarget extends InstalledGame {
+ configured: boolean;
+ source: GameSource;
+}
+
+export function sourceFromNonSteam(nonSteam: boolean): KnownGameSource {
+ return nonSteam ? "nonSteam" : "steam";
+}
+
+export function getTargetSource(
+ appid: string,
+ installedGames: InstalledGame[],
+ workaroundApps: WorkaroundApp[],
+): GameSource {
+ const workaround = workaroundApps.find((item) => item.appid === appid);
+ if (workaround) return sourceFromNonSteam(workaround.non_steam);
+
+ const installed = installedGames.find((game) => game.appid === appid);
+ return installed ? sourceFromNonSteam(installed.nonSteam) : "unknown";
+}
+
+export function mergeGameTargets(
+ configs: GameConfigEntry[],
+ installedGames: InstalledGame[],
+ workaroundApps: WorkaroundApp[],
+ runningGame: GameTarget | null = null,
+): GameTarget[] {
+ const configuredIds = new Set(configs.map((game) => game.appid));
+ const targets = installedGames.map((game) => {
+ const source = getTargetSource(game.appid, installedGames, workaroundApps);
+ return {
+ ...game,
+ nonSteam: source === "nonSteam",
+ source,
+ configured: configuredIds.has(game.appid),
+ };
+ });
+
+ for (const game of configs) {
+ if (targets.some((target) => target.appid === game.appid)) continue;
+ const source = getTargetSource(game.appid, installedGames, workaroundApps);
+ targets.push({
+ appid: game.appid,
+ name: game.profile || `App ${game.appid}`,
+ nonSteam: source === "nonSteam",
+ source,
+ configured: true,
+ });
+ }
+
+ if (
+ runningGame
+ && !targets.some((target) => target.appid === runningGame.appid)
+ && (runningGame.configured || runningGame.source !== "unknown")
+ ) {
+ targets.unshift(runningGame);
+ }
+
+ return targets;
+}
+
+export function targetsForSource(targets: GameTarget[], source: KnownGameSource): GameTarget[] {
+ return targets.filter((target) => target.source === source || (target.source === "unknown" && target.configured));
+}
+
+export function sourceLabel(source: GameSource): string {
+ if (source === "nonSteam") return "Non-Steam";
+ if (source === "steam") return "Steam";
+ return "Unknown source";
+}
diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts
index 380c9f5..a307f5c 100644
--- a/src/utils/nowPlaying.ts
+++ b/src/utils/nowPlaying.ts
@@ -1,5 +1,5 @@
import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi";
-import type { GameTarget } from "../hooks/useGameConfiguration";
+import type { GameTarget } from "./gameTargets";
export type NowPlayingTarget =
| {
@@ -10,6 +10,10 @@ export type NowPlayingTarget =
| {
kind: "steam";
game: GameTarget;
+ }
+ | {
+ kind: "nonSteam";
+ game: GameTarget;
};
function numericValue(value: number | null | undefined): number {
@@ -65,12 +69,18 @@ export function resolveNowPlayingTarget(
runningGame: GameTarget | null,
runningFlatpak: FlatpakApp | null,
): NowPlayingTarget | null {
- if (runningGame && !runningGame.nonSteam) {
+ if (runningGame?.source === "steam") {
return runningGame.configured ? { kind: "steam", game: runningGame } : null;
}
if (runningFlatpak) {
- return { kind: "flatpak", app: runningFlatpak, launcher: runningGame?.nonSteam ? runningGame : null };
+ return {
+ kind: "flatpak",
+ app: runningFlatpak,
+ launcher: runningGame?.source === "nonSteam" ? runningGame : null,
+ };
+ }
+ if (runningGame?.source === "nonSteam" && runningGame.configured) {
+ return { kind: "nonSteam", game: runningGame };
}
- if (runningGame?.configured) return { kind: "steam", game: runningGame };
return null;
}