summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/lsfgApi.ts108
-rw-r--r--src/components/ConfigurationTab.tsx28
-rw-r--r--src/components/Content.tsx46
-rw-r--r--src/components/FlatpaksTab.tsx196
-rw-r--r--src/components/GameConfigurationControls.tsx11
-rw-r--r--src/components/GameConfigurationSelector.tsx102
-rw-r--r--src/components/NowPlayingTab.tsx93
-rw-r--r--src/components/ProfileDetails.tsx4
-rw-r--r--src/components/SetupTab.tsx131
-rw-r--r--src/components/WorkaroundsSection.tsx66
-rw-r--r--src/components/index.ts3
-rw-r--r--src/hooks/useGameConfiguration.ts245
-rw-r--r--src/hooks/usePerAppWorkarounds.ts272
-rw-r--r--src/i18n/languages.json12
-rw-r--r--src/types.d.ts2
-rw-r--r--src/utils/steamLaunchOptionParser.ts501
-rw-r--r--src/utils/steamLaunchOptions.ts477
17 files changed, 1322 insertions, 975 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 8eaad98..8179ef9 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -43,7 +43,34 @@ export interface GameConfigEntry {
profile: string;
config: LsfgConfig;
}
-export interface InstalledGame { appid: string; name: string; nonSteam: boolean; }
+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; }
@@ -60,44 +87,52 @@ export interface GameConfigResult extends ConfigUpdateResult {
config?: LsfgConfig;
}
-export interface FileContentResult {
- success: boolean;
- content?: string;
- path?: string;
- error?: string;
+export interface WorkaroundState {
+ dxvkFrameRate: number;
+ disableGamescopeWsi: boolean;
+ disableHdr: boolean;
+ disableSteamdeckMode: boolean;
+ disableVkbasalt: boolean;
+ enableZink: boolean;
}
-// Flatpak management interfaces
-export interface FlatpakExtensionStatus {
+export interface WorkaroundStateResult {
success: boolean;
- message: string;
+ message?: string;
error?: string;
- installed_23_08: boolean;
- installed_24_08: boolean;
- installed_25_08: boolean;
+ appid?: string;
+ state?: WorkaroundState | null;
+ wrapper_path?: string;
+ wrapper_owned?: boolean;
+ shortcut_exe?: string | null;
+ command_token_added?: boolean;
+ transport?: TargetTransport | null;
}
-export interface FlatpakApp {
- app_id: string;
- app_name: string;
- has_filesystem_override: boolean;
- has_env_override: boolean;
+export interface FileContentResult {
+ success: boolean;
+ content?: string;
+ path?: string;
+ error?: string;
}
-export interface FlatpakAppInfo {
+export interface FlatpakExtensionStatus {
success: boolean;
message: string;
- error?: string;
- apps: FlatpakApp[];
- total_apps: number;
+ error?: string | null;
+ available: boolean;
+ extension_id: string;
+ supported_branches: string[];
+ installed_branches: string[];
}
-export interface FlatpakOperationResult {
+export interface FlatpakExtensionToggleResult {
success: boolean;
message: string;
- error?: string;
- app_id?: string;
- operation?: string;
+ error?: string | null;
+ runtime_branch: string;
+ enabled: boolean;
+ installed: boolean;
}
// API functions
@@ -107,16 +142,25 @@ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg
export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status");
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
-// Flatpak management API functions
-export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status");
-export const installFlatpakExtension = callable<[string], FlatpakOperationResult>("install_flatpak_extension");
-export const uninstallFlatpakExtension = callable<[string], FlatpakOperationResult>("uninstall_flatpak_extension");
-export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps");
-export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override");
-export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override");
+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 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 getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state");
+export const setWorkaroundState = callable<[
+ string,
+ WorkaroundState,
+ string | null | undefined,
+ boolean,
+ TargetTransport | null | undefined,
+], WorkaroundStateResult>("set_workaround_state");
+export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state");
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 2bb0f26..8f8690c 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -15,6 +15,7 @@ interface ConfigurationTabProps {
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
onEnableAll: () => Promise<void>;
+ onRepair: (appid: string) => Promise<boolean>;
onReset: () => Promise<void>;
onResetAll: () => Promise<void>;
}
@@ -27,12 +28,14 @@ export function ConfigurationTab({
onConfigChange,
onEnable,
onEnableAll,
+ onRepair,
onReset,
onResetAll,
}: ConfigurationTabProps) {
const [detailAppId, setDetailAppId] = useState<string | null>(null);
const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false);
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(() => {
@@ -41,6 +44,7 @@ export function ConfigurationTab({
setDetailAppId(null);
}, []);
const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []);
+ const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []);
useEffect(() => {
if (!focusDetailAction) return;
@@ -77,25 +81,34 @@ export function ConfigurationTab({
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 profileDescription = selectedTarget
- ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
+ ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
const handleProfileAction = async () => {
if (selectedTarget?.configured) {
promptedRunningAppId.current = detailAppId;
await onReset();
+ setFocusConfiguredToggle(true);
closeDetails();
} else if (detailAppId && await onEnable(detailAppId)) {
setFocusFpsMultiplier(true);
@@ -143,6 +156,18 @@ export function ConfigurationTab({
</PanelSectionRow>
)}
</PanelSection>
+ {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && (
+ <PanelSection>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={() => void onRepair(selectedTarget.appid)}
+ >
+ Repair Flatpak support
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
{selectedTarget?.configured && (
<GameConfigurationControls
config={config}
@@ -151,6 +176,7 @@ export function ConfigurationTab({
onFpsMultiplierFocused={clearFpsFocusRequest}
showWorkarounds
workaroundTarget={selectedTarget || undefined}
+ onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined}
/>
)}
{selectedTarget?.configured && (
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 1d7d2d1..58512d5 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,21 +1,19 @@
import { Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
-import { FaFileAlt, FaGamepad, FaLayerGroup, FaList, FaTools } from "react-icons/fa";
+import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
import { tabStyles } from "../styles";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallationActions } from "../hooks/useInstallationActions";
import { useInstallationStatus } from "../hooks/useLsfgHooks";
-import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
-import { FlatpaksTab } from "./FlatpaksTab";
+import { ConfigFileTab } from "./ConfigFileTab";
import { NowPlayingTab } from "./NowPlayingTab";
import { SetupTab } from "./SetupTab";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
- configuration: <FaList size={18} />,
- flatpak: <FaLayerGroup size={18} />,
+ games: <FaList size={18} />,
configFile: <FaFileAlt size={18} />,
setup: <FaTools size={18} />,
};
@@ -39,6 +37,7 @@ export function Content() {
save,
enable,
enableAll,
+ repair,
resetSelected,
resetAll,
reload,
@@ -51,25 +50,25 @@ export function Content() {
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
- const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null);
+ const previousRunningAppId = useRef<string | null>(null);
useEffect(() => {
if (!setupComplete) {
setTab("Setup");
return;
}
- setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current);
- }, [runningGame?.configured, setupComplete]);
+ setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current);
+ }, [runningGame?.appid, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
- const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null;
- const previous = previousRunningState.current;
- previousRunningState.current = current;
- if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) {
- setTab(current.configured ? "NowPlaying" : "Configuration");
- } else if (!current && previous) {
- setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab);
+ const appid = runningGame?.appid || null;
+ const previous = previousRunningAppId.current;
+ previousRunningAppId.current = appid;
+ if (appid && appid !== previous) {
+ setTab("NowPlaying");
+ } else if (!appid && previous) {
+ setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab);
}
}, [runningGame?.appid, runningGame?.configured, setupComplete]);
@@ -104,12 +103,13 @@ export function Content() {
isUninstalling={isUninstalling}
onInstall={onInstall}
onUninstall={onUninstall}
+ flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")}
/>
);
const tabs = setupComplete
? [
- ...(runningGame?.configured ? [{
+ ...(runningGame ? [{
id: "NowPlaying",
title: tabIcons.nowPlaying,
content: (
@@ -117,12 +117,14 @@ export function Content() {
game={runningGame}
config={config}
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)}
+ onEnable={enable}
+ onRepair={repair}
/>
),
}] : []),
{
- id: "Configuration",
- title: tabIcons.configuration,
+ id: "Games",
+ title: tabIcons.games,
content: (
<ConfigurationTab
config={config}
@@ -132,13 +134,17 @@ export function Content() {
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)}
onEnable={enable}
onEnableAll={enableAll}
+ onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
/>
),
},
- { id: "Flatpak", title: tabIcons.flatpak, content: <FlatpaksTab /> },
- { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, // comment out for prod
+ {
+ id: "ConfigFile",
+ title: tabIcons.configFile,
+ content: <ConfigFileTab />,
+ },
{ id: "Setup", title: tabIcons.setup, content: setupContent },
]
: [
diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx
deleted file mode 100644
index 8aab940..0000000
--- a/src/components/FlatpaksTab.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useEffect, useState } from "react";
-import {
- ConfirmModal,
- Field,
- PanelSection,
- PanelSectionRow,
- ToggleField,
- showModal,
-} from "@decky/ui";
-import {
- checkFlatpakExtensionStatus,
- FlatpakApp,
- FlatpakAppInfo,
- FlatpakExtensionStatus,
- getFlatpakApps,
- installFlatpakExtension,
- removeFlatpakAppOverride,
- setFlatpakAppOverride,
- uninstallFlatpakExtension,
-} from "../api/lsfgApi";
-import { showErrorToast } from "../utils/toastUtils";
-import t from "../i18n/i18n";
-
-const runtimeVersions = [
- { version: "23.08", key: "installed_23_08" },
- { version: "24.08", key: "installed_24_08" },
- { version: "25.08", key: "installed_25_08" },
-] as const;
-
-interface RuntimeRowProps {
- version: string;
- installed: boolean;
- busy: boolean;
- onAction: () => void;
-}
-
-function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) {
- return (
- <PanelSectionRow>
- <ToggleField
- label={`Runtime ${version}`}
- description={busy ? "Updating..." : installed ? t("FLATPAK_INSTALLED", "Installed") : t("FLATPAK_NOT_INSTALLED", "Not installed")}
- checked={installed}
- onChange={() => onAction()}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-interface AppRowProps {
- app: FlatpakApp;
- runtimeReady: boolean;
- busy: boolean;
- onToggle: () => void;
-}
-
-function AppRow({ app, runtimeReady, busy, onToggle }: AppRowProps) {
- const configured = app.has_filesystem_override && app.has_env_override;
- const partial = app.has_filesystem_override || app.has_env_override;
- const status = configured
- ? runtimeReady
- ? t("FLATPAK_STATUS_READY", "Ready")
- : t("FLATPAK_STATUS_RUNTIME_MISSING", "Runtime missing")
- : partial
- ? t("FLATPAK_STATUS_PARTIAL", "Partial")
- : t("FLATPAK_STATUS_NOT_ENABLED", "Not enabled");
-
- return (
- <PanelSectionRow>
- <ToggleField
- label={app.app_name || app.app_id}
- description={`${app.app_id} - ${status}`}
- checked={configured}
- onChange={onToggle}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-export function FlatpaksTab() {
- const [extensionStatus, setExtensionStatus] = useState<FlatpakExtensionStatus | null>(null);
- const [apps, setApps] = useState<FlatpakAppInfo | null>(null);
- const [loading, setLoading] = useState(true);
- const [operation, setOperation] = useState<string | null>(null);
- const [error, setError] = useState<string | null>(null);
- const runtimeReady = extensionStatus?.success === true
- && runtimeVersions.some(({ key }) => extensionStatus[key]);
-
- const load = async () => {
- setLoading(true);
- try {
- const [nextStatus, nextApps] = await Promise.all([
- checkFlatpakExtensionStatus(),
- getFlatpakApps(),
- ]);
- setExtensionStatus(nextStatus);
- setApps(nextApps);
- } catch (loadError) {
- setError(String(loadError));
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- void load();
- }, []);
-
- const runExtensionOperation = async (version: string, installed: boolean) => {
- const action = installed ? "uninstall" : "install";
- setOperation(`${action}-${version}`);
- setError(null);
- try {
- const result = installed
- ? await uninstallFlatpakExtension(version)
- : await installFlatpakExtension(version);
- if (!result.success) throw new Error(result.error || result.message);
- setExtensionStatus(await checkFlatpakExtensionStatus());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak operation failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- const confirmExtensionOperation = (version: string, installed: boolean) => {
- if (!installed) {
- void runExtensionOperation(version, false);
- return;
- }
- showModal(
- <ConfirmModal
- strTitle={t("FLATPAK_UNINSTALL_TITLE", "Uninstall Runtime Extension")}
- strDescription={`${t("FLATPAK_UNINSTALL_CONFIRM_PREFIX", "Are you sure you want to uninstall the")} ${version} ${t("FLATPAK_UNINSTALL_CONFIRM_SUFFIX", "runtime extension?")}`}
- onOK={() => void runExtensionOperation(version, true)}
- onCancel={() => {}}
- />,
- );
- };
-
- const toggleApp = async (app: FlatpakApp) => {
- const configured = app.has_filesystem_override && app.has_env_override;
- setOperation(`app-${app.app_id}`);
- setError(null);
- try {
- const result = configured
- ? await removeFlatpakAppOverride(app.app_id)
- : await setFlatpakAppOverride(app.app_id);
- if (!result.success) throw new Error(result.error || result.message);
- setApps(await getFlatpakApps());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak override failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- if (loading) {
- return <PanelSection title="Flatpak Runtimes" spinner />;
- }
-
- return (
- <>
- <PanelSection title="Flatpak Runtimes">
- {error && <PanelSectionRow><Field label={t("FLATPAK_OPERATION_ERROR", "Operation failed")} description={error} /></PanelSectionRow>}
- {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => (
- <RuntimeRow
- key={version}
- version={version}
- installed={extensionStatus[key]}
- busy={operation === `${extensionStatus[key] ? "uninstall" : "install"}-${version}`}
- onAction={() => confirmExtensionOperation(version, extensionStatus[key])}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={extensionStatus?.error || error || t("FLATPAK_ERROR_STATUS", "Failed to check extension status")} /></PanelSectionRow>}
- </PanelSection>
-
- <PanelSection title="Applications">
- {apps?.success ? apps.apps.length ? apps.apps.map((app) => (
- <AppRow
- key={app.app_id}
- app={app}
- runtimeReady={runtimeReady}
- busy={operation === `app-${app.app_id}`}
- onToggle={() => void toggleApp(app)}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_NO_APPS", "No Flatpak Apps Found")} description={t("FLATPAK_NO_APPS_DESC", "No Flatpak applications are currently installed")} /></PanelSectionRow> : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={apps?.error || error || t("FLATPAK_ERROR_APPS", "Failed to load Flatpak applications")} /></PanelSectionRow>}
- </PanelSection>
- </>
- );
-}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 58ccd02..6bea2e0 100644
--- a/src/components/GameConfigurationControls.tsx
+++ b/src/components/GameConfigurationControls.tsx
@@ -10,7 +10,8 @@ interface Props {
autoFocusFpsMultiplier?: boolean;
onFpsMultiplierFocused?: () => void;
showWorkarounds?: boolean;
- workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">;
+ workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">;
+ onRepairWorkaround?: () => Promise<boolean>;
}
export function GameConfigurationControls({
@@ -20,6 +21,7 @@ export function GameConfigurationControls({
onFpsMultiplierFocused,
showWorkarounds = false,
workaroundTarget,
+ onRepairWorkaround,
}: Props) {
return (
<>
@@ -31,7 +33,12 @@ export function GameConfigurationControls({
/>
<ConfigurationSection config={config} onConfigChange={onConfigChange} />
{showWorkarounds && workaroundTarget && (
- <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} />
+ <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 5f23b91..92eacba 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,5 +1,5 @@
import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState, type RefObject } from "react";
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
import { GameTarget } from "../hooks/useGameConfiguration";
@@ -9,10 +9,12 @@ interface Props {
onSelect: (appid: string) => void;
onEnableAll: () => Promise<void>;
onResetAll: () => Promise<void>;
+ focusConfiguredToggle?: boolean;
+ onConfiguredToggleFocused?: () => void;
}
-const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3";
-const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v2";
+const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4";
+const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3";
function usePersistentCollapsed(key: string) {
const [collapsed, setCollapsed] = useState(() => {
@@ -34,28 +36,36 @@ function usePersistentCollapsed(key: string) {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
+function targetDescription(game: GameTarget): string {
+ if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
function GameGroup({
title,
games,
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" />
+ <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
</PanelSectionRow>
<PanelSectionRow>
<div
+ ref={toggleRef}
className="LSFG_GameGroupCollapseButton_Container"
style={{ marginTop: "-2px", marginBottom: "4px" }}
>
@@ -64,11 +74,7 @@ function GameGroup({
bottomSeparator={collapsed ? "standard" : "none"}
onClick={onToggle}
>
- {collapsed ? (
- <RiArrowDownSFill />
- ) : (
- <RiArrowUpSFill />
- )}
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
</ButtonItem>
</div>
</PanelSectionRow>
@@ -76,7 +82,7 @@ function GameGroup({
<PanelSectionRow key={game.appid}>
<Field
label={game.name}
- description={game.nonSteam ? "Non-Steam" : "Steam"}
+ description={targetDescription(game)}
onActivate={() => onSelect(game.appid)}
highlightOnFocus
/>
@@ -86,22 +92,35 @@ function GameGroup({
);
}
-export function GameConfigurationSelector({ targets, runningGame, onSelect, onEnableAll, onResetAll }: Props) {
+export function GameConfigurationSelector({
+ targets,
+ runningGame,
+ onSelect,
+ onEnableAll,
+ onResetAll,
+ focusConfiguredToggle = false,
+ onConfiguredToggleFocused,
+}: Props) {
const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => {
if (a.appid === runningGame?.appid) return -1;
if (b.appid === runningGame?.appid) return 1;
return a.name.localeCompare(b.name);
});
- const configuredGames = sortGames(targets.filter((game) => game.configured));
+ const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
- const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam);
- const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam);
- const availableSteamGames = availableGames.filter((game) => !game.nonSteam);
- const availableNonSteamGames = availableGames.filter((game) => game.nonSteam);
- const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY);
- const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
- const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`);
+ const enabledToggleRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!focusConfiguredToggle) return;
+ const frame = requestAnimationFrame(() => {
+ enabledToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus();
+ onConfiguredToggleFocused?.();
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]);
+
const confirmResetAll = () => {
showModal(
<ConfirmModal
@@ -113,11 +132,12 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn
/>,
);
};
+
const confirmEnableAll = () => {
showModal(
<ConfirmModal
strTitle="Enable all available games?"
- strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults."
+ strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults. Flatpak targets will be provisioned as needed."
strOKButtonText="Enable all"
strCancelButtonText="Cancel"
onOK={() => void onEnableAll()}
@@ -133,6 +153,7 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn
.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;
@@ -150,41 +171,28 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
- {availableGames.length > 0 && (
- <PanelSectionRow>
- <ButtonItem layout="below" onClick={confirmEnableAll}>
- Enable all available games
- </ButtonItem>
- </PanelSectionRow>
- )}
<GameGroup
- title="LSFG-VK Enabled"
- games={configuredSteamGames}
- collapsed={configuredCollapsed}
- onToggle={toggleConfigured}
+ title="Enabled"
+ games={enabledGames}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
onSelect={onSelect}
+ toggleRef={enabledToggleRef}
/>
<GameGroup
- title="LSFG-VK Enabled (Non-Steam)"
- games={configuredNonSteamGames}
- collapsed={configuredNonSteamCollapsed}
- onToggle={toggleConfiguredNonSteam}
- onSelect={onSelect}
- />
- <GameGroup
- title="Available games"
- games={availableSteamGames}
+ title="Available"
+ games={availableGames}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
/>
- <GameGroup
- title="Available games (Non-Steam)"
- games={availableNonSteamGames}
- collapsed={availableNonSteamCollapsed}
- onToggle={toggleAvailableNonSteam}
- onSelect={onSelect}
- />
+ {availableGames.length > 0 && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={confirmEnableAll}>
+ Enable all available games
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
<PanelSectionRow>
<ButtonItem
layout="below"
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 188c56a..5bce5c4 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,4 +1,5 @@
-import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { useState } from "react";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
@@ -6,20 +7,96 @@ import { GameConfigurationControls } from "./GameConfigurationControls";
interface Props {
game: GameTarget;
config: ConfigurationData;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onConfigChange: (
+ fieldName: keyof ConfigurationData,
+ value: boolean | number | string | string[],
+ ) => Promise<void>;
+ onEnable: (appid: string) => Promise<boolean>;
+ onRepair: (appid: string) => Promise<boolean>;
}
-export function NowPlayingTab({ game, config, onConfigChange }: Props) {
+function targetDescription(game: GameTarget): string {
+ if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
+export function NowPlayingTab({
+ game,
+ config,
+ onConfigChange,
+ onEnable,
+ onRepair,
+}: Props) {
+ const [busy, setBusy] = useState(false);
+ const supportNeedsRepair =
+ game.configured &&
+ game.transport.kind === "flatpak" &&
+ game.flatpakSupport?.support_status !== "ready";
+
+ const handleEnable = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await onEnable(game.appid);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleRepair = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await onRepair(game.appid);
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
<Focusable>
- <PanelSection>
+ <PanelSection title="Now Playing">
<PanelSectionRow>
- <Field
- label={game.name}
- />
+ <Field label={game.name} description={targetDescription(game)} />
</PanelSectionRow>
</PanelSection>
- <GameConfigurationControls config={config} onConfigChange={onConfigChange} showWorkarounds={false} />
+ {!game.configured && (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field
+ label="LSFG-VK is available"
+ description="This target is not enabled yet. Create its AppID profile before the next launch."
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void handleEnable()}>
+ {busy ? "Enabling..." : "Enable LSFG-VK"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
+ {game.configured && supportNeedsRepair && (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field
+ label="Flatpak support needs repair"
+ description={game.flatpakSupport?.error || "The target runtime extension is not ready."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}>
+ {busy ? "Repairing..." : "Repair Flatpak support"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
+ {game.configured && (
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={onConfigChange}
+ showWorkarounds={false}
+ />
+ )}
</Focusable>
);
}
diff --git a/src/components/ProfileDetails.tsx b/src/components/ProfileDetails.tsx
index 056abbb..4dd8891 100644
--- a/src/components/ProfileDetails.tsx
+++ b/src/components/ProfileDetails.tsx
@@ -24,12 +24,12 @@ export function ProfileDetails({ description }: ProfileDetailsProps) {
bottomSeparator={expanded ? "none" : "standard"}
onClick={() => setExpanded((value) => !value)}
>
- {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Details
+ {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Game Details
</ButtonItem>
</PanelSectionRow>
{expanded && (
<PanelSectionRow>
- <Field label="Details" description={description} />
+ <Field label="Game Details" description={description} />
</PanelSectionRow>
)}
</Focusable>
diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx
index 98d6e78..aec0e93 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SetupTab.tsx
@@ -1,7 +1,14 @@
-import { PanelSection } from "@decky/ui";
-import type { SteamBranchStatus } from "../api/lsfgApi";
+import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui";
+import { useEffect, useState } from "react";
+import {
+ getFlatpakSupportStatus,
+ setFlatpakExtensionEnabled,
+ type FlatpakExtensionStatus,
+ type SteamBranchStatus,
+} from "../api/lsfgApi";
import { InstallationButton } from "./InstallationButton";
import { StatusDisplay } from "./StatusDisplay";
+import { showErrorToast } from "../utils/toastUtils";
interface SetupTabProps {
isInstalled: boolean;
@@ -13,6 +20,92 @@ interface SetupTabProps {
isUninstalling: boolean;
onInstall: () => void;
onUninstall: () => void;
+ flatpakRelevant: boolean;
+}
+
+function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) {
+ const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null);
+ const [advanced, setAdvanced] = useState(false);
+ const [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: [],
+ });
+ }
+ };
+
+ 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 handleExtensionToggle = (version: string, enabled: boolean) => {
+ void runExtensionOperation(version, enabled);
+ };
+
+ return (
+ <PanelSection title="Flatpak support">
+ <PanelSectionRow>
+ <Field
+ label="Runtime extension support"
+ description={status.message || "Flatpak is available for classified targets."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => setAdvanced((value) => !value)}>
+ {advanced ? "Hide runtime details" : "Show runtime details"}
+ </ButtonItem>
+ </PanelSectionRow>
+ {advanced && (
+ <>
+ {status.supported_branches.map((branch) => (
+ <PanelSectionRow key={branch}>
+ <ToggleField
+ label={branch}
+ description={
+ operation === `enable-${branch}`
+ ? "Installing..."
+ : operation === `disable-${branch}`
+ ? "Uninstalling..."
+ : status.installed_branches.includes(branch)
+ ? "Installed"
+ : "Not installed"
+ }
+ checked={status.installed_branches.includes(branch)}
+ onChange={(enabled) => handleExtensionToggle(branch, enabled)}
+ disabled={operation !== null}
+ />
+ </PanelSectionRow>
+ ))}
+ </>
+ )}
+ </PanelSection>
+ );
}
export function SetupTab({
@@ -25,22 +118,26 @@ export function SetupTab({
isUninstalling,
onInstall,
onUninstall,
+ flatpakRelevant,
}: SetupTabProps) {
return (
- <PanelSection title="Setup">
- <StatusDisplay
- installationStatus={installationStatus}
- losslessScalingInstalled={losslessScalingInstalled}
- losslessScalingStatus={losslessScalingStatus}
- steamBranchStatus={steamBranchStatus}
- />
- <InstallationButton
- isInstalled={isInstalled}
- isInstalling={isInstalling}
- isUninstalling={isUninstalling}
- onInstall={onInstall}
- onUninstall={onUninstall}
- />
- </PanelSection>
+ <>
+ <PanelSection title="Setup">
+ <StatusDisplay
+ installationStatus={installationStatus}
+ losslessScalingInstalled={losslessScalingInstalled}
+ losslessScalingStatus={losslessScalingStatus}
+ steamBranchStatus={steamBranchStatus}
+ />
+ <InstallationButton
+ isInstalled={isInstalled}
+ isInstalling={isInstalling}
+ isUninstalling={isUninstalling}
+ onInstall={onInstall}
+ onUninstall={onUninstall}
+ />
+ </PanelSection>
+ <FlatpakSupportDiagnostics relevant={flatpakRelevant} />
+ </>
);
}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index e990784..5587392 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -1,16 +1,19 @@
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 "../utils/steamLaunchOptions";
+import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
interface WorkaroundsSectionProps {
appId: string;
nonSteam: boolean;
+ transport: TargetTransport;
+ onRepair?: () => Promise<boolean>;
}
-const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed";
+const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2";
type ToggleWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">;
const TOGGLE_ROWS: readonly {
@@ -21,18 +24,25 @@ const TOGGLE_ROWS: readonly {
description: string;
}[] = [
{
+ field: "disableSteamdeckMode",
+ labelKey: "CONFIG_DISABLE_STEAMDECK_MODE",
+ label: "Disable Steam Deck Mode",
+ descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC",
+ description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
+ },
+ {
field: "disableGamescopeWsi",
labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI",
label: "Disable Gamescope WSI",
descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC",
- description: "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
+ description: "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.",
},
{
- field: "disableSteamdeckMode",
- labelKey: "CONFIG_DISABLE_STEAMDECK_MODE",
- label: "Disable Steam Deck Mode",
- descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC",
- description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
+ field: "disableHdr",
+ labelKey: "CONFIG_DISABLE_HDR",
+ label: "Disable HDR",
+ descriptionKey: "CONFIG_DISABLE_HDR_DESC",
+ description: "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.",
},
{
field: "disableVkbasalt",
@@ -71,18 +81,28 @@ function usePersistentCollapsed() {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) {
+export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) {
const [collapsed, toggleCollapsed] = usePersistentCollapsed();
- const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam);
- const state = snapshot?.parsed.state;
- const issues = snapshot?.parsed.issues || [];
- const controlsDisabled = status !== "ready" || state === undefined;
+ const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport);
+ const [repairing, setRepairing] = useState(false);
+ const state = snapshot?.state;
+ const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true;
const [fpsValue, setFpsValue] = useState<number | null>(null);
const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0;
const fpsLabel = effectiveFpsValue > 0
? `${effectiveFpsValue} FPS`
: t("CONFIG_BASE_FPS_CAP_OFF", "Off");
+ const handleRepair = async () => {
+ if (!onRepair || repairing) return;
+ setRepairing(true);
+ try {
+ if (await onRepair()) await refresh();
+ } finally {
+ setRepairing(false);
+ }
+ };
+
useEffect(() => {
setFpsValue(state?.dxvkFrameRate ?? null);
}, [state?.dxvkFrameRate, status]);
@@ -148,13 +168,19 @@ export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps)
</PanelSectionRow>
</>
)}
- {status === "ready" && issues.length > 0 && (
- <PanelSectionRow>
- <Field
- label="Launch options need attention"
- description={`${issues.join(" ")} Adjusting a workaround will normalize its managed values.`}
- />
- </PanelSectionRow>
+ {status === "ready" && snapshot && (!snapshot.wrapperOwned || !snapshot.integrationInstalled) && (
+ <>
+ <PanelSectionRow>
+ <Field label="Wrapper needs to be reinstalled" />
+ </PanelSectionRow>
+ {onRepair && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={repairing} onClick={() => void handleRepair()}>
+ {repairing ? "Reinstalling..." : "Reinstall wrapper"}
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
+ </>
)}
<PanelSectionRow>
diff --git a/src/components/index.ts b/src/components/index.ts
index 37a8edb..6856e76 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -4,9 +4,8 @@ export { InstallationButton } from "./InstallationButton";
export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
export { ConfigurationTab } from "./ConfigurationTab";
-export { SetupTab } from "./SetupTab";
export { ConfigFileTab } from "./ConfigFileTab";
-export { FlatpaksTab } from "./FlatpaksTab";
+export { SetupTab } from "./SetupTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
export { GameConfigurationControls } from "./GameConfigurationControls";
export { NowPlayingTab } from "./NowPlayingTab";
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index 46607cb..c70d589 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 { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi";
+import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions";
+import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
@@ -19,7 +19,12 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
const appid = Number(shortcut?.appid);
const name = shortcut?.data?.strAppName;
if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return [];
- return [{ appid: String(appid >>> 0), name, nonSteam: true }];
+ return [{
+ appid: String(appid >>> 0),
+ name,
+ nonSteam: true,
+ transport: { kind: "host" },
+ }];
});
} catch {
return [];
@@ -28,10 +33,43 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) {
const games = new Map(backendGames.map((game) => [game.appid, game]));
- for (const game of shortcutGames) games.set(game.appid, game);
+ for (const game of shortcutGames) {
+ const existing = games.get(game.appid);
+ games.set(game.appid, existing ? { ...existing, name: game.name, nonSteam: true } : game);
+ }
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,
+ disableHdr: true,
+ disableSteamdeckMode: false,
+ disableVkbasalt: false,
+ enableZink: false,
+};
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
+
export function useGameConfiguration() {
const [games, setGames] = useState<GameConfigEntry[]>([]);
const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false });
@@ -69,7 +107,7 @@ export function useGameConfiguration() {
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
setRunningGame((current) => current?.appid === appid ? current : {
- ...(installed || { appid, name, nonSteam: false }),
+ ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }),
name,
configured: games.some((game) => game.appid === appid),
});
@@ -88,20 +126,157 @@ 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, 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, transport: { kind: "host" }, 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 cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => {
+ 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 ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
try {
- await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam);
+ 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(
+ target.appid,
+ state,
+ originalExecutable || null,
+ oldCommandTokenAdded,
+ target.transport,
+ );
+ 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 (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");
+ }
+ throw error;
+ }
+ } catch (error) {
+ showErrorToast("Could not initialize workarounds", asError(error).message);
+ return false;
+ }
+ }, [installedGames]);
+
+ const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
+ try {
+ const existing = await getWorkaroundState(target.appid);
+ if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
+ const wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ 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);
+ }
+ const removed = await removeWorkaroundState(target.appid);
+ if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
return true;
} catch (error) {
- showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error));
+ showErrorToast("Could not clean up game workarounds", asError(error).message);
return false;
}
}, [installedGames]);
@@ -109,37 +284,65 @@ export function useGameConfiguration() {
const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
if (!selectedTarget?.name) return;
- if (cleanupLaunchOptions && !(await cleanupTargetLaunchOptions(selectedTarget))) 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);
if (result.success) await load();
- }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
+ }, [ensureTargetWorkarounds, load, selectedAppId, targets]);
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
- if (!(await cleanupTargetLaunchOptions(target))) 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;
- }, [cleanupTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
const enableAll = useCallback(async (): Promise<void> => {
const available = targets.filter((target) => !target.configured && target.name);
if (available.length === 0) return;
+
for (const target of available) {
- if (!(await cleanupTargetLaunchOptions(target))) return;
+ if (!(await ensureTargetFlatpakSupport(target))) return;
+ if (!(await ensureTargetWorkarounds(target))) return;
const result = await updateGameConfig(target.appid, target.name, template);
if (!result.success) {
- showErrorToast("Could not enable all games", result.error || "A game profile could not be created");
+ await removeTargetWorkarounds(target);
+ showErrorToast(
+ "Could not enable all games",
+ result.error || `Could not create a profile for ${target.name}`,
+ );
return;
}
}
await load();
- }, [cleanupTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ const repair = useCallback(async (appid: string): Promise<boolean> => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target) return false;
+ 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;
+ }, [ensureTargetWorkarounds, load, targets]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return;
+ if (selectedTarget && !(await removeTargetWorkarounds(selectedTarget))) return;
const result = await resetGameConfig(selectedAppId);
if (result.success) {
setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current);
@@ -147,10 +350,10 @@ export function useGameConfiguration() {
await load();
}
}
- }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
+ }, [load, removeTargetWorkarounds, selectedAppId, targets]);
const resetAll = useCallback(async () => {
for (const target of targets.filter((item) => item.configured)) {
- if (!(await cleanupTargetLaunchOptions(target))) return;
+ if (!(await removeTargetWorkarounds(target))) return;
}
const result = await resetAllGameConfigs();
if (result.success) {
@@ -158,7 +361,7 @@ export function useGameConfiguration() {
setSelectedAppId("");
await load();
}
- }, [cleanupTargetLaunchOptions, load, targets]);
+ }, [load, removeTargetWorkarounds, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load };
+ return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load };
}
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index a937780..e9e44e1 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -1,29 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- applyWorkaroundChange,
- parseWorkaroundOptions,
+ getWorkaroundState,
+ removeWorkaroundState,
+ setWorkaroundState,
+ type TargetTransport,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import {
+ getDefaultWrapperPath,
+ hasWrapperLaunchIntegration,
+ installWrapperIntegration,
+ isLegacyWrapperToken,
readSteamLaunchOptions,
+ removeWrapperIntegration,
subscribeSteamLaunchOptions,
- updateSteamLaunchOptions,
- type ParsedWorkaroundOptions,
type SteamLaunchOptionsSnapshot,
- type WorkaroundField,
} from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
+export type WorkaroundField = keyof WorkaroundState;
export type WorkaroundLoadStatus = "loading" | "ready" | "error";
const SLIDER_DEBOUNCE_MS = 250;
+const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
+ dxvkFrameRate: 0,
+ disableGamescopeWsi: true,
+ disableHdr: true,
+ disableSteamdeckMode: false,
+ disableVkbasalt: false,
+ enableZink: false,
+};
+
interface PendingSliderUpdate {
timer: number;
value: number;
waiters: Array<(success: boolean) => void>;
}
-interface WorkaroundSnapshot {
+export interface WorkaroundSnapshot {
steam: SteamLaunchOptionsSnapshot;
- parsed: ParsedWorkaroundOptions;
+ state: WorkaroundState;
+ wrapperPath: string;
+ wrapperOwned: boolean;
+ integrationInstalled: boolean;
+ commandTokenAdded: boolean;
+ shortcutExe?: string | null;
+ transport: TargetTransport;
}
interface PerAppWorkarounds {
@@ -38,19 +61,144 @@ function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
-function makeSnapshot(steam: SteamLaunchOptionsSnapshot): WorkaroundSnapshot {
- return { steam, parsed: parseWorkaroundOptions(steam.options) };
+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);
}
-export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds {
+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>>,
+ nonSteam: boolean,
+): 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),
+ 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,
+ );
+ const finalized = await setWorkaroundState(
+ appId,
+ DEFAULT_WORKAROUND_STATE,
+ nonSteam
+ ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null)
+ : null,
+ integration.commandTokenAdded,
+ transport,
+ );
+ 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) {
+ try {
+ await removeWrapperIntegration(
+ Number(appId),
+ nonSteam,
+ wrapperPath,
+ nonSteam
+ ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined)
+ : undefined,
+ integration?.commandTokenAdded ?? false,
+ );
+ } catch {
+ // Leave the owned integration in place rather than guessing at cleanup.
+ rollbackSucceeded = false;
+ }
+ }
+ if (rollbackSucceeded) {
+ const removed = await removeWorkaroundState(appId);
+ if (!removed.success) throw new Error(removed.error || "Could not roll back workaround state");
+ }
+ throw error;
+ }
+}
+
+export function usePerAppWorkarounds(
+ appId: string,
+ nonSteam: boolean,
+ transport: TargetTransport = { kind: "host" },
+): PerAppWorkarounds {
const [status, setStatus] = useState<WorkaroundLoadStatus>("loading");
const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const pendingSliderUpdate = useRef<PendingSliderUpdate | null>(null);
const numericAppId = Number(appId);
- const applySnapshot = useCallback((steam: SteamLaunchOptionsSnapshot) => {
- setSnapshot(makeSnapshot(steam));
+ const loadSnapshot = useCallback(async () => {
+ const [result, steam] = await Promise.all([
+ getWorkaroundState(appId),
+ readSteamLaunchOptions(numericAppId, nonSteam),
+ ]);
+ if (!result.success) throw new Error(result.error || "Could not read workaround state");
+ if (!result.state) {
+ return adoptWorkaroundState(
+ appId,
+ nonSteam,
+ transport,
+ steam,
+ result.wrapper_path || getDefaultWrapperPath(),
+ );
+ }
+ return makeSnapshot(steam, result, nonSteam);
+ }, [appId, nonSteam, numericAppId, transport]);
+
+ const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
+ setSnapshot(next);
setStatus("ready");
setError(null);
}, []);
@@ -59,65 +207,81 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
setStatus("loading");
setError(null);
try {
- applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam));
+ applySnapshot(await loadSnapshot());
} catch (refreshError) {
const nextError = asError(refreshError);
setStatus("error");
setError(nextError.message);
}
- }, [applySnapshot, nonSteam, numericAppId]);
+ }, [applySnapshot, loadSnapshot]);
useEffect(() => {
let active = true;
setStatus("loading");
setSnapshot(null);
setError(null);
-
- const handleSnapshot = (nextSnapshot: SteamLaunchOptionsSnapshot) => {
- if (!active) return;
- applySnapshot(nextSnapshot);
- };
- const handleSubscriptionError = (subscriptionError: Error) => {
- if (!active) return;
- setStatus("error");
- setError(subscriptionError.message);
- };
-
let unsubscribe = () => {};
try {
unsubscribe = subscribeSteamLaunchOptions(
numericAppId,
nonSteam,
- handleSnapshot,
- handleSubscriptionError,
+ (steam) => {
+ if (!active) return;
+ setSnapshot((current) => current ? {
+ ...current,
+ steam,
+ integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath),
+ } : current);
+ },
+ (subscriptionError) => {
+ if (!active) return;
+ setStatus("error");
+ setError(subscriptionError.message);
+ },
);
} catch (subscriptionError) {
- handleSubscriptionError(asError(subscriptionError));
+ if (active) {
+ setStatus("error");
+ setError(asError(subscriptionError).message);
+ }
}
-
- void readSteamLaunchOptions(numericAppId, nonSteam)
- .then((nextSnapshot) => {
- if (active) applySnapshot(nextSnapshot);
- })
+ void loadSnapshot()
+ .then((next) => { if (active) applySnapshot(next); })
.catch((readError) => {
- if (active) handleSubscriptionError(asError(readError));
+ if (active) {
+ setStatus("error");
+ setError(asError(readError).message);
+ }
});
-
return () => {
active = false;
unsubscribe();
};
- }, [applySnapshot, nonSteam, numericAppId]);
+ }, [applySnapshot, loadSnapshot, nonSteam, numericAppId]);
const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => {
+ const current = snapshot;
+ if (!current) return false;
setError(null);
+ const nextState = { ...current.state, [field]: value } as WorkaroundState;
try {
- const nextSnapshot = await updateSteamLaunchOptions(
- numericAppId,
- nonSteam,
- (options) => applyWorkaroundChange(options, field, value),
+ const result = await setWorkaroundState(
+ appId,
+ nextState,
+ current.shortcutExe ?? null,
+ current.commandTokenAdded,
+ current.transport,
);
- applySnapshot(nextSnapshot);
+ if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
+ applySnapshot({
+ ...current,
+ 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) {
const nextError = asError(updateError);
@@ -126,14 +290,13 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
showErrorToast("Workaround update failed", nextError.message);
return false;
}
- }, [applySnapshot, nonSteam, numericAppId]);
+ }, [appId, applySnapshot, snapshot]);
const flushSliderUpdate = useCallback(async (): Promise<boolean> => {
const pending = pendingSliderUpdate.current;
if (!pending) return true;
-
pendingSliderUpdate.current = null;
- window.clearTimeout(pending.timer);
+ clearTimeout(pending.timer);
const success = await persistUpdate("dxvkFrameRate", pending.value);
pending.waiters.forEach((resolve) => resolve(success));
return success;
@@ -143,30 +306,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
if (field === "dxvkFrameRate") {
setError(null);
return new Promise<boolean>((resolve) => {
- const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] };
+ const pending = pendingSliderUpdate.current || { timer: 0, value: 0, waiters: [] };
window.clearTimeout(pending.timer);
pending.value = Number(value);
pending.waiters.push(resolve);
- pending.timer = window.setTimeout(() => {
- void flushSliderUpdate();
- }, SLIDER_DEBOUNCE_MS);
+ pending.timer = window.setTimeout(() => { void flushSliderUpdate(); }, SLIDER_DEBOUNCE_MS);
pendingSliderUpdate.current = pending;
});
}
-
const sliderSuccess = await flushSliderUpdate();
if (!sliderSuccess) return false;
return persistUpdate(field, value);
}, [flushSliderUpdate, persistUpdate]);
- useEffect(() => {
- return () => {
- const pending = pendingSliderUpdate.current;
- if (!pending) return;
- window.clearTimeout(pending.timer);
- pendingSliderUpdate.current = null;
- pending.waiters.forEach((resolve) => resolve(false));
- };
+ useEffect(() => () => {
+ const pending = pendingSliderUpdate.current;
+ if (!pending) return;
+ window.clearTimeout(pending.timer);
+ pendingSliderUpdate.current = null;
+ pending.waiters.forEach((resolve) => resolve(false));
}, [numericAppId, nonSteam]);
return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]);
diff --git a/src/i18n/languages.json b/src/i18n/languages.json
index 3f1992c..7e5676b 100644
--- a/src/i18n/languages.json
+++ b/src/i18n/languages.json
@@ -22,7 +22,9 @@
"CONFIG_ENABLE_WSI": "WSIを有効化",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。",
"CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化",
- "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。",
+ "CONFIG_DISABLE_HDR": "HDRを無効化",
+ "CONFIG_DISABLE_HDR_DESC": "DXVKがゲームにHDRを公開しないようにします。ゲームの再起動が必要です。",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化",
"CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。",
"CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化",
@@ -109,7 +111,9 @@
"CONFIG_ENABLE_WSI": "WSI 활성화",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화",
- "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.",
+ "CONFIG_DISABLE_HDR": "HDR 비활성화",
+ "CONFIG_DISABLE_HDR_DESC": "DXVK가 게임에 HDR을 노출하지 않도록 합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화",
"CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화",
@@ -224,7 +228,9 @@
"CONFIG_ENABLE_WSI": "Enable WSI",
"CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.",
"CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI",
- "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.",
+ "CONFIG_DISABLE_HDR": "Disable HDR",
+ "CONFIG_DISABLE_HDR_DESC": "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode",
"CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
"CONFIG_DISABLE_VKBASALT": "Disable vkBasalt",
diff --git a/src/types.d.ts b/src/types.d.ts
index 4b88d3d..df433e0 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -17,6 +17,7 @@ interface SteamAppDetails {
strLaunchOptions?: string;
strShortcutLaunchOptions?: string;
strShortcutExe?: string;
+ strShortcutStartDir?: string;
}
interface SteamAppDetailsRegistration {
@@ -30,6 +31,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>;
GetAllShortcuts?(): Promise<unknown[]>;
}
diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts
deleted file mode 100644
index 5b8156c..0000000
--- a/src/utils/steamLaunchOptionParser.ts
+++ /dev/null
@@ -1,501 +0,0 @@
-export interface WorkaroundState {
- dxvkFrameRate: number;
- disableGamescopeWsi: boolean;
- disableSteamdeckMode: boolean;
- disableVkbasalt: boolean;
- enableZink: boolean;
-}
-
-export type WorkaroundField = keyof WorkaroundState;
-
-export interface ParsedWorkaroundOptions {
- state: WorkaroundState;
- issues: string[];
-}
-
-interface LaunchToken {
- raw: string;
- value: string;
-}
-
-interface EnvironmentEntry {
- value: string;
- count: number;
-}
-
-type BooleanWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">;
-type EnvironmentSpec = readonly [key: string, value: string];
-type DxvkFrameRateKey = "dxvk.maxFrameRate" | "dxgi.maxFrameRate" | "d3d9.maxFrameRate";
-
-interface WorkaroundDefinition {
- spec: EnvironmentSpec;
- clear: readonly string[];
- label?: string;
-}
-
-const COMMAND_TOKEN = "%command%";
-const LEGACY_WRAPPER_TOKENS = new Set([
- "~/lsfg",
- "/home/deck/lsfg",
- "~/.local/bin/lsfg-vk-experimental",
- "/home/deck/.local/bin/lsfg-vk-experimental",
- "~/.local/bin/mako-run",
- "/home/deck/.local/bin/mako-run",
- "~/.local/bin/mako-launch",
- "/home/deck/.local/bin/mako-launch",
-]);
-const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [
- "dxvk.maxFrameRate",
- "dxgi.maxFrameRate",
- "d3d9.maxFrameRate",
-];
-const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]);
-const WORKAROUND_DEFINITIONS = {
- disableGamescopeWsi: {
- spec: ["ENABLE_GAMESCOPE_WSI", "0"],
- clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"],
- },
- disableSteamdeckMode: {
- spec: ["SteamDeck", "0"],
- clear: ["SteamDeck"],
- label: "Steam Deck mode",
- },
- disableVkbasalt: {
- spec: ["DISABLE_VKBASALT", "1"],
- clear: ["DISABLE_VKBASALT"],
- label: "Disable vkBasalt",
- },
- enableZink: {
- spec: ["MESA_LOADER_DRIVER_OVERRIDE", "zink"],
- clear: ["__GLX_VENDOR_LIBRARY_NAME", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER"],
- },
-} as const satisfies Record<BooleanWorkaroundField, WorkaroundDefinition>;
-const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [
- "disableGamescopeWsi",
- "disableSteamdeckMode",
- "disableVkbasalt",
- "enableZink",
-];
-const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI";
-const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI";
-const MANAGED_ENV_KEYS = new Set([
- ...DXVK_MANAGED_KEYS,
- ...BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
-]);
-
-const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
- dxvkFrameRate: 0,
- disableGamescopeWsi: false,
- disableSteamdeckMode: false,
- disableVkbasalt: false,
- enableZink: false,
-};
-
-export function getDefaultWorkaroundState(): WorkaroundState {
- return { ...DEFAULT_WORKAROUND_STATE };
-}
-
-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;
- }
- }
-
- return value;
-}
-
-// Steam stores one shell-like line. Keep each token's raw spelling beside its
-// decoded value so managed edits leave unrelated quoting and arguments alone.
-function tokenize(options: string): LaunchToken[] {
- const tokens: 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);
- tokens.push({ raw, value: decodeToken(raw) });
- }
- return tokens;
-}
-
-function serialize(tokens: readonly LaunchToken[]): string {
- if (tokens.length === 1 && tokens[0].raw.toLowerCase() === COMMAND_TOKEN) return "";
- return tokens.map((token) => token.raw).join(" ");
-}
-
-export function normalizeLaunchOptions(options: string): string {
- return serialize(tokenize(options));
-}
-
-function parseEnvironmentToken(token: LaunchToken): [string, string] | null {
- const separator = token.value.indexOf("=");
- if (separator < 1) return null;
- const key = token.value.slice(0, separator);
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
- return [key, token.value.slice(separator + 1)];
-}
-
-function findCommandIndex(tokens: readonly LaunchToken[]): number {
- return tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN);
-}
-
-function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number {
- let count = 0;
- while (count < tokens.length && parseEnvironmentToken(tokens[count]) !== null) count += 1;
- return count;
-}
-
-function effectivePrefixLimit(tokens: readonly LaunchToken[]): number {
- return leadingEnvironmentCount(tokens);
-}
-
-function effectiveEnvironmentEntries(tokens: readonly LaunchToken[]): Map<string, EnvironmentEntry> {
- const entries = new Map<string, EnvironmentEntry>();
- for (let index = 0; index < effectivePrefixLimit(tokens); index += 1) {
- const parsed = parseEnvironmentToken(tokens[index]);
- if (!parsed) continue;
- const [key, value] = parsed;
- const previous = entries.get(key);
- entries.set(key, { value, count: (previous?.count || 0) + 1 });
- }
- return entries;
-}
-
-function removePrefixAssignments(tokens: LaunchToken[], predicate: (token: LaunchToken) => boolean): boolean {
- const limit = effectivePrefixLimit(tokens);
- const retained = tokens.filter((token, index) => index >= limit || !predicate(token));
- if (retained.length === tokens.length) return false;
- tokens.splice(0, tokens.length, ...retained);
- return true;
-}
-
-function removeAllAssignments(tokens: LaunchToken[], keys: ReadonlySet<string>): boolean {
- return removePrefixAssignments(tokens, (token) => {
- const parsed = parseEnvironmentToken(token);
- return parsed !== null && keys.has(parsed[0]);
- });
-}
-
-function encodeEnvironmentValue(value: string): string {
- if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value;
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
-}
-
-function insertEnvironmentSpecs(tokens: LaunchToken[], specs: readonly EnvironmentSpec[]): void {
- tokens.unshift(...specs.map(([key, value]) => ({
- raw: `${key}=${encodeEnvironmentValue(value)}`,
- value: `${key}=${value}`,
- })));
-}
-
-function ensureCommandToken(tokens: LaunchToken[]): void {
- if (findCommandIndex(tokens) >= 0) return;
- tokens.splice(leadingEnvironmentCount(tokens), 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN });
-}
-
-export function isLegacyWrapperToken(value: string): boolean {
- const path = decodeToken(value);
- return LEGACY_WRAPPER_TOKENS.has(path);
-}
-
-function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean {
- const commandIndex = findCommandIndex(tokens);
- const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length;
- const retained = tokens.filter((token, index) => index >= prefixEnd || !isLegacyWrapperToken(token.raw));
- if (retained.length === tokens.length) return false;
- tokens.splice(0, tokens.length, ...retained);
- return true;
-}
-
-interface DxvkConfigAssignment {
- values: string[];
- malformed: number;
-}
-
-interface ParsedDxvkConfig {
- segments: string[];
- assignments: Map<DxvkFrameRateKey, DxvkConfigAssignment>;
-}
-
-function splitDxvkConfig(value: string): string[] {
- const segments: string[] = [];
- let start = 0;
- let quote: "'" | '"' | null = null;
- let escaped = false;
-
- for (let index = 0; index < value.length; index += 1) {
- const character = value[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 (character === ";") {
- segments.push(value.slice(start, index));
- start = index + 1;
- }
- }
-
- segments.push(value.slice(start));
- return segments;
-}
-
-function knownDxvkKey(value: string): DxvkFrameRateKey | null {
- const key = value.match(/^([A-Za-z][A-Za-z0-9.]*)/)?.[1];
- return key && DXVK_FRAME_RATE_KEYS.includes(key as DxvkFrameRateKey)
- ? key as DxvkFrameRateKey
- : null;
-}
-
-function parseDxvkConfig(value: string): ParsedDxvkConfig {
- const segments = splitDxvkConfig(value);
- const assignments = new Map<DxvkFrameRateKey, DxvkConfigAssignment>();
- for (const segment of segments) {
- const trimmed = segment.trim();
- const key = knownDxvkKey(trimmed);
- if (!key) continue;
- const match = trimmed.match(/^[A-Za-z][A-Za-z0-9.]*\s*=\s*(.*?)\s*$/);
- const entry = assignments.get(key) || { values: [], malformed: 0 };
- if (match) entry.values.push(match[1]);
- else entry.malformed += 1;
- assignments.set(key, entry);
- }
- return { segments, assignments };
-}
-
-function isDxvkFrameRateSegment(segment: string): boolean {
- return knownDxvkKey(segment.trim()) !== null;
-}
-
-function parseSupportedFrameRate(value: string): number | null {
- if (!/^\d+$/.test(value)) return null;
- const numericValue = Number(value);
- return Number.isSafeInteger(numericValue) && numericValue <= 60 ? numericValue : null;
-}
-
-function rewriteDxvkFrameRate(tokens: LaunchToken[], frameRate: number): void {
- const config = effectiveEnvironmentEntries(tokens).get("DXVK_CONFIG");
- const parsed = parseDxvkConfig(config?.value || "");
- const retained = parsed.segments
- .filter((segment) => !isDxvkFrameRateSegment(segment))
- .filter((segment) => segment.trim().length > 0)
- .join(";");
- const nextConfig = frameRate > 0
- ? [`dxvk.maxFrameRate = ${frameRate}`, ...(retained ? [retained] : [])].join(";")
- : retained;
-
- removeAllAssignments(tokens, DXVK_MANAGED_KEYS);
- if (nextConfig) {
- ensureCommandToken(tokens);
- insertEnvironmentSpecs(tokens, [["DXVK_CONFIG", nextConfig]]);
- }
-}
-
-function environmentSpecsForState(state: WorkaroundState): EnvironmentSpec[] {
- return BOOLEAN_WORKAROUND_FIELDS
- .filter((field) => state[field])
- .map((field) => WORKAROUND_DEFINITIONS[field].spec);
-}
-
-function validateFrameRate(frameRate: number): void {
- if (!Number.isInteger(frameRate) || frameRate < 0 || frameRate > 60) {
- throw new Error("Base FPS Cap must be an integer from 0 to 60");
- }
-}
-
-function readBoolean(
- entries: Map<string, EnvironmentEntry>,
- key: string,
- label: string,
- trueValue: string,
- issues: string[],
-): boolean {
- const entry = entries.get(key);
- if (!entry) return false;
- const falseValue = trueValue === "1" ? "0" : "1";
- if (entry.value === trueValue) return true;
- if (entry.value === falseValue) return false;
- issues.push(`${label} has an unsupported value.`);
- return false;
-}
-
-export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions {
- const tokens = tokenize(options);
- const entries = effectiveEnvironmentEntries(tokens);
- const state = getDefaultWorkaroundState();
- const issues: string[] = [];
-
- for (const [key, entry] of entries) {
- if (MANAGED_ENV_KEYS.has(key) && entry.count > 1) {
- issues.push(`${key} appears more than once; Steam uses the last value.`);
- }
- }
-
- const dxvkConfig = parseDxvkConfig(entries.get("DXVK_CONFIG")?.value || "");
- const effectiveDxvkValues = new Map<DxvkFrameRateKey, number | null>();
- for (const key of DXVK_FRAME_RATE_KEYS) {
- const assignment = dxvkConfig.assignments.get(key);
- if (!assignment) continue;
- if (assignment.malformed > 0) issues.push(`${key} in DXVK_CONFIG is malformed.`);
- if (assignment.values.length > 1) {
- issues.push(`${key} appears more than once in DXVK_CONFIG; DXVK uses the last value.`);
- }
- if (assignment.values.length === 0) continue;
- const value = parseSupportedFrameRate(assignment.values[assignment.values.length - 1]);
- effectiveDxvkValues.set(key, value);
- if (value === null) issues.push(`${key} in DXVK_CONFIG is outside the supported 0-60 range.`);
- }
-
- const unifiedFrameRate = effectiveDxvkValues.get("dxvk.maxFrameRate");
- const dxgiFrameRate = effectiveDxvkValues.get("dxgi.maxFrameRate");
- const d3d9FrameRate = effectiveDxvkValues.get("d3d9.maxFrameRate");
- if (unifiedFrameRate !== undefined) {
- if (unifiedFrameRate !== null) state.dxvkFrameRate = unifiedFrameRate;
- } else if (dxgiFrameRate !== undefined && d3d9FrameRate !== undefined) {
- if (dxgiFrameRate !== null && dxgiFrameRate === d3d9FrameRate) state.dxvkFrameRate = dxgiFrameRate;
- else issues.push("DXVK_CONFIG has conflicting or invalid DirectX frame caps.");
- } else if (dxgiFrameRate !== undefined || d3d9FrameRate !== undefined) {
- const partial = dxgiFrameRate ?? d3d9FrameRate;
- if (partial !== null && partial !== undefined) state.dxvkFrameRate = partial;
- issues.push("DXVK_CONFIG only caps one DirectX API; adjust the cap to normalize it.");
- }
-
- if (entries.has("DXVK_FRAME_RATE")) {
- issues.push("DXVK_FRAME_RATE is obsolete on current DXVK; adjust the cap to migrate it.");
- }
-
- const wsiSignals: boolean[] = [];
- const wsiDisable = entries.get(WSI_DISABLE_KEY);
- if (wsiDisable) {
- if (wsiDisable.value !== "0" && wsiDisable.value !== "1") issues.push("Disable Gamescope WSI has an unsupported value.");
- else wsiSignals.push(wsiDisable.value === "1");
- }
- const wsiEnable = entries.get(WSI_ENABLE_KEY);
- if (wsiEnable) {
- if (wsiEnable.value !== "0" && wsiEnable.value !== "1") issues.push("Enable Gamescope WSI has an unsupported value.");
- else wsiSignals.push(wsiEnable.value === "0");
- }
- if (wsiSignals.length > 0) {
- if (wsiSignals.length === 2 && wsiSignals[0] !== wsiSignals[1]) {
- issues.push("Gamescope WSI has conflicting enable and disable assignments.");
- }
- state.disableGamescopeWsi = wsiSignals.some(Boolean);
- }
-
- for (const field of ["disableSteamdeckMode", "disableVkbasalt"] as const) {
- const { spec, label } = WORKAROUND_DEFINITIONS[field];
- state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues);
- }
-
- const vkBasaltEnable = entries.get("ENABLE_VKBASALT");
- const vkBasaltDisable = entries.get("DISABLE_VKBASALT");
- if (vkBasaltEnable?.value === "1" && vkBasaltDisable?.value === "1") {
- issues.push("vkBasalt has conflicting enable and disable assignments.");
- }
-
- const zink = entries.get(WORKAROUND_DEFINITIONS.enableZink.spec[0]);
- const glxVendor = entries.get("__GLX_VENDOR_LIBRARY_NAME");
- const galliumDriver = entries.get("GALLIUM_DRIVER");
- const hasLegacyZink = glxVendor !== undefined || galliumDriver !== undefined;
- if (zink || hasLegacyZink) {
- state.enableZink = zink?.value === WORKAROUND_DEFINITIONS.enableZink.spec[1];
- if (hasLegacyZink && (
- glxVendor?.value !== "mesa" ||
- zink?.value !== WORKAROUND_DEFINITIONS.enableZink.spec[1] ||
- galliumDriver?.value !== "zink"
- )) {
- issues.push("Zink workaround is only partially configured.");
- } else if (!state.enableZink) {
- issues.push("Zink workaround has an unsupported driver value.");
- }
- }
-
- return { state, issues };
-}
-
-export function applyWorkaroundState(options: string, state: WorkaroundState): string {
- validateFrameRate(state.dxvkFrameRate);
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
- rewriteDxvkFrameRate(tokens, state.dxvkFrameRate);
- const keysToClear = new Set<string>(
- BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
- );
- if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT");
- removeAllAssignments(tokens, keysToClear);
- const specs = environmentSpecsForState(state);
- if (specs.length > 0) {
- ensureCommandToken(tokens);
- insertEnvironmentSpecs(tokens, specs);
- }
- return serialize(tokens);
-}
-
-export function applyWorkaroundChange(options: string, field: WorkaroundField, value: boolean | number): string {
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
-
- if (field === "dxvkFrameRate") {
- if (typeof value !== "number") throw new Error("Base FPS Cap must be an integer from 0 to 60");
- validateFrameRate(value);
- rewriteDxvkFrameRate(tokens, value);
- return serialize(tokens);
- }
-
- if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`);
- const definition = WORKAROUND_DEFINITIONS[field];
- const keysToClear = new Set<string>(definition.clear);
- if (value && field === "disableVkbasalt") keysToClear.add("ENABLE_VKBASALT");
- removeAllAssignments(tokens, keysToClear);
- if (value) {
- ensureCommandToken(tokens);
- insertEnvironmentSpecs(tokens, [definition.spec]);
- }
- return serialize(tokens);
-}
-
-export function cleanupLegacyLaunchOptions(options: string): string {
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
- return serialize(tokens);
-}
-
-export function cleanupLegacyWrapper(options: string): string {
- return cleanupLegacyLaunchOptions(options);
-}
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
index 39125bd..e00b32d 100644
--- a/src/utils/steamLaunchOptions.ts
+++ b/src/utils/steamLaunchOptions.ts
@@ -1,16 +1,52 @@
-// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests.
-import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts";
+const DEFAULT_WRAPPER_PATH = "~/.lsfg";
+const COMMAND_TOKEN = "%command%";
-// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests.
-export * from "./steamLaunchOptionParser.ts";
+export const LEGACY_WRAPPER_TOKENS = new Set([
+ "~/lsfg",
+ "~/.local/bin/lsfg",
+ "~/.local/bin/lsfg-vk-experimental",
+ "~/.local/bin/mako-run",
+ "mako-run",
+ "~/.local/bin/mako-launch",
+ "mako-launch",
+]);
+
+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",
+]);
+
+const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i;
+
+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;
+}
+
function validateAppId(appId: number): void {
if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID");
}
@@ -21,14 +57,30 @@ function getSteamApps(): Partial<SteamApps> | undefined {
}).SteamClient?.Apps;
}
-function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
- if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) {
- throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first");
+interface TimerHost {
+ setTimeout(handler: () => void, timeout: number): number;
+ clearTimeout(timeout: number): void;
+}
+
+function timerHost(): TimerHost {
+ if (typeof window !== "undefined") {
+ return {
+ setTimeout: (handler, timeout) => window.setTimeout(handler, timeout),
+ clearTimeout: (timeout) => window.clearTimeout(timeout),
+ };
}
return {
+ setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number,
+ clearTimeout: (timeout) => globalThis.clearTimeout(timeout),
+ };
+}
+
+function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
+ return {
appId,
nonSteam,
options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "",
+ target: nonSteam ? details.strShortcutExe || "" : "",
details,
};
}
@@ -44,7 +96,7 @@ function registerSteamAppDetails(
validateAppId(appId);
const apps = getSteamApps();
const registerForAppDetails = apps?.RegisterForAppDetails;
- if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable");
+ if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable");
let active = true;
let unregisterPending = false;
@@ -58,7 +110,7 @@ function registerSteamAppDetails(
try {
registration.unregister();
} catch {
- // Steam may invalidate registrations during a details refresh.
+ // Steam can invalidate a registration while details are refreshing.
}
};
@@ -71,7 +123,7 @@ function registerSteamAppDetails(
try {
registration.unregister();
} catch {
- // The registration can be invalidated before a synchronous callback returns.
+ // A synchronous callback can invalidate the registration before return.
}
}
} catch (error) {
@@ -83,25 +135,21 @@ function registerSteamAppDetails(
export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> {
return new Promise((resolve, reject) => {
let settled = false;
- let timeout = 0;
+ let timeout: number | undefined;
let unsubscribe = () => {};
const finish = (error?: unknown, details?: SteamAppDetails) => {
if (settled) return;
settled = true;
- window.clearTimeout(timeout);
+ if (timeout !== undefined) timerHost().clearTimeout(timeout);
unsubscribe();
if (error) {
reject(asError(error));
return;
}
- try {
- resolve(snapshotFromDetails(appId, nonSteam, details || {}));
- } catch (snapshotError) {
- reject(asError(snapshotError));
- }
+ resolve(snapshotFromDetails(appId, nonSteam, details || {}));
};
- timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000);
+ timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000);
try {
unsubscribe = registerSteamAppDetails(appId, (details) => {
finish(undefined, details);
@@ -128,6 +176,224 @@ export function subscribeSteamLaunchOptions(
});
}
+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;
+ }
+ }
+ return value;
+}
+
+function tokenize(options: string): LaunchToken[] {
+ const tokens: 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);
+ tokens.push({ raw, value: decodeToken(raw) });
+ }
+ 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);
+}
+
+export function isLegacyWrapperToken(value: string): boolean {
+ return 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);
+ 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 {
+ 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));
+ return { options: serialize(tokens), commandTokenAdded: false };
+ }
+
+ const insertion = leadingAssignments(tokens);
+ const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-");
+ if (tokens.length !== insertion && !argumentsOnly) {
+ 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 });
+ return { options: serialize(tokens), commandTokenAdded: true };
+}
+
+/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */
+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);
+ }
+ return serialize(tokens);
+}
+
+function encodeAssignmentValue(value: string): string {
+ if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value;
+ return `"${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);
+ 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;
+ }
+ if (!MANAGED_ENV_KEYS.has(key)) retained.push(token);
+ }
+ return serialize(retained);
+}
+
+export function cleanupLegacyLaunchOptions(options: string): string {
+ const tokens = tokenize(options);
+ removeLegacyTokens(tokens);
+ 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 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;
+}
+
+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;
@@ -135,49 +401,96 @@ async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options:
await Promise.resolve(setter.call(apps, appId, options));
}
-function delay(milliseconds: number): Promise<void> {
- return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
+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));
}
-async function waitForLaunchOptions(
+async function waitForSnapshot(
appId: number,
nonSteam: boolean,
- expected: string,
+ matches: (snapshot: 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 (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot;
+ if (matches(snapshot)) return snapshot;
} catch (error) {
lastError = asError(error);
}
if (Date.now() >= deadline) break;
await delay(100);
}
- if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`);
- throw new Error("Steam did not accept the launch options before the readback timeout");
+ if (lastError) throw new Error(`${message}: ${lastError.message}`);
+ throw new Error(`${message} before the readback timeout`);
}
-const operationQueues = new Map<string, Promise<void>>();
+async function writeLaunchOptionsAndVerify(
+ appId: number,
+ nonSteam: boolean,
+ previous: string,
+ next: string,
+ 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;
+ }
+}
-function queueKey(appId: number, nonSteam: boolean): string {
- return `${nonSteam ? "shortcut" : "app"}:${appId}`;
+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);
+ } 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}`);
+ }
+ throw failure;
+ }
}
-function queueSteamAppOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
- const key = queueKey(appId, nonSteam);
+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);
- let cleanup: Promise<void>;
- cleanup = queued.then(
- () => {
- if (operationQueues.get(key) === cleanup) operationQueues.delete(key);
- },
- () => {
- if (operationQueues.get(key) === cleanup) operationQueues.delete(key);
- },
+ 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;
@@ -188,24 +501,96 @@ export function updateSteamLaunchOptions(
nonSteam: boolean,
transform: (options: string) => string,
): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamAppOperation(appId, nonSteam, async () => {
+ return queueSteamOperation(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
const next = transform(current.options);
if (next === current.options) return current;
- await setSteamLaunchOptions(appId, nonSteam, next);
- return waitForLaunchOptions(appId, nonSteam, next);
+ return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options");
});
}
-export function cleanupSteamLaunchOptions(
+export function installWrapperIntegration(
appId: number,
nonSteam: boolean,
+ wrapperPath: string,
+ commandTokenAdded = false,
+): Promise<WrapperIntegrationResult> {
+ return queueSteamOperation(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 };
+ });
+}
+
+export function removeWrapperIntegration(
+ appId: number,
+ nonSteam: boolean,
+ wrapperPath: string,
+ originalExecutable?: string,
+ commandTokenAdded = false,
): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamAppOperation(appId, nonSteam, async () => {
+ return queueSteamOperation(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
- const next = cleanupLegacyLaunchOptions(current.options);
+ 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;
- await setSteamLaunchOptions(appId, nonSteam, next);
- return waitForLaunchOptions(appId, nonSteam, next);
+ return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options");
+ });
+}
+
+export function 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");
});
}
+
+export function getDefaultWrapperPath(): string {
+ return DEFAULT_WRAPPER_PATH;
+}