summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/lsfgApi.ts189
-rw-r--r--src/components/CollapsibleItemGroup.tsx101
-rw-r--r--src/components/ConfigFileTab.tsx119
-rw-r--r--src/components/ConfigurationSection.tsx5
-rw-r--r--src/components/ConfigurationTab.tsx126
-rw-r--r--src/components/Content.tsx212
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx38
-rw-r--r--src/components/FlatpakTab.tsx247
-rw-r--r--src/components/FlatpakWorkaroundsSection.tsx144
-rw-r--r--src/components/GameConfigurationControls.tsx3
-rw-r--r--src/components/GameConfigurationSelector.tsx108
-rw-r--r--src/components/InstallationButton.tsx38
-rw-r--r--src/components/NowPlayingSummary.tsx16
-rw-r--r--src/components/NowPlayingTab.tsx86
-rw-r--r--src/components/SetupTab.tsx261
-rw-r--r--src/components/StatusDisplay.tsx41
-rw-r--r--src/components/WorkaroundsSection.tsx10
-rw-r--r--src/components/index.ts2
-rw-r--r--src/hooks/useFlatpakConfiguration.ts160
-rw-r--r--src/hooks/useGameConfiguration.ts290
-rw-r--r--src/hooks/useInstallationActions.ts84
-rw-r--r--src/hooks/useLsfgHooks.ts94
-rw-r--r--src/hooks/usePerAppWorkarounds.ts85
-rw-r--r--src/styles.ts17
-rw-r--r--src/types.d.ts4
-rw-r--r--src/utils/nowPlaying.ts76
-rw-r--r--src/utils/steamLaunchOptions.ts568
-rw-r--r--src/utils/toastUtils.ts79
28 files changed, 1705 insertions, 1498 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 16b6eab..e050f62 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -1,11 +1,13 @@
import { callable } from "@decky/api";
import { ConfigurationData } from "../config/configSchema";
-// Type definitions for API responses
-export interface InstallationResult {
+interface ApiResult {
success: boolean;
- error?: string;
message?: string;
+ error?: string | null;
+}
+
+export interface InstallationResult extends ApiResult {
removed_files?: string[];
}
@@ -16,10 +18,8 @@ export interface InstallationStatus {
error?: string;
}
-export interface SteamBranchStatus {
- success: boolean;
+export interface SteamBranchStatus extends ApiResult {
message: string;
- error?: string;
installed: boolean;
manifest_path?: string;
selected_branch?: string;
@@ -29,62 +29,23 @@ export interface SteamBranchStatus {
restart_required: boolean;
}
-// Use centralized configuration data type
export type LsfgConfig = ConfigurationData;
-export interface ConfigUpdateResult {
- success: boolean;
- message?: string;
- error?: string;
-}
-
export interface GameConfigEntry {
appid: string;
profile: string;
config: LsfgConfig;
}
-export type TargetTransport =
- | { kind: "host" }
- | { kind: "flatpak"; flatpakAppId: string };
-
-export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error";
-
-export interface FlatpakTargetSupport {
- success: boolean;
- message?: string;
- error?: string | null;
- flatpak_app_id?: string;
- runtime?: string | null;
- runtime_branch?: string | null;
- support_status: FlatpakTargetSupportStatus;
- extension_installed: boolean;
- installed_branches: string[];
-}
export interface InstalledGame {
appid: string;
name: string;
nonSteam: boolean;
- transport: TargetTransport;
- executable?: string;
- arguments?: string;
- startDir?: string;
- flatpakSupport?: FlatpakTargetSupport;
}
-export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; }
-export interface GlobalConfig { dll: string; no_fp16: boolean; }
-export interface GameConfigsResult {
- success: boolean;
- global_config?: GlobalConfig;
- games?: GameConfigEntry[];
- error?: string;
-}
-
-export interface GameConfigResult extends ConfigUpdateResult {
- appid?: string;
- exists?: boolean;
- config?: LsfgConfig;
+export interface GlobalConfig {
+ dll: string;
+ no_fp16: boolean;
}
export interface WorkaroundState {
@@ -96,88 +57,122 @@ export interface WorkaroundState {
enableZink: boolean;
}
-export interface WorkaroundStateResult {
- success: boolean;
- message?: string;
- error?: string;
+export interface WorkaroundStateResult extends ApiResult {
appid?: string;
+ app_id?: string;
state?: WorkaroundState | null;
wrapper_path?: string;
wrapper_owned?: boolean;
- shortcut_exe?: string | null;
command_token_added?: boolean;
- transport?: TargetTransport | null;
+ non_steam?: boolean;
}
-export interface FileContentResult {
- success: boolean;
+export interface WorkaroundApp {
+ appid: string;
+ non_steam: boolean;
+ command_token_added: boolean;
+}
+
+export interface WorkaroundAppsResult extends ApiResult {
+ apps?: WorkaroundApp[];
+ wrapper_path?: string;
+}
+
+export interface GameConfigsResult extends ApiResult {
+ global_config?: GlobalConfig;
+ games?: GameConfigEntry[];
+}
+
+export interface GlobalConfigResult extends ApiResult {
+ global_config?: GlobalConfig;
+}
+
+export interface GameConfigResult extends ApiResult {
+ appid?: string;
+ exists?: boolean;
+ config?: LsfgConfig;
+}
+
+export interface InstalledGamesResult extends ApiResult {
+ games?: InstalledGame[];
+}
+
+export interface FileContentResult extends ApiResult {
content?: string;
path?: string;
- error?: string;
}
-export interface FlatpakExtensionStatus {
- success: boolean;
- message: string;
+export interface DebugFileContent {
+ id: string;
+ label: string;
+ path: string;
+ exists: boolean;
+ content?: string | null;
error?: string | null;
- available: boolean;
- extension_id: string;
- supported_branches: string[];
- installed_branches: string[];
- owned_branches: string[];
- ownership_uncertain: boolean;
}
-export interface FlatpakCleanupResult {
- success: boolean;
- message: string;
- error?: string | null;
- removed_branches: string[];
- preserved_branches: string[];
- ownership_uncertain: boolean;
+export interface DebugFileContentsResult extends ApiResult {
+ files?: DebugFileContent[];
}
-export interface FlatpakExtensionToggleResult {
- success: boolean;
- message: string;
- error?: string | null;
- runtime_branch: string;
+export interface FlatpakApp {
+ app_id: string;
+ app_name: string;
+ runtime?: string | null;
+ runtime_branch?: string | null;
+ runtime_ready: boolean;
+ prepared: boolean;
+ owned: boolean;
enabled: boolean;
- installed: boolean;
- owned_by_plugin: boolean;
- preserved: boolean;
+ profile: string;
+ config?: LsfgConfig | null;
+ workarounds: WorkaroundState;
+ error?: string | null;
+}
+
+export interface RunningFlatpakApp {
+ app_id: string;
+ active: boolean;
+ pid?: string;
+ start_time?: number | null;
+}
+
+export interface FlatpakAppsResult extends ApiResult {
+ apps?: FlatpakApp[];
+}
+
+export interface RunningFlatpakAppsResult extends ApiResult {
+ apps?: RunningFlatpakApp[];
+}
+
+export interface FlatpakAppResult extends ApiResult, Partial<FlatpakApp> {
+ app_id: string;
}
-// API functions
export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk");
export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk");
export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed");
export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status");
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
-
-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
->("remove_plugin_owned_flatpak_extensions");
-
+export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps");
+export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app");
+export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config");
+export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state");
+export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app");
+export const getRunningFlatpakApps = callable<[], RunningFlatpakAppsResult>("get_running_flatpak_apps");
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 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");
export const setWorkaroundState = callable<[
string,
WorkaroundState,
- string | null | undefined,
boolean,
- TargetTransport | null | undefined,
+ boolean,
], WorkaroundStateResult>("set_workaround_state");
export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state");
+export const getWorkaroundApps = callable<[], WorkaroundAppsResult>("get_workaround_apps");
+export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents");
diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx
new file mode 100644
index 0000000..29fe945
--- /dev/null
+++ b/src/components/CollapsibleItemGroup.tsx
@@ -0,0 +1,101 @@
+import { ButtonItem, Field, PanelSectionRow } from "@decky/ui";
+import { useEffect, useState, type RefObject } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+
+export interface CollapsibleItem {
+ id: string;
+ label: string;
+ description: string;
+}
+
+export const collapsibleItemGroupStyles = `
+ .LSFG_GameGroupCollapseButton_Container {
+ margin-top: -2px;
+ margin-bottom: 4px;
+ }
+
+ .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;
+ }
+`;
+
+export function usePersistentCollapsed(key: string) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(key) !== "false";
+ } catch {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(collapsed));
+ } catch {}
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
+interface Props {
+ title: string;
+ items: CollapsibleItem[];
+ collapsed: boolean;
+ onToggle: () => void;
+ onSelect: (id: string) => void;
+ toggleRef?: RefObject<HTMLDivElement>;
+}
+
+export function CollapsibleItemGroup({
+ title,
+ items,
+ collapsed,
+ onToggle,
+ onSelect,
+ toggleRef,
+}: Props) {
+ if (items.length === 0) return null;
+
+ return (
+ <>
+ <PanelSectionRow>
+ <Field label={`${title} (${items.length})`} bottomSeparator="none" />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div
+ ref={toggleRef}
+ className="LSFG_GameGroupCollapseButton_Container"
+ >
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={onToggle}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+ {!collapsed && items.map((item) => (
+ <PanelSectionRow key={item.id}>
+ <Field
+ label={item.label}
+ description={item.description}
+ onActivate={() => onSelect(item.id)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ ))}
+ </>
+ );
+}
diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx
index e3cc09d..07408d7 100644
--- a/src/components/ConfigFileTab.tsx
+++ b/src/components/ConfigFileTab.tsx
@@ -1,13 +1,79 @@
+import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui";
import { useEffect, useState } from "react";
-import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui";
-import { getConfigFileContent, FileContentResult } from "../api/lsfgApi";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi";
import t from "../i18n/i18n";
+function usePersistentCollapsed(key: string) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(key) !== "false";
+ } catch {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(collapsed));
+ } catch {
+ // Persisting the view preference is optional.
+ }
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
+function DebugFileSection({ file }: { file: DebugFileContent }) {
+ const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`);
+ const status = file.exists ? "Present" : "Not present";
+
+ return (
+ <>
+ <PanelSectionRow>
+ <Field
+ label={file.label}
+ description={`${file.path} · ${status}`}
+ bottomSeparator="none"
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div
+ className="LSFG_DebugFileCollapseButton_Container"
+ style={{ marginTop: "-2px", marginBottom: "4px" }}
+ >
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={toggleCollapsed}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+ {!collapsed && (
+ <PanelSectionRow>
+ {file.exists && file.content !== null && file.content !== undefined ? (
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
+ {file.content}
+ </pre>
+ ) : (
+ <Field
+ label="File unavailable"
+ description={file.error || "The file has not been created yet."}
+ />
+ )}
+ </PanelSectionRow>
+ )}
+ </>
+ );
+}
+
export function ConfigFileTab() {
- const [result, setResult] = useState<FileContentResult | null>(null);
+ const [result, setResult] = useState<DebugFileContentsResult | null>(null);
useEffect(() => {
- getConfigFileContent().then(setResult).catch((error) => {
+ getDebugFileContents().then(setResult).catch((error) => {
setResult({ success: false, error: String(error) });
});
}, []);
@@ -23,24 +89,35 @@ export function ConfigFileTab() {
}
return (
- <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
- {result.error && (
- <PanelSectionRow>
- <Field label="Error" description={result.error} />
- </PanelSectionRow>
- )}
- {result.success && result.content && (
- <>
- <PanelSectionRow>
- <Field label="Config file" description={result.path} />
- </PanelSectionRow>
+ <>
+ <style>
+ {`
+ .LSFG_DebugFileCollapseButton_Container > div > div > div > button,
+ .LSFG_DebugFileCollapseButton_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_DebugFileCollapseButton_Container svg {
+ display: block;
+ margin: 0;
+ }
+ `}
+ </style>
+ <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
+ {result.error && (
<PanelSectionRow>
- <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
- {result.content}
- </pre>
+ <Field label="Error" description={result.error} />
</PanelSectionRow>
- </>
- )}
- </PanelSection>
+ )}
+ {result.success && result.files?.map((file) => (
+ <DebugFileSection key={file.id} file={file} />
+ ))}
+ </PanelSection>
+ </>
);
}
diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx
index 1f17264..6996bcd 100644
--- a/src/components/ConfigurationSection.tsx
+++ b/src/components/ConfigurationSection.tsx
@@ -1,6 +1,6 @@
import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
-import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema";
+import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema";
interface ConfigurationSectionProps {
config: ConfigurationData;
@@ -13,9 +13,6 @@ export function ConfigurationSection({ config, onConfigChange }: ConfigurationSe
<SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} />
</PanelSectionRow>
<PanelSectionRow>
- <ToggleField label="FP16 Acceleration" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} />
- </PanelSectionRow>
- <PanelSectionRow>
<ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} />
</PanelSectionRow>
<PanelSectionRow>
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 8f8690c..37a7867 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,4 +1,4 @@
-import { ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui";
+import { ButtonItem, ConfirmModal, DialogButton, 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";
@@ -37,7 +37,6 @@ export function ConfigurationTab({
const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null);
const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false);
const enableRef = useRef<HTMLDivElement>(null);
- const promptedRunningAppId = useRef<string | null>(null);
const closeDetails = useCallback(() => {
setFocusFpsMultiplier(false);
setFocusDetailAction(null);
@@ -60,58 +59,65 @@ export function ConfigurationTab({
return () => cancelAnimationFrame(frame);
}, [focusDetailAction]);
- useEffect(() => {
- if (!runningGame || runningGame.configured) {
- promptedRunningAppId.current = null;
- return;
- }
- if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) {
- promptedRunningAppId.current = runningGame.appid;
- setFocusDetailAction("enable");
- setDetailAppId(runningGame.appid);
- }
- }, [detailAppId, runningGame?.appid, runningGame?.configured]);
-
const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null;
if (detailAppId === null) {
return (
- <PanelSection title="Games">
- <GameConfigurationSelector
- targets={targets}
- runningGame={runningGame}
- onSelect={(appid) => {
- setFocusConfiguredToggle(false);
- setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
- onSelect(appid);
- setDetailAppId(appid);
- }}
- onEnableAll={onEnableAll}
- onResetAll={onResetAll}
- focusConfiguredToggle={focusConfiguredToggle}
- onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
- />
- </PanelSection>
+ <>
+ <PanelSection title="Games">
+ <GameConfigurationSelector
+ targets={targets}
+ runningGame={runningGame}
+ onSelect={(appid) => {
+ setFocusConfiguredToggle(false);
+ setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
+ onSelect(appid);
+ setDetailAppId(appid);
+ }}
+ onEnableAll={onEnableAll}
+ onResetAll={onResetAll}
+ focusConfiguredToggle={focusConfiguredToggle}
+ onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
+ />
+ </PanelSection>
+ </>
);
}
const profileLabel = selectedTarget?.name || "Game profile";
- const profileTransport = selectedTarget
- ? selectedTarget.transport.kind === "flatpak"
- ? "Non-Steam · Flatpak"
- : selectedTarget.nonSteam ? "Non-Steam" : "Steam"
- : "Game";
+ const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam";
const profileDescription = selectedTarget
? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
+ const enableProfile = async (appid: string, quitRunningGame = false) => {
+ if (!(await onEnable(appid))) return;
+ if (quitRunningGame) SteamClient.Apps.TerminateApp(appid, false);
+ setFocusFpsMultiplier(true);
+ };
const handleProfileAction = async () => {
if (selectedTarget?.configured) {
- promptedRunningAppId.current = detailAppId;
await onReset();
setFocusConfiguredToggle(true);
closeDetails();
- } else if (detailAppId && await onEnable(detailAppId)) {
- setFocusFpsMultiplier(true);
+ } else if (detailAppId) {
+ const isRunningUnconfigured = runningGame?.appid === detailAppId
+ && runningGame.nonSteam === false
+ && selectedTarget?.nonSteam === false
+ && !runningGame.configured;
+ if (isRunningUnconfigured) {
+ showModal(
+ <ConfirmModal
+ strTitle="Game is running"
+ strDescription="Quit the game now so LSFG-VK is used on its next launch?"
+ strOKButtonText="Quit and enable"
+ strCancelButtonText="Enable without quitting"
+ onOK={() => void enableProfile(detailAppId, true)}
+ onCancel={() => void enableProfile(detailAppId)}
+ />,
+ );
+ } else {
+ await enableProfile(detailAppId);
+ }
}
};
@@ -121,22 +127,22 @@ export function ConfigurationTab({
<PanelSectionRow>
<div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<Focusable noFocusRing style={{ flex: "none" }}>
- <DialogButton
- aria-label="Back to games"
- onClick={closeDetails}
- style={{
- width: "48px",
- minWidth: "48px",
- height: "24px",
- minHeight: "24px",
- padding: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <FaArrowLeft />
- </DialogButton>
+ <DialogButton
+ aria-label="Back to games"
+ onClick={closeDetails}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
</Focusable>
<div
className={gamepadDialogClasses.FieldLabel}
@@ -156,18 +162,6 @@ 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 58512d5..3f25320 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,80 +1,126 @@
import { Tabs } from "@decky/ui";
-import { useEffect, useRef, useState } from "react";
-import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
+import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react";
+import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
-import { tabStyles } from "../styles";
+import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
-import { useInstallationActions } from "../hooks/useInstallationActions";
-import { useInstallationStatus } from "../hooks/useLsfgHooks";
-import { ConfigurationTab } from "./ConfigurationTab";
+import { useInstallation } from "../hooks/useLsfgHooks";
+import { tabStyles } from "../styles";
+import { resolveNowPlayingTarget } 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";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
games: <FaList size={18} />,
+ flatpak: <FaCube size={18} />,
configFile: <FaFileAlt size={18} />,
setup: <FaTools size={18} />,
};
+const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1";
+
+function usePersistentBoolean(key: string, defaultValue: boolean) {
+ const [value, setValue] = useState(() => {
+ try {
+ const stored = localStorage.getItem(key);
+ return stored === null ? defaultValue : stored === "true";
+ } catch {
+ return defaultValue;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(value));
+ } catch {}
+ }, [key, value]);
+
+ return [value, setValue] as const;
+}
+
export function Content() {
const {
- isInstalled,
- installationStatus,
- setIsInstalled,
- setInstallationStatus,
- losslessScalingInstalled,
- losslessScalingStatus,
- steamBranchStatus,
- checkInstallation,
- } = useInstallationStatus();
- const {
config,
+ runningConfig,
+ globalConfig,
targets,
runningGame,
setSelectedAppId,
save,
+ saveFor,
+ updateGlobal,
enable,
enableAll,
repair,
resetSelected,
resetAll,
+ cleanupAllWorkarounds,
reload,
} = useGameConfiguration();
- const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions();
- const [tab, setTab] = useState("Setup");
+ const {
+ isInstalled,
+ installationStatus,
+ losslessScalingInstalled,
+ losslessScalingStatus,
+ steamBranchStatus,
+ isInstalling,
+ isUninstalling,
+ install,
+ uninstall,
+ } = useInstallation(reload, cleanupAllWorkarounds);
const setupComplete =
isInstalled &&
losslessScalingInstalled &&
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
- const previousRunningAppId = useRef<string | null>(null);
+ const flatpak = useFlatpakConfiguration(setupComplete);
+ const [tab, setTab] = useState("Setup");
+ const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false);
+ const [contentFocused, setContentFocused] = useState(false);
+ const previousRunningWorkload = useRef<string | null>(null);
+ 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}`
+ : null;
useEffect(() => {
if (!setupComplete) {
setTab("Setup");
return;
}
- setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current);
- }, [runningGame?.appid, setupComplete]);
+ setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current);
+ }, [hasNowPlaying, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
- 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);
+ const previous = previousRunningWorkload.current;
+ previousRunningWorkload.current = runningWorkload;
+ if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying");
+ else if (!runningWorkload && previous) {
+ setTab((current) => current === "NowPlaying" ? "Games" : current);
+ }
+ }, [runningWorkload, setupComplete]);
+
+ useEffect(() => {
+ if (isInstalled) {
+ void reload();
+ void flatpak.reload();
}
- }, [runningGame?.appid, runningGame?.configured, setupComplete]);
+ }, [isInstalled, reload, flatpak.reload]);
useEffect(() => {
- if (isInstalled) void reload();
- }, [isInstalled, reload]);
+ if (!showDebugTab && tab === "ConfigFile") setTab("Games");
+ }, [showDebugTab, tab]);
const handleConfigChange = async (
fieldName: keyof ConfigurationData,
@@ -84,15 +130,7 @@ export function Content() {
await save({ ...config, [fieldName]: value }, cleanupLaunchOptions);
};
- const onInstall = () => {
- void handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation);
- };
-
- const onUninstall = () => {
- void handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation);
- };
-
- const setupContent = (
+ const setup = (
<SetupTab
isInstalled={isInstalled}
installationStatus={installationStatus}
@@ -101,63 +139,101 @@ export function Content() {
steamBranchStatus={steamBranchStatus}
isInstalling={isInstalling}
isUninstalling={isUninstalling}
- onInstall={onInstall}
- onUninstall={onUninstall}
- flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")}
+ globalConfig={globalConfig}
+ showDebugTab={showDebugTab}
+ onGlobalConfigChange={updateGlobal}
+ onShowDebugTabChange={setShowDebugTab}
+ onInstall={() => void install()}
+ onUninstall={() => void uninstall()}
/>
);
+ const tabContent = (content: ReactNode) => (
+ <div className="lsfg-vk-tab-content">{content}</div>
+ );
+
+ const nowPlaying = nowPlayingTarget?.kind === "steam" ? (
+ <NowPlayingTab
+ game={nowPlayingTarget.game}
+ config={runningConfig}
+ onConfigChange={async (field, value) => {
+ 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
? [
- ...(runningGame ? [{
- id: "NowPlaying",
- title: tabIcons.nowPlaying,
- content: (
- <NowPlayingTab
- game={runningGame}
- config={config}
- onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)}
- onEnable={enable}
- onRepair={repair}
- />
- ),
- }] : []),
+ ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []),
{
id: "Games",
title: tabIcons.games,
- content: (
+ content: tabContent(
<ConfigurationTab
config={config}
targets={targets}
runningGame={runningGame}
onSelect={setSelectedAppId}
- onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)}
+ onConfigChange={(field, value) => handleConfigChange(field, value, true)}
onEnable={enable}
onEnableAll={enableAll}
onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
- />
+ />,
),
},
{
- id: "ConfigFile",
- title: tabIcons.configFile,
- content: <ConfigFileTab />,
+ id: "Flatpak",
+ title: tabIcons.flatpak,
+ content: tabContent(
+ <FlatpakTab
+ apps={flatpak.apps}
+ runningApp={runningFlatpak}
+ loading={flatpak.loading}
+ busyAppId={flatpak.busyAppId}
+ onRefresh={flatpak.reload}
+ onEnable={flatpak.enableApp}
+ onEnableAll={flatpak.enableAll}
+ onRemove={flatpak.removeApp}
+ onRemoveAll={flatpak.removeAll}
+ onConfigChange={flatpak.updateConfig}
+ onWorkaroundChange={flatpak.updateWorkarounds}
+ />,
+ ),
},
- { id: "Setup", title: tabIcons.setup, content: setupContent },
+ ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []),
+ { id: "Setup", title: tabIcons.setup, content: tabContent(setup) },
]
- : [
- { id: "Setup", title: tabIcons.setup, content: setupContent },
- ];
+ : [{ id: "Setup", title: tabIcons.setup, content: tabContent(setup) }];
+
+ const availableTabIds = new Set(tabs.map(({ id }) => id));
+ const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup";
+ const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => {
+ const focusedElement = event.target as HTMLElement | null;
+ setContentFocused(!focusedElement?.closest?.('[role="tab"]'));
+ };
return (
<div
- className="lsfg-vk-tabs"
+ className={`lsfg-vk-tabs${contentFocused ? " lsfg-vk-tabs--content-focused" : ""}`}
style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }}
+ onFocusCapture={handleFocusCapture}
>
<style>{tabStyles}</style>
- <Tabs activeTab={tab} onShowTab={setTab} tabs={tabs} />
+ <Tabs
+ activeTab={activeTab}
+ onShowTab={(nextTab: string) => {
+ if (availableTabIds.has(nextTab)) setTab(nextTab);
+ }}
+ tabs={tabs}
+ />
</div>
);
}
diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx
new file mode 100644
index 0000000..89f7f49
--- /dev/null
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -0,0 +1,38 @@
+import { Focusable } from "@decky/ui";
+import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { NowPlayingSummary } from "./NowPlayingSummary";
+
+interface Props {
+ app: FlatpakApp;
+ launcher: GameTarget | null;
+ onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
+}
+
+export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) {
+ if (!app.config) return null;
+ const changeConfig = async (
+ field: keyof LsfgConfig,
+ value: boolean | number | string | string[],
+ ) => {
+ await onConfigChange(app.app_id, { ...app.config!, [field]: value });
+ };
+
+ return (
+ <Focusable>
+ <NowPlayingSummary
+ title={launcher?.name || app.app_name}
+ details={[
+ launcher ? (launcher.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`,
+ ].filter((detail): detail is string => detail !== null)}
+ />
+ <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} />
+ <ConfigurationSection config={app.config} onConfigChange={changeConfig} />
+ </Focusable>
+ );
+}
diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx
new file mode 100644
index 0000000..35721f6
--- /dev/null
+++ b/src/components/FlatpakTab.tsx
@@ -0,0 +1,247 @@
+import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui";
+import { useCallback, useMemo, useState } from "react";
+import { FaArrowLeft } from "react-icons/fa";
+import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { ProfileDetails } from "./ProfileDetails";
+
+interface Props {
+ apps: FlatpakApp[];
+ runningApp: FlatpakApp | null;
+ loading: boolean;
+ busyAppId: string;
+ onRefresh: () => Promise<void>;
+ onEnable: (appId: string) => Promise<boolean>;
+ onEnableAll: () => Promise<void>;
+ onRemove: (appId: string) => Promise<boolean>;
+ onRemoveAll: () => Promise<void>;
+ onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
+ onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
+}
+
+const ENABLED_COLLAPSED_KEY = "lsfg-flatpak-enabled-collapsed-v2";
+const AVAILABLE_COLLAPSED_KEY = "lsfg-flatpak-available-collapsed-v2";
+
+export function FlatpakTab({
+ apps,
+ runningApp,
+ loading,
+ busyAppId,
+ onRefresh,
+ onEnable,
+ onEnableAll,
+ onRemove,
+ onRemoveAll,
+ onConfigChange,
+ onWorkaroundChange,
+}: Props) {
+ const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
+ const selected = useMemo(
+ () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null,
+ [apps, selectedAppId],
+ );
+ const close = useCallback(() => setSelectedAppId(null), []);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
+ const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
+
+ const enabledApps = useMemo(
+ () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)),
+ [apps],
+ );
+ const availableApps = useMemo(
+ () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)),
+ [apps],
+ );
+ const enableableApps = useMemo(
+ () => availableApps.filter((app) => !(app.prepared && !app.owned) && !app.error),
+ [availableApps],
+ );
+ const confirmEnableAll = () => {
+ showModal(
+ <ConfirmModal
+ strTitle="Enable all available Flatpaks?"
+ strDescription="Create individual LSFG-VK profiles for every Flatpak app this plugin can manage. Apps prepared externally or unavailable will be skipped."
+ strOKButtonText="Enable all"
+ strCancelButtonText="Cancel"
+ onOK={() => void onEnableAll()}
+ onCancel={() => {}}
+ />,
+ );
+ };
+ const confirmRemoveAll = () => {
+ showModal(
+ <ConfirmModal
+ strTitle="Remove all Flatpak profiles?"
+ strOKButtonText="Remove all"
+ strCancelButtonText="Cancel"
+ onOK={() => void onRemoveAll()}
+ onCancel={() => {}}
+ />,
+ );
+ };
+ const itemFor = (app: FlatpakApp) => ({
+ id: app.app_id,
+ label: app.app_name,
+ description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`,
+ });
+
+ if (!selectedAppId) {
+ return (
+ <PanelSection title="Flatpak">
+ <style>{collapsibleItemGroupStyles}</style>
+ <CollapsibleItemGroup
+ title="Enabled"
+ items={enabledApps.map((app) => ({
+ id: app.app_id,
+ label: app.app_name,
+ description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`,
+ }))}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
+ onSelect={setSelectedAppId}
+ />
+ <CollapsibleItemGroup
+ title="Available"
+ items={availableApps.map(itemFor)}
+ collapsed={availableCollapsed}
+ onToggle={toggleAvailable}
+ onSelect={setSelectedAppId}
+ />
+ {enableableApps.length > 0 && (
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={loading || Boolean(busyAppId)}
+ onClick={confirmEnableAll}
+ >
+ Enable all available Flatpaks
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={loading || Boolean(busyAppId) || enabledApps.length === 0}
+ onClick={confirmRemoveAll}
+ >
+ Remove all profiles
+ </ButtonItem>
+ </PanelSectionRow>
+ {apps.length === 0 && !loading && (
+ <PanelSectionRow>
+ <Field label="No Flatpak applications found" />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={loading || Boolean(busyAppId)} onClick={() => void onRefresh()}>
+ {loading ? "Refreshing..." : "Refresh Flatpaks"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ );
+ }
+
+ if (!selected) {
+ return (
+ <PanelSection title="Flatpak">
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={close}>Back</ButtonItem>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <Field label="Flatpak application is no longer installed" />
+ </PanelSectionRow>
+ </PanelSection>
+ );
+ }
+
+ const busy = busyAppId === selected.app_id;
+ const config = selected.config;
+ const external = selected.prepared && !selected.owned;
+ const profileDescription = [
+ selected.app_id,
+ selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null,
+ selected.enabled ? `profile ${selected.profile}` : null,
+ selected.app_id === runningApp?.app_id ? "Running" : null,
+ ].filter(Boolean).join(" · ");
+
+ const changeConfig = async (
+ field: keyof LsfgConfig,
+ value: boolean | number | string | string[],
+ ) => {
+ if (!config) return;
+ await onConfigChange(selected.app_id, { ...config, [field]: value });
+ };
+
+ return (
+ <Focusable onCancelButton={close}>
+ <PanelSection>
+ <PanelSectionRow>
+ <div style={{ display: "flex", alignItems: "center", width: "100%" }}>
+ <Focusable noFocusRing style={{ flex: "none" }}>
+ <DialogButton
+ aria-label="Back to Flatpaks"
+ onClick={close}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
+ </Focusable>
+ <div
+ className={gamepadDialogClasses.FieldLabel}
+ style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
+ >
+ {selected.app_name}
+ </div>
+ </div>
+ </PanelSectionRow>
+ </PanelSection>
+ {!selected.enabled && (
+ <PanelSection>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy || external || Boolean(selected.error)}
+ onClick={() => void onEnable(selected.app_id)}
+ >
+ {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"}
+ </ButtonItem>
+ </PanelSectionRow>
+ {selected.error && (
+ <PanelSectionRow>
+ <Field label="Unavailable" description={selected.error} />
+ </PanelSectionRow>
+ )}
+ </PanelSection>
+ )}
+ {selected.enabled && config && (
+ <>
+ <FpsMultiplierControl config={config} onConfigChange={changeConfig} />
+ <ConfigurationSection config={config} onConfigChange={changeConfig} />
+ <FlatpakWorkaroundsSection
+ state={selected.workarounds}
+ disabled={busy}
+ onChange={(state) => onWorkaroundChange(selected.app_id, state)}
+ />
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void onRemove(selected.app_id)}>
+ {busy ? "Removing..." : "Remove Flatpak profile"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </>
+ )}
+ <ProfileDetails description={profileDescription} />
+ </Focusable>
+ );
+}
diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx
new file mode 100644
index 0000000..7d9d880
--- /dev/null
+++ b/src/components/FlatpakWorkaroundsSection.tsx
@@ -0,0 +1,144 @@
+import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui";
+import { useEffect, useRef, useState } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import type { WorkaroundState } from "../api/lsfgApi";
+import t from "../i18n/i18n";
+
+interface Props {
+ state: WorkaroundState;
+ disabled?: boolean;
+ onChange: (state: WorkaroundState) => Promise<boolean>;
+}
+
+const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1";
+
+export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false";
+ } catch {
+ return true;
+ }
+ });
+ const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate);
+ const timer = useRef<number | null>(null);
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed));
+ } catch {}
+ }, [collapsed]);
+
+ useEffect(() => {
+ setFpsValue(state.dxvkFrameRate);
+ }, [state.dxvkFrameRate]);
+
+ useEffect(() => () => {
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ }, []);
+
+ const update = (field: keyof WorkaroundState, value: boolean | number) => {
+ void onChange({ ...state, [field]: value });
+ };
+
+ const updateFps = (value: number) => {
+ setFpsValue(value);
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => {
+ timer.current = null;
+ void onChange({ ...state, dxvkFrameRate: value });
+ }, 250);
+ };
+
+ const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off");
+
+ return (
+ <>
+ <PanelSectionRow>
+ <div
+ style={{
+ fontSize: "14px",
+ fontWeight: "bold",
+ marginTop: "8px",
+ marginBottom: "6px",
+ borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
+ paddingBottom: "3px",
+ color: "white",
+ }}
+ >
+ {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")}
+ </div>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={() => setCollapsed((value) => !value)}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </PanelSectionRow>
+ {!collapsed && (
+ <>
+ <PanelSectionRow>
+ <SliderField
+ label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`}
+ description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires app restart to apply.")}
+ value={fpsValue}
+ min={0}
+ max={60}
+ step={1}
+ onChange={updateFps}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_STEAMDECK_MODE", "Disable Steam Deck Mode")}
+ description={t("CONFIG_DISABLE_STEAMDECK_MODE_DESC", "Disables a game-specific Steam Deck compatibility switch. Requires app restart to apply.")}
+ checked={state.disableSteamdeckMode}
+ onChange={(value) => update("disableSteamdeckMode", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_GAMESCOPE_WSI", "Disable Gamescope WSI")}
+ description={t("CONFIG_DISABLE_GAMESCOPE_WSI_DESC", "Adds ENABLE_GAMESCOPE_WSI=0. Requires app restart to apply.")}
+ checked={state.disableGamescopeWsi}
+ onChange={(value) => update("disableGamescopeWsi", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_HDR", "Disable HDR")}
+ description={t("CONFIG_DISABLE_HDR_DESC", "Prevents DXVK from exposing HDR to the app. Requires app restart to apply.")}
+ checked={state.disableHdr}
+ onChange={(value) => update("disableHdr", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_VKBASALT", "Disable vkBasalt")}
+ description={t("CONFIG_DISABLE_VKBASALT_DESC", "Disables vkBasalt which can conflict with LSFG-VK.")}
+ checked={state.disableVkbasalt}
+ onChange={(value) => update("disableVkbasalt", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_ENABLE_ZINK", "Force Zink for OpenGL Games")}
+ description={t("CONFIG_ENABLE_ZINK_DESC", "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes. Requires app restart to apply.")}
+ checked={state.enableZink}
+ onChange={(value) => update("enableZink", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ </>
+ )}
+ </>
+ );
+}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 6bea2e0..7025f78 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" | "transport">;
+ workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">;
onRepairWorkaround?: () => Promise<boolean>;
}
@@ -36,7 +36,6 @@ 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 92eacba..2c683ae 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,7 +1,7 @@
import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui";
-import { useEffect, useRef, useState, type RefObject } from "react";
-import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { useEffect, useRef } from "react";
import { GameTarget } from "../hooks/useGameConfiguration";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup";
interface Props {
targets: GameTarget[];
@@ -16,82 +16,10 @@ interface Props {
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(() => {
- try {
- return localStorage.getItem(key) !== "false";
- } catch {
- return true;
- }
- });
-
- useEffect(() => {
- try {
- localStorage.setItem(key, String(collapsed));
- } catch {
- // Persisting the view preference is optional.
- }
- }, [collapsed, key]);
-
- 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,
- collapsed,
- onToggle,
- onSelect,
- toggleRef,
-}: {
- title: string;
- games: GameTarget[];
- collapsed: boolean;
- onToggle: () => void;
- onSelect: (appid: string) => void;
- toggleRef?: RefObject<HTMLDivElement>;
-}) {
- if (games.length === 0) return null;
-
- return (
- <>
- <PanelSectionRow>
- <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
- </PanelSectionRow>
- <PanelSectionRow>
- <div
- ref={toggleRef}
- className="LSFG_GameGroupCollapseButton_Container"
- style={{ marginTop: "-2px", marginBottom: "4px" }}
- >
- <ButtonItem
- layout="below"
- bottomSeparator={collapsed ? "standard" : "none"}
- onClick={onToggle}
- >
- {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
- </ButtonItem>
- </div>
- </PanelSectionRow>
- {!collapsed && games.map((game) => (
- <PanelSectionRow key={game.appid}>
- <Field
- label={game.name}
- description={targetDescription(game)}
- onActivate={() => onSelect(game.appid)}
- highlightOnFocus
- />
- </PanelSectionRow>
- ))}
- </>
- );
-}
-
export function GameConfigurationSelector({
targets,
runningGame,
@@ -108,6 +36,11 @@ export function GameConfigurationSelector({
});
const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
+ 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 enabledToggleRef = useRef<HTMLDivElement>(null);
@@ -137,7 +70,7 @@ export function GameConfigurationSelector({
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."
+ strDescription="Create individual LSFG-VK profiles for every available Steam game and non-Steam shortcut. Flatpak profiles are managed separately in the Flatpak tab."
strOKButtonText="Enable all"
strCancelButtonText="Cancel"
onOK={() => void onEnableAll()}
@@ -149,39 +82,24 @@ export function GameConfigurationSelector({
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;
- }
- `}
+ {collapsibleItemGroupStyles}
</style>
{targets.length === 0 && (
<PanelSectionRow>
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
- <GameGroup
+ <CollapsibleItemGroup
title="Enabled"
- games={enabledGames}
+ items={enabledGames.map(toItem)}
collapsed={enabledCollapsed}
onToggle={toggleEnabled}
onSelect={onSelect}
toggleRef={enabledToggleRef}
/>
- <GameGroup
+ <CollapsibleItemGroup
title="Available"
- games={availableGames}
+ items={availableGames.map(toItem)}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx
deleted file mode 100644
index 1bf10ac..0000000
--- a/src/components/InstallationButton.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ButtonItem, PanelSectionRow } from "@decky/ui";
-import t from '../i18n/i18n';
-
-interface InstallationButtonProps {
- isInstalled: boolean;
- isInstalling: boolean;
- isUninstalling: boolean;
- onInstall: () => void;
- onUninstall: () => void;
-}
-
-export function InstallationButton({
- isInstalled,
- isInstalling,
- isUninstalling,
- onInstall,
- onUninstall
-}: InstallationButtonProps) {
- const label = isInstalling
- ? t('INSTALL_INSTALLING', 'Installing...')
- : isUninstalling
- ? t('INSTALL_UNINSTALLING', 'Uninstalling...')
- : isInstalled
- ? t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK')
- : t('INSTALL_INSTALL_BTN', 'Install LSFG-VK');
-
- return (
- <PanelSectionRow>
- <ButtonItem
- layout="below"
- onClick={isInstalled ? onUninstall : onInstall}
- disabled={isInstalling || isUninstalling}
- >
- {label}
- </ButtonItem>
- </PanelSectionRow>
- );
-}
diff --git a/src/components/NowPlayingSummary.tsx b/src/components/NowPlayingSummary.tsx
new file mode 100644
index 0000000..adc5d10
--- /dev/null
+++ b/src/components/NowPlayingSummary.tsx
@@ -0,0 +1,16 @@
+import { Field, PanelSection, PanelSectionRow } from "@decky/ui";
+
+interface Props {
+ title: string;
+ details: string[];
+}
+
+export function NowPlayingSummary({ title, details }: Props) {
+ return (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field label={title} description={details.filter(Boolean).join(" | ")} />
+ </PanelSectionRow>
+ </PanelSection>
+ );
+}
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 5bce5c4..4c22fb7 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,8 +1,8 @@
-import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
-import { useState } from "react";
+import { Focusable } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
+import { NowPlayingSummary } from "./NowPlayingSummary";
interface Props {
game: GameTarget;
@@ -11,12 +11,9 @@ interface Props {
fieldName: keyof ConfigurationData,
value: boolean | number | string | string[],
) => Promise<void>;
- onEnable: (appid: string) => Promise<boolean>;
- onRepair: (appid: string) => Promise<boolean>;
}
function targetDescription(game: GameTarget): string {
- if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
return game.nonSteam ? "Non-Steam" : "Steam";
}
@@ -24,79 +21,18 @@ 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 title="Now Playing">
- <PanelSectionRow>
- <Field label={game.name} description={targetDescription(game)} />
- </PanelSectionRow>
- </PanelSection>
- {!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}
- />
- )}
+ <NowPlayingSummary
+ title={game.name}
+ details={[targetDescription(game), `Controls: ${game.name} profile`]}
+ />
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={onConfigChange}
+ showWorkarounds={false}
+ />
</Focusable>
);
}
diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx
index df43c5e..ff072cb 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SetupTab.tsx
@@ -1,15 +1,6 @@
-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";
+import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui";
+import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi";
+import t from "../i18n/i18n";
interface SetupTabProps {
isInstalled: boolean;
@@ -19,195 +10,91 @@ interface SetupTabProps {
steamBranchStatus: SteamBranchStatus | null;
isInstalling: boolean;
isUninstalling: boolean;
+ globalConfig: GlobalConfig;
+ showDebugTab: boolean;
+ onGlobalConfigChange: (config: GlobalConfig) => Promise<boolean>;
+ onShowDebugTabChange: (value: boolean) => void;
onInstall: () => void;
onUninstall: () => void;
- flatpakRelevant: boolean;
}
-function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) {
- const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null);
- const [advanced, setAdvanced] = useState(false);
- const [operation, setOperation] = useState<string | null>(null);
-
- 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 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
- 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 () => {
- setOperation("cleanup");
- try {
- 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 {
- setOperation(null);
- }
- }}
- onCancel={() => {}}
- />,
- );
- };
+export function SetupTab(props: SetupTabProps) {
+ const {
+ isInstalled,
+ installationStatus,
+ losslessScalingInstalled,
+ losslessScalingStatus,
+ steamBranchStatus,
+ isInstalling,
+ isUninstalling,
+ globalConfig,
+ showDebugTab,
+ onGlobalConfigChange,
+ onShowDebugTabChange,
+ onInstall,
+ onUninstall,
+ } = props;
+ const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true;
+ const buttonLabel = isInstalling
+ ? t("INSTALL_INSTALLING", "Installing...")
+ : isUninstalling
+ ? t("INSTALL_UNINSTALLING", "Uninstalling...")
+ : isInstalled
+ ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK")
+ : t("INSTALL_INSTALL_BTN", "Install LSFG-VK");
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 && (
+ <>
+ <PanelSection title="Setup">
+ <PanelSectionRow>
+ <Field
+ label="Lossless Scaling"
+ description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <Field label="LSFG-VK" description={installationStatus} />
+ </PanelSectionRow>
+ {steamBranchStatus?.installed && (
+ <PanelSectionRow>
+ <Field
+ label="Steam branch"
+ description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`}
+ />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={isInstalled ? onUninstall : onInstall}
+ disabled={isInstalling || isUninstalling}
+ >
+ {buttonLabel}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ {isInstalled && (
<>
- {status.supported_branches.map((branch) => (
- <PanelSectionRow key={branch}>
+ <PanelSection title="Global settings">
+ <PanelSectionRow>
<ToggleField
- label={branch}
- description={
- 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}
+ label="FP16 Acceleration"
+ checked={!globalConfig.no_fp16}
+ onChange={(value) => void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })}
/>
</PanelSectionRow>
- ))}
- {status.ownership_uncertain && (
+ </PanelSection>
+ <PanelSection title="Advanced">
<PanelSectionRow>
- <Field label="Ownership metadata is uncertain" description="Cleanup is disabled until the metadata is repaired." />
+ <ToggleField
+ label="Show config file tab"
+ checked={showDebugTab}
+ onChange={onShowDebugTabChange}
+ />
</PanelSectionRow>
- )}
- <PanelSectionRow>
- <ButtonItem
- layout="below"
- disabled={operation !== null || status.ownership_uncertain || status.owned_branches.length === 0}
- onClick={confirmCleanup}
- >
- {operation === "cleanup" ? "Removing..." : "Remove plugin-installed extensions"}
- </ButtonItem>
- </PanelSectionRow>
+ </PanelSection>
</>
)}
- </PanelSection>
- );
-}
-
-export function SetupTab({
- isInstalled,
- installationStatus,
- losslessScalingInstalled,
- losslessScalingStatus,
- steamBranchStatus,
- isInstalling,
- 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>
- <FlatpakSupportDiagnostics relevant={flatpakRelevant} />
</>
);
}
diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx
deleted file mode 100644
index b1a98e5..0000000
--- a/src/components/StatusDisplay.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Field, PanelSectionRow } from "@decky/ui";
-import type { SteamBranchStatus } from "../api/lsfgApi";
-
-interface StatusDisplayProps {
- installationStatus: string;
- losslessScalingInstalled: boolean;
- losslessScalingStatus: string;
- steamBranchStatus: SteamBranchStatus | null;
-}
-
-export function StatusDisplay({
- installationStatus,
- losslessScalingInstalled,
- losslessScalingStatus,
- steamBranchStatus
-}: StatusDisplayProps) {
- const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true;
-
- return (
- <>
- <PanelSectionRow>
- <Field
- label="Lossless Scaling"
- description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"}
- />
- </PanelSectionRow>
- <PanelSectionRow>
- <Field label="LSFG-VK" description={installationStatus} />
- </PanelSectionRow>
-
- {steamBranchStatus?.installed && (
- <PanelSectionRow>
- <Field
- label="Steam branch"
- description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`}
- />
- </PanelSectionRow>
- )}
- </>
- );
-}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index 5587392..3de5ec1 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -1,7 +1,6 @@
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";
@@ -9,7 +8,6 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
interface WorkaroundsSectionProps {
appId: string;
nonSteam: boolean;
- transport: TargetTransport;
onRepair?: () => Promise<boolean>;
}
@@ -73,17 +71,15 @@ function usePersistentCollapsed() {
useEffect(() => {
try {
localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed));
- } catch {
- // Persisting the view preference is optional.
- }
+ } catch {}
}, [collapsed]);
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) {
+export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) {
const [collapsed, toggleCollapsed] = usePersistentCollapsed();
- const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport);
+ const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam);
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 6856e76..bca6f6f 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,6 +1,4 @@
export { Content } from "./Content";
-export { StatusDisplay } from "./StatusDisplay";
-export { InstallationButton } from "./InstallationButton";
export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
export { ConfigurationTab } from "./ConfigurationTab";
diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts
new file mode 100644
index 0000000..f936271
--- /dev/null
+++ b/src/hooks/useFlatpakConfiguration.ts
@@ -0,0 +1,160 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ enableFlatpakApp,
+ getFlatpakApps,
+ getRunningFlatpakApps,
+ removeFlatpakApp,
+ setFlatpakWorkaroundState,
+ updateFlatpakConfig,
+ type FlatpakApp,
+ type LsfgConfig,
+ type RunningFlatpakApp,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import { selectMostRecentRunningFlatpak } from "../utils/nowPlaying";
+import { showErrorToast } from "../utils/toastUtils";
+
+type FlatpakOperationResult = {
+ success: boolean;
+ error?: string | null;
+ config?: LsfgConfig | null;
+ state?: WorkaroundState | null;
+};
+
+export function useFlatpakConfiguration(enabled: boolean) {
+ const [apps, setApps] = useState<FlatpakApp[]>([]);
+ const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [busyAppId, setBusyAppId] = useState("");
+
+ const reload = useCallback(async () => {
+ if (!enabled) {
+ setApps([]);
+ return;
+ }
+ setLoading(true);
+ try {
+ const result = await getFlatpakApps();
+ if (!result.success) throw new Error(result.error || "Could not list Flatpak applications");
+ setApps(result.apps || []);
+ } catch (error) {
+ showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error));
+ } finally {
+ setLoading(false);
+ }
+ }, [enabled]);
+
+ const pollRunning = useCallback(async () => {
+ if (!enabled) {
+ setRunningApps([]);
+ return;
+ }
+ try {
+ const result = await getRunningFlatpakApps();
+ if (result.success) setRunningApps(result.apps || []);
+ } catch {}
+ }, [enabled]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ useEffect(() => {
+ void pollRunning();
+ if (!enabled) return;
+ const interval = window.setInterval(() => void pollRunning(), 2000);
+ return () => window.clearInterval(interval);
+ }, [enabled, pollRunning]);
+
+ const operate = useCallback(async (
+ appId: string,
+ operation: () => Promise<FlatpakOperationResult>,
+ refresh = true,
+ ): Promise<FlatpakOperationResult> => {
+ if (busyAppId) return { success: false };
+ setBusyAppId(appId);
+ try {
+ const result = await operation();
+ if (!result.success) throw new Error(result.error || "Flatpak operation failed");
+ if (refresh) {
+ await reload();
+ await pollRunning();
+ }
+ return result;
+ } catch (error) {
+ showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error));
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ } finally {
+ setBusyAppId("");
+ }
+ }, [busyAppId, pollRunning, reload]);
+
+ const enableApp = useCallback(async (appId: string) => (
+ await operate(appId, () => enableFlatpakApp(appId))
+ ).success, [operate]);
+ const removeApp = useCallback(async (appId: string) => (
+ await operate(appId, () => removeFlatpakApp(appId))
+ ).success, [operate]);
+ const enableAll = useCallback(async (): Promise<void> => {
+ if (busyAppId) return;
+ const available = apps.filter((app) => (
+ !app.enabled && !(app.prepared && !app.owned) && !app.error
+ ));
+ for (const app of available) {
+ const result = await operate(app.app_id, () => enableFlatpakApp(app.app_id), false);
+ if (!result.success) break;
+ }
+ await reload();
+ await pollRunning();
+ }, [apps, busyAppId, operate, pollRunning, reload]);
+ const removeAll = useCallback(async (): Promise<void> => {
+ if (busyAppId) return;
+ for (const app of apps.filter((item) => item.enabled)) {
+ const result = await operate(app.app_id, () => removeFlatpakApp(app.app_id), false);
+ if (!result.success) break;
+ }
+ await reload();
+ await pollRunning();
+ }, [apps, busyAppId, operate, pollRunning, reload]);
+ const updateConfig = useCallback(
+ async (appId: string, config: LsfgConfig) => {
+ const result = await operate(appId, () => updateFlatpakConfig(appId, config), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, config: result.config || config } : app
+ )));
+ }
+ return result.success;
+ },
+ [operate],
+ );
+ const updateWorkarounds = useCallback(
+ async (appId: string, state: WorkaroundState) => {
+ const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, workarounds: result.state || state } : app
+ )));
+ }
+ return result.success;
+ },
+ [operate],
+ );
+
+ const runningApp = useMemo(() => selectMostRecentRunningFlatpak(apps, runningApps), [apps, runningApps]);
+
+ return {
+ apps,
+ runningApps,
+ runningApp,
+ loading,
+ busyAppId,
+ reload,
+ enableApp,
+ enableAll,
+ removeApp,
+ removeAll,
+ updateConfig,
+ updateWorkarounds,
+ };
+}
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index c70d589..fd8dfb1 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
-import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
+import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions";
+import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
@@ -23,7 +23,6 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
appid: String(appid >>> 0),
name,
nonSteam: true,
- transport: { kind: "host" },
}];
});
} catch {
@@ -40,23 +39,6 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta
return Array.from(games.values());
}
-function selectShortcutExecutable(
- target: GameTarget,
- ...candidates: Array<string | null | undefined>
-): string | undefined {
- const absolute = candidates
- .map((candidate) => candidate?.trim())
- .find((candidate) => candidate && candidate.startsWith("/"));
- if (absolute) return absolute;
-
- // 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,
@@ -97,6 +79,7 @@ export function useGameConfiguration() {
previousQuickAccessVisible.current = quickAccessVisible;
if (initialLoad || becameVisible) void load();
}, [load, quickAccessVisible]);
+
useEffect(() => {
const poll = () => {
if (!configsLoaded) return;
@@ -106,16 +89,25 @@ export function useGameConfiguration() {
const installed = installedGames.find((game) => game.appid === appid);
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
- setRunningGame((current) => current?.appid === appid ? current : {
- ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }),
+ const next: GameTarget = {
+ ...(installed || { appid, name, nonSteam: false }),
name,
configured: games.some((game) => game.appid === appid),
- });
+ };
+ setRunningGame((current) => (
+ current?.appid === next.appid
+ && current.name === next.name
+ && current.nonSteam === next.nonSteam
+ && current.configured === next.configured
+ ? current
+ : next
+ ));
};
poll();
const interval = window.setInterval(poll, 2000);
return () => window.clearInterval(interval);
}, [configsLoaded, games, installedGames]);
+
useEffect(() => {
const appid = runningGame?.appid || null;
if (appid !== previousRunningAppId.current) {
@@ -126,125 +118,67 @@ export function useGameConfiguration() {
const targets = useMemo<GameTarget[]>(() => {
const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) }));
- for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true });
+ 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]);
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
-
- const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise<boolean> => {
- if (target.transport.kind !== "flatpak") return true;
- const result = await ensureFlatpakSupport(target.transport.flatpakAppId);
- if (!result.success || result.support_status !== "ready") {
- showErrorToast(
- "Flatpak support unavailable",
- result.error || result.message || "The required Flatpak runtime extension is not ready",
- );
- return false;
- }
- return true;
- }, []);
+ const runningConfig = runningGame
+ ? games.find((game) => game.appid === runningGame.appid)?.config || template
+ : template;
const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
const appId = Number(target.appid);
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ let newState = false;
+ let stateWriteAttempted = false;
+ let wrapperPath = getDefaultWrapperPath();
try {
const existing = await getWorkaroundState(target.appid);
if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
- const current = await readSteamLaunchOptions(appId, target.nonSteam);
- const wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
- const oldState = existing.state;
- const oldShortcutExe = existing.shortcut_exe || undefined;
- const oldCommandTokenAdded = existing.command_token_added === true;
- const oldTransport = existing.transport || target.transport;
- if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) {
- throw new Error("Managed shortcut Target has no saved original executable");
- }
- if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) {
- throw new Error("Shortcut Target changed externally; refusing to replace it");
- }
- if (target.nonSteam && !oldState && current.target === wrapperPath) {
- throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown");
- }
- const state = oldState || { ...DEFAULT_WORKAROUND_STATE };
- const originalExecutable = 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);
- const initialStateResult = await setWorkaroundState(
+ wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ const state = existing.state || { ...DEFAULT_WORKAROUND_STATE };
+ const commandTokenAdded = existing.command_token_added === true;
+ newState = !existing.state;
+ integration = await installWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ commandTokenAdded,
+ );
+ stateWriteAttempted = true;
+ const saved = await setWorkaroundState(
target.appid,
state,
- originalExecutable || null,
- oldCommandTokenAdded,
- target.transport,
+ integration.commandTokenAdded,
+ target.nonSteam,
);
- if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state");
-
- let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
- try {
- integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded);
- const finalStateResult = await setWorkaroundState(
- target.appid,
- state,
- target.nonSteam
- ? (selectShortcutExecutable(
- target,
- integration.originalExecutable,
- originalExecutable,
- target.transport.kind === "flatpak" ? target.executable : undefined,
- ) || null)
- : null,
- integration.commandTokenAdded,
- target.transport,
- );
- if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state");
- return true;
- } catch (error) {
- let rollbackSucceeded = true;
- if (!initialIntegration && integration) {
- try {
- await removeWrapperIntegration(
- appId,
- target.nonSteam,
- wrapperPath,
- target.nonSteam
- ? (selectShortcutExecutable(
- target,
- integration?.originalExecutable,
- originalExecutable,
- target.transport.kind === "flatpak" ? target.executable : undefined,
- ) || undefined)
- : undefined,
- integration?.commandTokenAdded ?? oldCommandTokenAdded,
- );
- } catch (rollbackError) {
- showErrorToast("Workaround rollback failed", asError(rollbackError).message);
- rollbackSucceeded = false;
- }
+ if (!saved.success) throw new Error(saved.error || "Could not save workaround state");
+ return true;
+ } catch (error) {
+ let rollbackSucceeded = true;
+ if (integration?.changed) {
+ try {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ integration.commandTokenAdded,
+ );
+ } catch (rollbackError) {
+ showErrorToast("Workaround rollback failed", asError(rollbackError).message);
+ rollbackSucceeded = false;
}
- if (rollbackSucceeded) {
- const restored = oldState
- ? await setWorkaroundState(
- target.appid,
- oldState,
- oldShortcutExe || null,
- oldCommandTokenAdded,
- oldTransport,
- )
- : await removeWorkaroundState(target.appid);
- if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state");
+ }
+ if (rollbackSucceeded && newState && stateWriteAttempted) {
+ const restored = await removeWorkaroundState(target.appid);
+ if (!restored.success) {
+ showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state");
+ rollbackSucceeded = false;
}
- throw error;
}
- } catch (error) {
showErrorToast("Could not initialize workarounds", asError(error).message);
return false;
}
@@ -257,21 +191,12 @@ export function useGameConfiguration() {
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();
- if (existing.state) {
- await removeWrapperIntegration(
- appId,
- target.nonSteam,
- wrapperPath,
- existing.shortcut_exe || undefined,
- existing.command_token_added === true,
- );
- } else {
- const current = await readSteamLaunchOptions(appId, target.nonSteam);
- if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) {
- throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown");
- }
- await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath);
- }
+ 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;
@@ -281,33 +206,78 @@ export function useGameConfiguration() {
}
}, [installedGames]);
- const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => {
- const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (!selectedTarget?.name) return;
- // The profile owns its wrapper integration. Keep this check on every
- // configuration save so an external edit is detected before the profile
- // is changed; toggles update the sidecar only.
- if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return;
- const result = await updateGameConfig(selectedAppId, selectedTarget.name, next);
+ const 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,
+ wrapperPath,
+ entry.command_token_added,
+ );
+ const removed = await removeWorkaroundState(entry.appid);
+ if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
+ cleaned.add(entry.appid);
+ }
+
+ // Also clean configured targets whose sidecar entry was lost. This
+ // removes an old wrapper and only the plugin-managed launch pieces.
+ for (const target of targets.filter((item) => item.configured && installedGames.some((game) => game.appid === item.appid))) {
+ if (!cleaned.has(target.appid) && !(await removeTargetWorkarounds(target))) return false;
+ }
+ return true;
+ } catch (error) {
+ showErrorToast("Could not clean up game launch options", asError(error).message);
+ return false;
+ }
+ }, [installedGames, removeTargetWorkarounds, targets]);
+
+ const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target?.name) return false;
+ if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false;
+ const result = await updateGameConfig(appid, target.name, next);
if (result.success) await load();
- }, [ensureTargetWorkarounds, load, selectedAppId, targets]);
+ return result.success;
+ }, [ensureTargetWorkarounds, load, targets]);
+
+ const save = useCallback(
+ async (next: ConfigurationData, cleanupLaunchOptions = false) => {
+ if (!selectedAppId) return false;
+ return saveFor(selectedAppId, next, cleanupLaunchOptions);
+ },
+ [saveFor, selectedAppId],
+ );
+
+ const updateGlobal = useCallback(async (next: GlobalConfig): Promise<boolean> => {
+ const result = await saveGlobalConfig(next);
+ if (!result.success) return false;
+ setGlobalConfig(result.global_config || next);
+ return true;
+ }, []);
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
- if (!(await ensureTargetFlatpakSupport(target))) return false;
if (!(await ensureTargetWorkarounds(target))) return false;
const result = await updateGameConfig(appid, target.name, template);
if (result.success) await load();
else await removeTargetWorkarounds(target);
return result.success;
- }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ }, [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) {
@@ -320,20 +290,11 @@ export function useGameConfiguration() {
}
}
await load();
- }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ }, [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;
- if (target.transport.kind === "flatpak") {
- const support = await repairFlatpakSupport(target.transport.flatpakAppId);
- if (!support.success || support.support_status !== "ready") {
- showErrorToast(
- "Flatpak support unavailable",
- support.error || support.message || "The required Flatpak runtime extension is not ready",
- );
- return false;
- }
- }
const success = await ensureTargetWorkarounds(target);
if (success) await load();
return success;
@@ -351,6 +312,7 @@ 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;
@@ -363,5 +325,5 @@ export function useGameConfiguration() {
}
}, [load, removeTargetWorkarounds, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load };
+ return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, cleanupAllWorkarounds, reload: load };
}
diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts
deleted file mode 100644
index 41189bd..0000000
--- a/src/hooks/useInstallationActions.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useState } from "react";
-import { installLsfgVk, uninstallLsfgVk } from "../api/lsfgApi";
-import {
- showInstallSuccessToast,
- showInstallErrorToast,
- showUninstallSuccessToast,
- showUninstallErrorToast
-} from "../utils/toastUtils";
-
-export function useInstallationActions() {
- const [isInstalling, setIsInstalling] = useState<boolean>(false);
- const [isUninstalling, setIsUninstalling] = useState<boolean>(false);
-
- const handleInstall = async (
- setIsInstalled: (value: boolean) => void,
- setInstallationStatus: (value: string) => void,
- reloadConfig?: () => Promise<void>,
- reloadStatus?: () => Promise<boolean>
- ) => {
- setIsInstalling(true);
- setInstallationStatus("Installing lsfg-vk...");
-
- try {
- const result = await installLsfgVk();
- if (result.success) {
- setIsInstalled(true);
- setInstallationStatus("lsfg-vk installed");
- showInstallSuccessToast();
-
- // Reload lsfg config after installation
- if (reloadConfig) {
- await reloadConfig();
- }
- if (reloadStatus) {
- await reloadStatus();
- }
- } else {
- setInstallationStatus(`Installation failed: ${result.error}`);
- showInstallErrorToast(result.error);
- }
- } catch (error) {
- setInstallationStatus(`Installation failed: ${error}`);
- showInstallErrorToast(String(error));
- } finally {
- setIsInstalling(false);
- }
- };
-
- const handleUninstall = async (
- setIsInstalled: (value: boolean) => void,
- setInstallationStatus: (value: string) => void,
- reloadStatus?: () => Promise<boolean>
- ) => {
- setIsUninstalling(true);
- setInstallationStatus("Uninstalling lsfg-vk...");
-
- try {
- const result = await uninstallLsfgVk();
- if (result.success) {
- setIsInstalled(false);
- setInstallationStatus("lsfg-vk uninstalled successfully!");
- if (reloadStatus) {
- await reloadStatus();
- }
- showUninstallSuccessToast();
- } else {
- setInstallationStatus(`Uninstallation failed: ${result.error}`);
- showUninstallErrorToast(result.error);
- }
- } catch (error) {
- setInstallationStatus(`Uninstallation failed: ${error}`);
- showUninstallErrorToast(String(error));
- } finally {
- setIsUninstalling(false);
- }
- };
-
- return {
- isInstalling,
- isUninstalling,
- handleInstall,
- handleUninstall
- };
-}
diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts
index 0b71ee9..0f51e90 100644
--- a/src/hooks/useLsfgHooks.ts
+++ b/src/hooks/useLsfgHooks.ts
@@ -1,16 +1,29 @@
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
import {
checkLsfgVkInstalled,
getLosslessScalingBranchStatus,
- type SteamBranchStatus
+ installLsfgVk,
+ uninstallLsfgVk,
+ type SteamBranchStatus,
} from "../api/lsfgApi";
+import {
+ showInstallErrorToast,
+ showInstallSuccessToast,
+ showUninstallErrorToast,
+ showUninstallSuccessToast,
+} from "../utils/toastUtils";
-export function useInstallationStatus() {
- const [isInstalled, setIsInstalled] = useState<boolean>(false);
- const [installationStatus, setInstallationStatus] = useState<string>("");
- const [losslessScalingInstalled, setLosslessScalingInstalled] = useState<boolean>(false);
- const [losslessScalingStatus, setLosslessScalingStatus] = useState<string>("");
+export function useInstallation(
+ reloadConfig?: () => Promise<void>,
+ beforeUninstall?: () => Promise<boolean>,
+) {
+ const [isInstalled, setIsInstalled] = useState(false);
+ const [installationStatus, setInstallationStatus] = useState("");
+ const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false);
+ const [losslessScalingStatus, setLosslessScalingStatus] = useState("");
const [steamBranchStatus, setSteamBranchStatus] = useState<SteamBranchStatus | null>(null);
+ const [isInstalling, setIsInstalling] = useState(false);
+ const [isUninstalling, setIsUninstalling] = useState(false);
const checkInstallation = async () => {
try {
@@ -25,13 +38,9 @@ export function useInstallationStatus() {
setIsInstalled(status.installed);
setLosslessScalingInstalled(status.lossless_scaling_installed);
setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed");
- if (status.installed) {
- setInstallationStatus("lsfg-vk Installed");
- } else {
- setInstallationStatus("lsfg-vk Not Installed");
- }
+ setInstallationStatus(status.installed ? "lsfg-vk Installed" : "lsfg-vk Not Installed");
return status.installed;
- } catch (error) {
+ } catch {
setSteamBranchStatus(null);
setLosslessScalingInstalled(false);
setLosslessScalingStatus("Lossless Scaling Not Installed");
@@ -41,17 +50,68 @@ export function useInstallationStatus() {
};
useEffect(() => {
- checkInstallation();
+ void checkInstallation();
}, []);
+ const install = async () => {
+ setIsInstalling(true);
+ setInstallationStatus("Installing lsfg-vk...");
+ try {
+ const result = await installLsfgVk();
+ if (!result.success) {
+ setInstallationStatus(`Installation failed: ${result.error}`);
+ showInstallErrorToast(result.error ?? undefined);
+ return;
+ }
+ setIsInstalled(true);
+ setInstallationStatus("lsfg-vk installed");
+ showInstallSuccessToast();
+ await reloadConfig?.();
+ await checkInstallation();
+ } catch (error) {
+ setInstallationStatus(`Installation failed: ${error}`);
+ showInstallErrorToast(String(error));
+ } finally {
+ setIsInstalling(false);
+ }
+ };
+
+ const uninstall = async () => {
+ setIsUninstalling(true);
+ setInstallationStatus("Uninstalling lsfg-vk...");
+ try {
+ if (beforeUninstall && !(await beforeUninstall())) {
+ setInstallationStatus("Uninstallation cancelled: could not clean up launch options");
+ return;
+ }
+ const result = await uninstallLsfgVk();
+ if (!result.success) {
+ setInstallationStatus(`Uninstallation failed: ${result.error}`);
+ showUninstallErrorToast(result.error ?? undefined);
+ return;
+ }
+ setIsInstalled(false);
+ setInstallationStatus("lsfg-vk uninstalled successfully!");
+ await checkInstallation();
+ showUninstallSuccessToast();
+ } catch (error) {
+ setInstallationStatus(`Uninstallation failed: ${error}`);
+ showUninstallErrorToast(String(error));
+ } finally {
+ setIsUninstalling(false);
+ }
+ };
+
return {
isInstalled,
installationStatus,
- setIsInstalled,
- setInstallationStatus,
losslessScalingInstalled,
losslessScalingStatus,
steamBranchStatus,
- checkInstallation
+ isInstalling,
+ isUninstalling,
+ install,
+ uninstall,
+ checkInstallation,
};
}
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index e9e44e1..ab33bb2 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -3,14 +3,12 @@ import {
getWorkaroundState,
removeWorkaroundState,
setWorkaroundState,
- type TargetTransport,
type WorkaroundState,
} from "../api/lsfgApi";
import {
getDefaultWrapperPath,
- hasWrapperLaunchIntegration,
installWrapperIntegration,
- isLegacyWrapperToken,
+ isWrapperIntegrationInstalled,
readSteamLaunchOptions,
removeWrapperIntegration,
subscribeSteamLaunchOptions,
@@ -45,8 +43,6 @@ export interface WorkaroundSnapshot {
wrapperOwned: boolean;
integrationInstalled: boolean;
commandTokenAdded: boolean;
- shortcutExe?: string | null;
- transport: TargetTransport;
}
interface PerAppWorkarounds {
@@ -61,26 +57,6 @@ 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,
- wrapperPath: string,
-): boolean {
- return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath);
-}
-
function makeSnapshot(
steam: SteamLaunchOptionsSnapshot,
result: Awaited<ReturnType<typeof getWorkaroundState>>,
@@ -88,75 +64,43 @@ function makeSnapshot(
): WorkaroundSnapshot {
if (!result.state) throw new Error("Workaround state is not initialized for this profile");
const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
- if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) {
- throw new Error("Managed shortcut Target has no saved original executable");
- }
return {
steam,
state: result.state,
wrapperPath,
wrapperOwned: result.wrapper_owned === true,
- integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath),
commandTokenAdded: result.command_token_added === true,
- shortcutExe: result.shortcut_exe,
- transport: result.transport || { kind: "host" },
};
}
async function adoptWorkaroundState(
appId: string,
nonSteam: boolean,
- transport: TargetTransport,
- steam: SteamLaunchOptionsSnapshot,
wrapperPath: string,
): Promise<WorkaroundSnapshot> {
- 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
- ? selectShortcutExecutable(transport, steam.target)
- : null;
- const initial = await setWorkaroundState(
- appId,
- DEFAULT_WORKAROUND_STATE,
- originalExecutable,
- false,
- transport,
- );
- if (!initial.success) throw new Error(initial.error || "Could not create workaround state");
let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
try {
- integration = await installWrapperIntegration(
- Number(appId),
- nonSteam,
- wrapperPath,
- );
+ integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false);
const finalized = await setWorkaroundState(
appId,
DEFAULT_WORKAROUND_STATE,
- nonSteam
- ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null)
- : null,
integration.commandTokenAdded,
- transport,
+ nonSteam,
);
if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state");
return makeSnapshot(integration.snapshot, finalized, nonSteam);
} catch (error) {
let rollbackSucceeded = true;
- if (integration) {
+ if (integration?.changed) {
try {
await removeWrapperIntegration(
Number(appId),
nonSteam,
wrapperPath,
- nonSteam
- ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined)
- : undefined,
- integration?.commandTokenAdded ?? false,
+ integration.commandTokenAdded,
);
} catch {
- // Leave the owned integration in place rather than guessing at cleanup.
rollbackSucceeded = false;
}
}
@@ -168,11 +112,7 @@ async function adoptWorkaroundState(
}
}
-export function usePerAppWorkarounds(
- appId: string,
- nonSteam: boolean,
- transport: TargetTransport = { kind: "host" },
-): PerAppWorkarounds {
+export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds {
const [status, setStatus] = useState<WorkaroundLoadStatus>("loading");
const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -189,13 +129,11 @@ export function usePerAppWorkarounds(
return adoptWorkaroundState(
appId,
nonSteam,
- transport,
- steam,
result.wrapper_path || getDefaultWrapperPath(),
);
}
return makeSnapshot(steam, result, nonSteam);
- }, [appId, nonSteam, numericAppId, transport]);
+ }, [appId, nonSteam, numericAppId]);
const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
setSnapshot(next);
@@ -230,7 +168,7 @@ export function usePerAppWorkarounds(
setSnapshot((current) => current ? {
...current,
steam,
- integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath),
} : current);
},
(subscriptionError) => {
@@ -268,9 +206,8 @@ export function usePerAppWorkarounds(
const result = await setWorkaroundState(
appId,
nextState,
- current.shortcutExe ?? null,
current.commandTokenAdded,
- current.transport,
+ nonSteam,
);
if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
applySnapshot({
@@ -278,9 +215,7 @@ export function usePerAppWorkarounds(
state: result.state,
wrapperPath: result.wrapper_path || current.wrapperPath,
wrapperOwned: result.wrapper_owned === true,
- shortcutExe: result.shortcut_exe,
commandTokenAdded: result.command_token_added === true,
- transport: result.transport || current.transport,
});
return true;
} catch (updateError) {
diff --git a/src/styles.ts b/src/styles.ts
index 1bc089b..58c6b01 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -10,6 +10,10 @@ export const tabStyles = `
padding-right: 8px !important;
}
+ .lsfg-vk-tabs .lsfg-vk-tab-content {
+ padding-bottom: 96px; // workaround for in-game bottom bar padding behaving differently than in launcher, remove later?
+ }
+
.lsfg-vk-tabs [role="tablist"] {
display: flex;
flex-wrap: nowrap;
@@ -31,4 +35,17 @@ export const tabStyles = `
display: block;
margin: 0;
}
+
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"],
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div,
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div,
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] [role="tab"] {
+ animation: none !important;
+ transition: none !important;
+ }
+
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div {
+ scroll-behavior: auto !important;
+ scroll-snap-type: none !important;
+ }
`;
diff --git a/src/types.d.ts b/src/types.d.ts
index df433e0..4adad61 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -16,8 +16,6 @@ declare module "*.jpg" {
interface SteamAppDetails {
strLaunchOptions?: string;
strShortcutLaunchOptions?: string;
- strShortcutExe?: string;
- strShortcutStartDir?: string;
}
interface SteamAppDetailsRegistration {
@@ -31,7 +29,7 @@ interface SteamApps {
): SteamAppDetailsRegistration;
SetAppLaunchOptions(appId: number, options: string): void | Promise<void>;
SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>;
- SetShortcutExe(appId: number, executable: string): void | Promise<void>;
+ TerminateApp(appId: string, param1: boolean): void;
GetAllShortcuts?(): Promise<unknown[]>;
}
diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts
new file mode 100644
index 0000000..380c9f5
--- /dev/null
+++ b/src/utils/nowPlaying.ts
@@ -0,0 +1,76 @@
+import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
+
+export type NowPlayingTarget =
+ | {
+ kind: "flatpak";
+ app: FlatpakApp;
+ launcher: GameTarget | null;
+ }
+ | {
+ kind: "steam";
+ game: GameTarget;
+ };
+
+function numericValue(value: number | null | undefined): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : -1;
+}
+
+function numericPid(value: string | undefined): number {
+ return value && /^\d+$/.test(value) ? Number(value) : -1;
+}
+
+function compareRunningProcesses(a: RunningFlatpakApp, b: RunningFlatpakApp): number {
+ if (a.active !== b.active) return a.active ? -1 : 1;
+ const startDifference = numericValue(b.start_time) - numericValue(a.start_time);
+ if (startDifference !== 0) return startDifference;
+ return numericPid(b.pid) - numericPid(a.pid);
+}
+
+export function selectMostRecentRunningFlatpak(
+ apps: FlatpakApp[],
+ runningApps: RunningFlatpakApp[],
+): FlatpakApp | null {
+ const newestProcessByApp = new Map<string, RunningFlatpakApp>();
+ for (const running of runningApps) {
+ const current = newestProcessByApp.get(running.app_id);
+ if (!current || compareRunningProcesses(running, current) < 0) {
+ newestProcessByApp.set(running.app_id, running);
+ }
+ }
+
+ const candidates = Array.from(newestProcessByApp.values())
+ .map((running) => ({
+ running,
+ app: apps.find((app) => app.app_id === running.app_id) || null,
+ }))
+ .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null);
+ const activeCandidates = candidates.filter(({ running }) => running.active);
+ const eligibleCandidates = activeCandidates.length > 0
+ ? activeCandidates
+ : candidates.length === 1
+ ? candidates
+ : [];
+
+ eligibleCandidates.sort((a, b) => {
+ const processDifference = compareRunningProcesses(a.running, b.running);
+ if (processDifference !== 0) return processDifference;
+ return a.running.app_id.localeCompare(b.running.app_id);
+ });
+
+ return eligibleCandidates[0]?.app || null;
+}
+
+export function resolveNowPlayingTarget(
+ runningGame: GameTarget | null,
+ runningFlatpak: FlatpakApp | null,
+): NowPlayingTarget | null {
+ if (runningGame && !runningGame.nonSteam) {
+ return runningGame.configured ? { kind: "steam", game: runningGame } : null;
+ }
+ if (runningFlatpak) {
+ return { kind: "flatpak", app: runningFlatpak, launcher: runningGame?.nonSteam ? runningGame : null };
+ }
+ if (runningGame?.configured) return { kind: "steam", game: runningGame };
+ return null;
+}
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
index e00b32d..f03eeab 100644
--- a/src/utils/steamLaunchOptions.ts
+++ b/src/utils/steamLaunchOptions.ts
@@ -12,146 +12,88 @@ export const LEGACY_WRAPPER_TOKENS = new Set([
]);
const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/;
-
const MANAGED_ENV_KEYS = new Set([
- "ENABLE_GAMESCOPE_WSI",
- "DISABLE_GAMESCOPE_WSI",
- "DXVK_HDR",
- "SteamDeck",
- "DISABLE_VKBASALT",
- "ENABLE_VKBASALT",
- "MESA_LOADER_DRIVER_OVERRIDE",
- "__GLX_VENDOR_LIBRARY_NAME",
- "GALLIUM_DRIVER",
- "DXVK_FRAME_RATE",
+ "ENABLE_GAMESCOPE_WSI", "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck",
+ "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE",
+ "__GLX_VENDOR_LIBRARY_NAME", "GALLIUM_DRIVER", "DXVK_FRAME_RATE",
]);
-
const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i;
-interface LaunchToken {
- raw: string;
- value: string;
-}
-
+interface LaunchToken { raw: string; value: string; }
export interface SteamLaunchOptionsSnapshot {
appId: number;
nonSteam: boolean;
options: string;
- target: string;
details: SteamAppDetails;
}
-
export interface WrapperIntegrationResult {
snapshot: SteamLaunchOptionsSnapshot;
- originalExecutable?: string;
commandTokenAdded: boolean;
+ changed: boolean;
}
-function validateAppId(appId: number): void {
- if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID");
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
}
-function getSteamApps(): Partial<SteamApps> | undefined {
- return (globalThis as typeof globalThis & {
- SteamClient?: { Apps?: Partial<SteamApps> };
- }).SteamClient?.Apps;
+function apps(): Partial<SteamApps> | undefined {
+ return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial<SteamApps> } }).SteamClient?.Apps;
}
-interface TimerHost {
- setTimeout(handler: () => void, timeout: number): number;
- clearTimeout(timeout: number): void;
+function validateAppId(appId: number): void {
+ if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID");
}
-function timerHost(): TimerHost {
- if (typeof window !== "undefined") {
- return {
- setTimeout: (handler, timeout) => window.setTimeout(handler, timeout),
- clearTimeout: (timeout) => window.clearTimeout(timeout),
- };
- }
+function timer() {
+ const host = typeof window !== "undefined" ? window : globalThis;
return {
- setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number,
- clearTimeout: (timeout) => globalThis.clearTimeout(timeout),
+ set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number,
+ clear: (id: number) => host.clearTimeout(id),
};
}
-function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
+function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
return {
appId,
nonSteam,
options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "",
- target: nonSteam ? details.strShortcutExe || "" : "",
details,
};
}
-function asError(error: unknown): Error {
- return error instanceof Error ? error : new Error(String(error));
-}
-
-function registerSteamAppDetails(
- appId: number,
- onDetails: (details: SteamAppDetails) => boolean | void,
-): () => void {
+function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void {
validateAppId(appId);
- const apps = getSteamApps();
- const registerForAppDetails = apps?.RegisterForAppDetails;
- if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable");
-
+ const register = apps()?.RegisterForAppDetails;
+ if (!register) throw new Error("Steam app-details API is unavailable");
let active = true;
- let unregisterPending = false;
let registration: SteamAppDetailsRegistration | undefined;
const unsubscribe = () => {
active = false;
- if (!registration) {
- unregisterPending = true;
- return;
- }
- try {
- registration.unregister();
- } catch {
- // Steam can invalidate a registration while details are refreshing.
- }
+ try { registration?.unregister(); } catch {}
};
-
- try {
- registration = registerForAppDetails.call(apps, appId, (details) => {
- if (!active) return;
- if (onDetails(details || {}) === false && active) unsubscribe();
- });
- if (unregisterPending) {
- try {
- registration.unregister();
- } catch {
- // A synchronous callback can invalidate the registration before return.
- }
- }
- } catch (error) {
- throw asError(error);
- }
+ registration = register.call(apps(), appId, (details) => {
+ if (active && onDetails(details || {}) === false) unsubscribe();
+ });
+ if (!active) unsubscribe();
return unsubscribe;
}
export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> {
return new Promise((resolve, reject) => {
- let settled = false;
- let timeout: number | undefined;
+ let done = false;
let unsubscribe = () => {};
+ const clock = timer();
+ const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000);
const finish = (error?: unknown, details?: SteamAppDetails) => {
- if (settled) return;
- settled = true;
- if (timeout !== undefined) timerHost().clearTimeout(timeout);
+ if (done) return;
+ done = true;
+ clock.clear(timeout);
unsubscribe();
- if (error) {
- reject(asError(error));
- return;
- }
- resolve(snapshotFromDetails(appId, nonSteam, details || {}));
+ if (error) reject(asError(error));
+ else resolve(snapshot(appId, nonSteam, details || {}));
};
-
- timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000);
try {
- unsubscribe = registerSteamAppDetails(appId, (details) => {
+ unsubscribe = registerDetails(appId, (details) => {
finish(undefined, details);
return false;
});
@@ -164,34 +106,24 @@ export async function readSteamLaunchOptions(appId: number, nonSteam: boolean):
export function subscribeSteamLaunchOptions(
appId: number,
nonSteam: boolean,
- onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void,
+ onSnapshot: (value: SteamLaunchOptionsSnapshot) => void,
onError: (error: Error) => void,
): () => void {
- return registerSteamAppDetails(appId, (details) => {
- try {
- onSnapshot(snapshotFromDetails(appId, nonSteam, details));
- } catch (error) {
- onError(asError(error));
- }
+ return registerDetails(appId, (details) => {
+ try { onSnapshot(snapshot(appId, nonSteam, details)); }
+ catch (error) { onError(asError(error)); }
});
}
function decodeToken(raw: string): string {
let value = "";
let quote: "'" | '"' | null = null;
- for (let index = 0; index < raw.length; index += 1) {
- const character = raw[index];
- if (character === "\\" && quote !== "'" && index + 1 < raw.length) {
- value += raw[index + 1];
- index += 1;
- } else if (quote !== null) {
- if (character === quote) quote = null;
- else value += character;
- } else if (character === "'" || character === '"') {
- quote = character;
- } else {
- value += character;
- }
+ for (let i = 0; i < raw.length; i++) {
+ const c = raw[i];
+ if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i];
+ else if (quote) { if (c === quote) quote = null; else value += c; }
+ else if (c === "'" || c === '"') quote = c;
+ else value += c;
}
return value;
}
@@ -201,299 +133,190 @@ function tokenize(options: string): LaunchToken[] {
let start = -1;
let quote: "'" | '"' | null = null;
let escaped = false;
- for (let index = 0; index < options.length; index += 1) {
- const character = options[index];
- if (start < 0) {
- if (/\s/.test(character)) continue;
- start = index;
- }
- if (escaped) escaped = false;
- else if (character === "\\" && quote !== "'") escaped = true;
- else if (quote !== null) {
- if (character === quote) quote = null;
- } else if (character === "'" || character === '"') quote = character;
- else if (/\s/.test(character)) {
- const raw = options.slice(start, index);
- tokens.push({ raw, value: decodeToken(raw) });
- start = -1;
- }
- }
- if (start >= 0) {
- const raw = options.slice(start);
+ const push = (end: number) => {
+ if (start < 0) return;
+ const raw = options.slice(start, end);
tokens.push({ raw, value: decodeToken(raw) });
+ start = -1;
+ };
+ for (let i = 0; i < options.length; i++) {
+ const c = options[i];
+ if (start < 0) { if (/\s/.test(c)) continue; start = i; }
+ if (escaped) escaped = false;
+ else if (c === "\\" && quote !== "'") escaped = true;
+ else if (quote) { if (c === quote) quote = null; }
+ else if (c === "'" || c === '"') quote = c;
+ else if (/\s/.test(c)) push(i);
}
+ push(options.length);
return tokens;
}
-function serialize(tokens: readonly LaunchToken[]): string {
- return tokens.map((token) => token.raw).join(" ");
-}
-
-export function normalizeLaunchOptions(options: string): string {
- return serialize(tokenize(options));
-}
-
-function isCommandToken(token: LaunchToken): boolean {
- return token.raw.toLowerCase() === COMMAND_TOKEN;
-}
-
-function commandIndex(tokens: readonly LaunchToken[]): number {
- return tokens.findIndex(isCommandToken);
-}
-
-function isAssignment(token: LaunchToken): boolean {
- return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value);
-}
-
-function isLegacyToken(value: string): boolean {
- return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value);
-}
+const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" ");
+const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN);
+const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value);
+const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value);
+const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value);
-export function isLegacyWrapperToken(value: string): boolean {
- return isLegacyToken(decodeToken(value));
-}
+export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options));
+export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value));
-function isWrapperToken(value: string, wrapperPath: string): boolean {
- return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value);
-}
-
-function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean {
- const index = commandIndex(tokens);
- const prefixEnd = index >= 0 ? index : tokens.length;
- const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath));
- if (retained.length === tokens.length) return false;
- tokens.splice(0, tokens.length, ...retained);
- return true;
-}
-
-function removeLegacyTokens(tokens: LaunchToken[]): boolean {
- const index = commandIndex(tokens);
- const prefixEnd = index >= 0 ? index : tokens.length;
- const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value));
- if (retained.length === tokens.length) return false;
- tokens.splice(0, tokens.length, ...retained);
+function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean {
+ const command = commandIndex(tokens);
+ const prefixEnd = command >= 0 ? command : tokens.length;
+ const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value));
+ if (kept.length === tokens.length) return false;
+ tokens.splice(0, tokens.length, ...kept);
return true;
}
-function leadingAssignments(tokens: readonly LaunchToken[]): number {
- let count = 0;
- while (count < tokens.length && isAssignment(tokens[count])) count += 1;
- return count;
-}
-
-function wrapperToken(wrapperPath: string): LaunchToken {
- return { raw: wrapperPath, value: wrapperPath };
-}
-
-export interface LaunchOptionRewrite {
- options: string;
- commandTokenAdded: boolean;
-}
-
-/** Add one exact wrapper token immediately before Steam's command macro. */
-export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite {
+function installLaunchOption(
+ options: string,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+ shortcutLaunchOptions = false,
+) {
const tokens = tokenize(options);
- removeLegacyTokens(tokens);
- let index = commandIndex(tokens);
- if (index >= 0) {
- const currentWrapper = tokens[index - 1];
- if (currentWrapper && currentWrapper.value === wrapperPath) {
- return { options: serialize(tokens), commandTokenAdded: false };
- }
- const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath);
- tokens.splice(0, tokens.length, ...retained);
- index = commandIndex(tokens);
- tokens.splice(index, 0, wrapperToken(wrapperPath));
+ removeMatchingWrappers(tokens, isLegacyToken);
+ let command = commandIndex(tokens);
+ if (command >= 0) {
+ if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false };
+ removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath);
+ command = commandIndex(tokens);
+ tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath });
return { options: serialize(tokens), commandTokenAdded: false };
}
-
- const insertion = leadingAssignments(tokens);
- const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-");
- if (tokens.length !== insertion && !argumentsOnly) {
+ let insertion = 0;
+ while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++;
+ if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) {
throw new Error("Launch options do not contain %command%; refusing to guess a launcher command");
}
- tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN });
+ tokens.splice(insertion, 0,
+ { raw: wrapperPath, value: wrapperPath },
+ { raw: COMMAND_TOKEN, value: COMMAND_TOKEN },
+ );
return { options: serialize(tokens), commandTokenAdded: true };
}
-/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */
+export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) {
+ return installLaunchOption(options, wrapperPath);
+}
+
export function removeWrapperLaunchOption(
options: string,
wrapperPath = DEFAULT_WRAPPER_PATH,
commandTokenAdded = false,
): string {
const tokens = tokenize(options);
- const removed = removeWrapperTokens(tokens, wrapperPath);
- if (removed && commandTokenAdded) {
- const index = commandIndex(tokens);
- if (index >= 0) tokens.splice(index, 1);
+ if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) {
+ const command = commandIndex(tokens);
+ if (command >= 0) tokens.splice(command, 1);
}
return serialize(tokens);
}
function encodeAssignmentValue(value: string): string {
- if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value;
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+ return /^[A-Za-z0-9_./:+,%=-]+$/.test(value)
+ ? value
+ : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
-function cleanDxvkConfigValue(value: string): string | null {
- const retained = value
- .split(";")
- .map((segment) => segment.trim())
- .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment));
- return retained.length > 0 ? retained.join("; ") : null;
-}
-
-/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */
export function cleanupPluginAssignments(options: string): string {
const tokens = tokenize(options);
- const index = commandIndex(tokens);
- const prefixEnd = index >= 0 ? index : tokens.length;
- const retained: LaunchToken[] = [];
- for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
- const token = tokens[tokenIndex];
- if (tokenIndex >= prefixEnd || !isAssignment(token)) {
- retained.push(token);
- continue;
- }
- const separator = token.value.indexOf("=");
- const key = token.value.slice(0, separator);
+ const command = commandIndex(tokens);
+ const prefixEnd = command >= 0 ? command : tokens.length;
+ return serialize(tokens.flatMap((token, i) => {
+ if (i >= prefixEnd || !isAssignment(token)) return [token];
+ const split = token.value.indexOf("=");
+ const key = token.value.slice(0, split);
if (key === "DXVK_CONFIG") {
- const cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1));
- if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` });
- continue;
+ const value = token.value.slice(split + 1).split(";").map((part) => part.trim())
+ .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; ");
+ return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : [];
}
- if (!MANAGED_ENV_KEYS.has(key)) retained.push(token);
- }
- return serialize(retained);
+ return MANAGED_ENV_KEYS.has(key) ? [] : [token];
+ }));
}
export function cleanupLegacyLaunchOptions(options: string): string {
const tokens = tokenize(options);
- removeLegacyTokens(tokens);
+ removeMatchingWrappers(tokens, isLegacyToken);
return serialize(tokens);
}
-
-export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string {
- const tokens = tokenize(options);
- removeWrapperTokens(tokens, wrapperPath);
- return cleanupPluginAssignments(serialize(tokens));
-}
-
-export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string {
- return cleanupPluginLaunchOptions(options, wrapperPath);
-}
-
+export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) =>
+ cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath));
+export const cleanupLegacyWrapper = cleanupPluginLaunchOptions;
export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean {
const tokens = tokenize(options);
- const index = commandIndex(tokens);
- return index > 0 && tokens[index - 1].value === wrapperPath;
+ const command = commandIndex(tokens);
+ return command > 0 && tokens[command - 1].value === wrapperPath;
}
-function delay(milliseconds: number): Promise<void> {
- return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds));
-}
-
-async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> {
- const apps = getSteamApps();
- const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions;
- if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`);
- await Promise.resolve(setter.call(apps, appId, options));
+export function isWrapperIntegrationInstalled(
+ steam: SteamLaunchOptionsSnapshot,
+ _nonSteam: boolean,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+): boolean {
+ return hasWrapperLaunchIntegration(steam.options, wrapperPath);
}
-async function setShortcutExecutable(appId: number, executable: string): Promise<void> {
- const apps = getSteamApps();
- if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable");
- await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable));
+const queues = new Map<string, Promise<unknown>>();
+function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
+ const key = `${nonSteam ? "shortcut" : "app"}:${appId}`;
+ const previous = queues.get(key) || Promise.resolve();
+ const current = previous.catch(() => undefined).then(operation);
+ const cleanup = current.then(
+ () => { if (queues.get(key) === cleanup) queues.delete(key); },
+ () => { if (queues.get(key) === cleanup) queues.delete(key); },
+ );
+ queues.set(key, cleanup);
+ return current;
}
-async function waitForSnapshot(
+async function waitFor(
appId: number,
nonSteam: boolean,
- matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean,
+ matches: (value: SteamLaunchOptionsSnapshot) => boolean,
message: string,
): Promise<SteamLaunchOptionsSnapshot> {
const deadline = Date.now() + 5000;
let lastError: Error | null = null;
while (Date.now() <= deadline) {
try {
- const snapshot = await readSteamLaunchOptions(appId, nonSteam);
- if (matches(snapshot)) return snapshot;
- } catch (error) {
- lastError = asError(error);
- }
- if (Date.now() >= deadline) break;
- await delay(100);
+ const value = await readSteamLaunchOptions(appId, nonSteam);
+ if (matches(value)) return value;
+ } catch (error) { lastError = asError(error); }
+ if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100));
}
- if (lastError) throw new Error(`${message}: ${lastError.message}`);
- throw new Error(`${message} before the readback timeout`);
+ throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`);
}
-async function writeLaunchOptionsAndVerify(
+async function writeVerified(
appId: number,
nonSteam: boolean,
previous: string,
next: string,
+ write: (value: string) => Promise<void>,
message: string,
): Promise<SteamLaunchOptionsSnapshot> {
try {
- await setSteamLaunchOptions(appId, nonSteam, next);
- return await waitForSnapshot(
- appId,
- nonSteam,
- (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next),
- message,
- );
- } catch (error) {
- const failure = asError(error);
- try {
- await setSteamLaunchOptions(appId, nonSteam, previous);
- await waitForSnapshot(
- appId,
- nonSteam,
- (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous),
- "Steam did not restore the previous launch options",
- );
- } catch (rollbackError) {
- throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`);
- }
- throw failure;
- }
-}
-
-async function writeShortcutExecutableAndVerify(
- appId: number,
- previous: string,
- next: string,
- message: string,
-): Promise<SteamLaunchOptionsSnapshot> {
- try {
- await setShortcutExecutable(appId, next);
- return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message);
+ await write(next);
+ return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message);
} catch (error) {
const failure = asError(error);
try {
- await setShortcutExecutable(appId, previous);
- await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target");
- } catch (rollbackError) {
- throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`);
+ await write(previous);
+ await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options");
+ } catch (rollback) {
+ throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`);
}
throw failure;
}
}
-const operationQueues = new Map<string, Promise<unknown>>();
-
-function queueSteamOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
- const key = `${nonSteam ? "shortcut" : "app"}:${appId}`;
- const previous = operationQueues.get(key) || Promise.resolve();
- const queued = previous.catch(() => undefined).then(operation);
- const cleanup = queued.then(
- () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); },
- () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); },
- );
- operationQueues.set(key, cleanup);
- return queued;
+function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> {
+ const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions;
+ if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`));
+ return Promise.resolve(setter.call(apps(), appId, value));
}
export function updateSteamLaunchOptions(
@@ -501,11 +324,14 @@ export function updateSteamLaunchOptions(
nonSteam: boolean,
transform: (options: string) => string,
): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamOperation(appId, nonSteam, async () => {
+ return queued(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
const next = transform(current.options);
- if (next === current.options) return current;
- return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options");
+ return next === current.options ? current : writeVerified(
+ appId, nonSteam, current.options, next,
+ (value) => writeOptions(appId, nonSteam, value),
+ "Steam did not accept the launch options",
+ );
});
}
@@ -515,33 +341,18 @@ export function installWrapperIntegration(
wrapperPath: string,
commandTokenAdded = false,
): Promise<WrapperIntegrationResult> {
- return queueSteamOperation(appId, nonSteam, async () => {
+ return queued(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
- if (nonSteam) {
- if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it");
- if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) {
- throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first");
- }
- const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath);
- if (cleanedOptions !== current.options) {
- await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options");
- }
- if (current.target === wrapperPath) {
- return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false };
- }
- const originalExecutable = current.target;
- const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target");
- return { snapshot, originalExecutable, commandTokenAdded: false };
- }
-
const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options));
const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath);
- const rewrite = installWrapperLaunchOption(cleaned, wrapperPath);
- if (rewrite.options === current.options) {
- return { snapshot: current, commandTokenAdded };
- }
- const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options");
- return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded };
+ const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam);
+ if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false };
+ const value = await writeVerified(
+ appId, nonSteam, current.options, rewrite.options,
+ (options) => writeOptions(appId, nonSteam, options),
+ "Steam did not accept the launch options",
+ );
+ return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true };
});
}
@@ -549,48 +360,23 @@ export function removeWrapperIntegration(
appId: number,
nonSteam: boolean,
wrapperPath: string,
- originalExecutable?: string,
commandTokenAdded = false,
): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamOperation(appId, nonSteam, async () => {
+ return queued(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
- if (nonSteam) {
- if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) {
- throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target");
- }
- if (current.target !== wrapperPath && current.target !== originalExecutable) {
- throw new Error("Shortcut Target changed externally; refusing to restore it");
- }
- const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath);
- if (cleaned !== current.options) {
- await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options");
- }
- if (current.target === originalExecutable) {
- return readSteamLaunchOptions(appId, true);
- }
- return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target");
- }
-
- const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded);
- const next = cleanupPluginAssignments(withoutWrapper);
- if (next === current.options) return current;
- return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options");
+ const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded));
+ return next === current.options ? current : writeVerified(
+ appId, nonSteam, current.options, next,
+ (options) => writeOptions(appId, nonSteam, options),
+ "Steam did not clean the launch options",
+ );
});
}
-export function cleanupLegacySteamLaunchOptions(
+export const cleanupLegacySteamLaunchOptions = (
appId: number,
nonSteam: boolean,
wrapperPath = DEFAULT_WRAPPER_PATH,
-): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamOperation(appId, nonSteam, async () => {
- const current = await readSteamLaunchOptions(appId, nonSteam);
- const next = cleanupPluginLaunchOptions(current.options, wrapperPath);
- if (next === current.options) return current;
- return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options");
- });
-}
+) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath));
-export function getDefaultWrapperPath(): string {
- return DEFAULT_WRAPPER_PATH;
-}
+export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH;
diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts
index cbbbc55..c41f4c0 100644
--- a/src/utils/toastUtils.ts
+++ b/src/utils/toastUtils.ts
@@ -1,8 +1,3 @@
-/**
- * Centralized toast notification utilities
- * Provides consistent success/error messaging patterns
- */
-
import { toaster } from "@decky/api";
export interface ToastOptions {
@@ -10,84 +5,46 @@ export interface ToastOptions {
body: string;
}
-/**
- * Show a success toast notification
- */
-export function showSuccessToast(title: string, body: string): void {
- toaster.toast({
- title,
- body
- });
-}
+const showToast = (title: string, body: string): void => {
+ toaster.toast({ title, body });
+};
+export const showSuccessToast = showToast;
+export const showErrorToast = showToast;
-/**
- * Show an error toast notification
- */
-export function showErrorToast(title: string, body: string): void {
- toaster.toast({
- title,
- body
- });
-}
-
-/**
- * Standard success messages for common operations
- */
export const ToastMessages = {
INSTALL_SUCCESS: {
title: "Installation Complete",
- body: "lsfg-vk has been installed successfully"
+ body: "lsfg-vk has been installed successfully",
},
INSTALL_ERROR: {
title: "Installation Failed",
- body: "Unknown error occurred"
+ body: "Unknown error occurred",
},
UNINSTALL_SUCCESS: {
- title: "Uninstallation Complete",
- body: "lsfg-vk has been uninstalled successfully"
+ title: "Uninstallation Complete",
+ body: "lsfg-vk has been uninstalled successfully",
},
UNINSTALL_ERROR: {
title: "Uninstallation Failed",
- body: "Unknown error occurred"
+ body: "Unknown error occurred",
},
CONFIG_UPDATE_ERROR: {
title: "Update Failed",
- body: "Failed to update configuration"
- }
+ body: "Failed to update configuration",
+ },
} as const;
-/**
- * Show a toast with dynamic error message
- */
-export function showErrorToastWithMessage(title: string, error: unknown): void {
- const errorMessage = error instanceof Error ? error.message : String(error);
- showErrorToast(title, errorMessage);
-}
+export const showErrorToastWithMessage = (title: string, error: unknown): void =>
+ showErrorToast(title, error instanceof Error ? error.message : String(error));
-/**
- * Show installation success toast
- */
-export function showInstallSuccessToast(): void {
+export const showInstallSuccessToast = (): void =>
showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body);
-}
-/**
- * Show installation error toast
- */
-export function showInstallErrorToast(error?: string): void {
+export const showInstallErrorToast = (error?: string): void =>
showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body);
-}
-/**
- * Show uninstallation success toast
- */
-export function showUninstallSuccessToast(): void {
+export const showUninstallSuccessToast = (): void =>
showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body);
-}
-/**
- * Show uninstallation error toast
- */
-export function showUninstallErrorToast(error?: string): void {
+export const showUninstallErrorToast = (error?: string): void =>
showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body);
-}