summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--py_modules/lsfg_vk/constants.py1
-rw-r--r--py_modules/lsfg_vk/installation.py1
-rw-r--r--py_modules/lsfg_vk/plugin.py25
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py428
-rw-r--r--src/api/lsfgApi.ts29
-rw-r--r--src/components/ConfigurationTab.tsx3
-rw-r--r--src/components/Content.tsx2
-rw-r--r--src/components/GameConfigurationControls.tsx8
-rw-r--r--src/components/WorkaroundsSection.tsx41
-rw-r--r--src/hooks/useGameConfiguration.ts158
-rw-r--r--src/hooks/usePerAppWorkarounds.ts234
-rw-r--r--src/types.d.ts1
-rw-r--r--src/utils/steamLaunchOptionParser.ts525
-rw-r--r--src/utils/steamLaunchOptions.ts472
-rw-r--r--tests/steamLaunchOptions.test.ts434
-rw-r--r--tests/test_wrapper_service.py170
16 files changed, 1566 insertions, 966 deletions
diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py
index 19df278..960d230 100644
--- a/py_modules/lsfg_vk/constants.py
+++ b/py_modules/lsfg_vk/constants.py
@@ -5,6 +5,7 @@ VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d"
CONFIG_DIR = ".config/lsfg-vk"
SCRIPT_NAME = "lsfg"
+WRAPPER_FILENAME = ".lsfg"
CONFIG_FILENAME = "conf.toml"
ARCHIVE_FILENAME = "lsfg-vk-2.0.0.tar.xz"
LIB_FILENAME = "liblsfg-vk-layer.so"
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index b583cc7..8a3094d 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -209,7 +209,6 @@ class InstallationService(BaseService):
self.user_home / LOCAL_SHARE / "icons" / "hicolor" / "256x256" / "apps" / UI_ICON_FILENAME,
self.legacy_lib_file,
self.legacy_json_file,
- self.legacy_script_path,
):
if self._remove_if_exists(path):
removed.append(str(path))
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index 0472e42..f80c635 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -6,7 +6,7 @@ Vulkan layer for frame generation on Steam Deck.
"""
import os
-from typing import Dict, Any
+from typing import Any, Dict, Optional
import decky
@@ -15,6 +15,7 @@ from .configuration import ConfigurationService
from .flatpak_service import FlatpakService
from .runtime_service import RuntimeService
from .steam_service import SteamService
+from .wrapper_service import WrapperService
class Plugin:
@@ -36,6 +37,7 @@ class Plugin:
)
self.configuration_service = ConfigurationService(runtime_service=self.runtime_service)
self.flatpak_service = FlatpakService()
+ self.wrapper_service = WrapperService()
async def install_lsfg_vk(self) -> Dict[str, Any]:
"""Install the bundled lsfg-vk runtime to ~/.local
@@ -76,6 +78,21 @@ class Plugin:
async def reset_all_game_configs(self) -> Dict[str, Any]:
return self.configuration_service.reset_all_game_configs()
+ async def get_workaround_state(self, appid: str) -> Dict[str, Any]:
+ return self.wrapper_service.get(appid)
+
+ async def set_workaround_state(
+ self,
+ appid: str,
+ state: Dict[str, Any],
+ shortcut_exe: Optional[str] = None,
+ command_token_added: bool = False,
+ ) -> Dict[str, Any]:
+ return self.wrapper_service.set(appid, state, shortcut_exe, command_token_added)
+
+ async def remove_workaround_state(self, appid: str) -> Dict[str, Any]:
+ return self.wrapper_service.remove(appid)
+
async def get_config_file_content(self) -> Dict[str, Any]:
"""Get the current config file content
@@ -177,6 +194,9 @@ class Plugin:
This method is called by Decky Loader when the plugin is loaded.
Any initialization code should go here.
"""
+ repair = self.wrapper_service.repair()
+ if not repair.get("success"):
+ decky.logger.error(f"Could not repair lsfg workaround wrapper: {repair.get('error')}")
decky.logger.info("decky-lsfg-vk plugin loaded")
async def _unload(self):
@@ -198,6 +218,9 @@ class Plugin:
decky.logger.info("decky-lsfg-vk plugin being uninstalled")
# Clean up lsfg-vk files when the plugin is uninstalled
+ # Launch integrations are removed with their profiles. Keep the
+ # generated pass-through wrapper if it is still referenced elsewhere;
+ # InstallationService only removes files owned by the runtime bundle.
self.installation_service.cleanup_on_uninstall()
try:
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
new file mode 100644
index 0000000..a3cba6e
--- /dev/null
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -0,0 +1,428 @@
+"""Own the small per-AppID workaround dispatcher used by Steam launches."""
+
+from __future__ import annotations
+
+import json
+import re
+import shlex
+import threading
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple
+
+from .base_service import BaseService
+from .constants import WRAPPER_FILENAME
+
+
+class WrapperService(BaseService):
+ """Persist workaround state and compile it into a safe POSIX wrapper."""
+
+ FORMAT_VERSION = 1
+ MARKER = "# lsfg-vk-wrapper-format: 1"
+ WRAPPER_TOKEN = "~/.lsfg"
+ STATE_FIELDS = (
+ "dxvkFrameRate",
+ "disableGamescopeWsi",
+ "disableHdr",
+ "disableSteamdeckMode",
+ "disableVkbasalt",
+ "enableZink",
+ )
+ BOOLEAN_FIELDS = STATE_FIELDS[1:]
+ MANAGED_ENV_KEYS = (
+ "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",
+ )
+
+ def __init__(self, logger=None):
+ super().__init__(logger)
+ self.sidecar_path = self.config_dir / "workarounds.json"
+ self.wrapper_path = self.user_home / WRAPPER_FILENAME
+ self._lock = threading.RLock()
+
+ @classmethod
+ def default_state(cls) -> Dict[str, Any]:
+ return {
+ "dxvkFrameRate": 0,
+ "disableGamescopeWsi": True,
+ "disableHdr": True,
+ "disableSteamdeckMode": False,
+ "disableVkbasalt": False,
+ "enableZink": False,
+ }
+
+ @staticmethod
+ def _valid_appid(appid: Any) -> str:
+ value = str(appid)
+ if not re.fullmatch(r"[1-9][0-9]*", value):
+ raise ValueError("Invalid Steam App ID")
+ return value
+
+ @classmethod
+ def _validate_state(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise ValueError("Workaround state must be an object")
+ missing = [field for field in cls.STATE_FIELDS if field not in raw]
+ if missing:
+ raise ValueError("Workaround state is missing: " + ", ".join(missing))
+ state = {field: raw[field] for field in cls.STATE_FIELDS}
+ frame_rate = state["dxvkFrameRate"]
+ if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60:
+ raise ValueError("Base FPS Cap must be an integer from 0 to 60")
+ for field in cls.BOOLEAN_FIELDS:
+ if type(state[field]) is not bool:
+ raise ValueError(f"{field} must be a boolean")
+ return state
+
+ @classmethod
+ def _validate_entry(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise ValueError("Workaround AppID entry must be an object")
+ entry = {
+ "state": cls._validate_state(raw.get("state")),
+ "command_token_added": raw.get("command_token_added", False),
+ }
+ if type(entry["command_token_added"]) is not bool:
+ raise ValueError("command_token_added must be a boolean")
+ if "shortcut_exe" in raw and raw["shortcut_exe"] is not None:
+ shortcut_exe = raw["shortcut_exe"]
+ if (
+ not isinstance(shortcut_exe, str)
+ or not shortcut_exe.startswith("/")
+ or "\x00" in shortcut_exe
+ or not shortcut_exe.strip()
+ ):
+ raise ValueError("shortcut_exe must be an absolute executable path")
+ entry["shortcut_exe"] = shortcut_exe
+ return entry
+
+ @classmethod
+ def _validate_document(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict) or raw.get("version") != cls.FORMAT_VERSION:
+ raise ValueError("Unsupported lsfg-vk workaround state version")
+ apps = raw.get("apps")
+ if not isinstance(apps, dict):
+ raise ValueError("Workaround state apps must be an object")
+ validated_apps: Dict[str, Any] = {}
+ for appid, entry in apps.items():
+ normalized = cls._valid_appid(appid)
+ if normalized != str(appid):
+ raise ValueError("Workaround AppIDs must not contain leading zeroes")
+ validated_apps[normalized] = cls._validate_entry(entry)
+ return {"version": cls.FORMAT_VERSION, "apps": validated_apps}
+
+ def _empty_document(self) -> Dict[str, Any]:
+ return {"version": self.FORMAT_VERSION, "apps": {}}
+
+ def _read_document(self) -> Tuple[Dict[str, Any], bool, Optional[str]]:
+ if not self.sidecar_path.exists():
+ return self._empty_document(), False, None
+ if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file():
+ raise RuntimeError("Workaround state path is not a regular file")
+ try:
+ raw = json.loads(self.sidecar_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise RuntimeError(f"Could not read workaround state: {error}") from error
+ document = self._validate_document(raw)
+ return document, True, self.sidecar_path.read_text(encoding="utf-8")
+
+ def _wrapper_marker(self) -> bool:
+ if self.wrapper_path.is_symlink() or not self.wrapper_path.exists():
+ return False
+ if not self.wrapper_path.is_file():
+ raise RuntimeError("lsfg wrapper path is not a regular file")
+ try:
+ prefix = "\n".join(self.wrapper_path.read_text(encoding="utf-8").splitlines()[:8])
+ except OSError as error:
+ raise RuntimeError(f"Could not read lsfg wrapper: {error}") from error
+ return self.MARKER in prefix
+
+ def _assert_wrapper_owned_or_absent(self) -> bool:
+ if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink():
+ return False
+ if self.wrapper_path.is_symlink() or not self._wrapper_marker():
+ raise RuntimeError(
+ f"Refusing to replace unowned wrapper at {self.wrapper_path}"
+ )
+ return True
+
+ @staticmethod
+ def _shell(value: str) -> str:
+ return shlex.quote(value)
+
+ @classmethod
+ def _state_lines(cls, state: Dict[str, Any], shortcut_exe: Optional[str]) -> list[str]:
+ lines = [" unset " + " ".join(cls.MANAGED_ENV_KEYS)]
+ if state["disableGamescopeWsi"]:
+ lines.extend([" ENABLE_GAMESCOPE_WSI=0", " export ENABLE_GAMESCOPE_WSI"])
+ if state["disableHdr"]:
+ lines.extend([" DXVK_HDR=0", " export DXVK_HDR"])
+ if state["disableSteamdeckMode"]:
+ lines.extend([" SteamDeck=0", " export SteamDeck"])
+ if state["disableVkbasalt"]:
+ lines.extend([" DISABLE_VKBASALT=1", " export DISABLE_VKBASALT"])
+ if state["enableZink"]:
+ lines.extend([
+ " __GLX_VENDOR_LIBRARY_NAME=mesa",
+ " export __GLX_VENDOR_LIBRARY_NAME",
+ " MESA_LOADER_DRIVER_OVERRIDE=zink",
+ " export MESA_LOADER_DRIVER_OVERRIDE",
+ " GALLIUM_DRIVER=zink",
+ " export GALLIUM_DRIVER",
+ ])
+ frame_rate = state["dxvkFrameRate"]
+ if frame_rate > 0:
+ lines.extend([
+ ' if [ -n "${DXVK_CONFIG+x}" ]; then',
+ ' if [ -n "${DXVK_CONFIG}" ]; then',
+ f' DXVK_CONFIG="${{DXVK_CONFIG}}; dxvk.maxFrameRate = {frame_rate}"',
+ " else",
+ f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"',
+ " fi",
+ " else",
+ f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"',
+ " fi",
+ " export DXVK_CONFIG",
+ ])
+ lines.append(f" shortcut_exe={cls._shell(shortcut_exe or '')}")
+ return lines
+
+ @classmethod
+ def _flatpak_args(cls, state: Dict[str, Any]) -> list[str]:
+ args = [
+ '"--env=SteamAppId=$appid"',
+ '"--unset-env=DISABLE_GAMESCOPE_WSI"',
+ '"--unset-env=ENABLE_GAMESCOPE_WSI"' if not state["disableGamescopeWsi"] else
+ '"--env=ENABLE_GAMESCOPE_WSI=0"',
+ '"--unset-env=DXVK_HDR"' if not state["disableHdr"] else
+ '"--env=DXVK_HDR=0"',
+ '"--unset-env=SteamDeck"' if not state["disableSteamdeckMode"] else
+ '"--env=SteamDeck=0"',
+ '"--unset-env=DISABLE_VKBASALT" "--unset-env=ENABLE_VKBASALT"',
+ ]
+ if state["disableVkbasalt"]:
+ args.append('"--env=DISABLE_VKBASALT=1"')
+ args.extend([
+ '"--unset-env=MESA_LOADER_DRIVER_OVERRIDE" "--unset-env=__GLX_VENDOR_LIBRARY_NAME" "--unset-env=GALLIUM_DRIVER"',
+ ])
+ if state["enableZink"]:
+ args.extend([
+ '"--env=__GLX_VENDOR_LIBRARY_NAME=mesa"',
+ '"--env=MESA_LOADER_DRIVER_OVERRIDE=zink"',
+ '"--env=GALLIUM_DRIVER=zink"',
+ ])
+ args.extend([
+ '"--unset-env=DXVK_FRAME_RATE"',
+ ])
+ static_args = " ".join(args)
+ return [
+ ' if [ -n "${DXVK_CONFIG+x}" ]; then',
+ f' set -- "$flatpak_command" {static_args} "--env=DXVK_CONFIG=$DXVK_CONFIG" "$@"',
+ " else",
+ f' set -- "$flatpak_command" {static_args} "$@"',
+ " fi",
+ ]
+
+ @classmethod
+ def _render_wrapper(cls, document: Dict[str, Any]) -> str:
+ lines = [
+ "#!/bin/sh",
+ cls.MARKER,
+ "# Generated by Decky LSFG-VK; edits will be rejected on the next update.",
+ "",
+ "appid=",
+ 'case "${SteamAppId-}" in',
+ " ''|*[!0-9]*) ;;",
+ ' *) appid="${SteamAppId}" ;;',
+ "esac",
+ 'if [ -z "$appid" ]; then',
+ ' case "${SteamGameId-}" in',
+ " ''|*[!0-9]*) ;;",
+ ' *) appid="${SteamGameId}" ;;',
+ " esac",
+ "fi",
+ 'if [ -z "$appid" ]; then',
+ ' case "${STEAM_COMPAT_APP_ID-}" in',
+ " ''|*[!0-9]*) ;;",
+ ' *) appid="${STEAM_COMPAT_APP_ID}" ;;',
+ " esac",
+ "fi",
+ "shortcut_exe=",
+ 'case "$appid" in',
+ ]
+ for appid in sorted(document["apps"], key=lambda value: int(value)):
+ entry = document["apps"][appid]
+ lines.append(f" {appid})")
+ lines.extend(cls._state_lines(entry["state"], entry.get("shortcut_exe")))
+ lines.append(" ;;")
+ lines.extend([
+ "esac",
+ "",
+ 'if [ -n "$shortcut_exe" ]; then',
+ ' if [ "${1-}" = "run" ]; then',
+ ' flatpak_command="$1"',
+ " shift",
+ ])
+ # The arguments are emitted per branch below so the values are static and
+ # the wrapper never needs a JSON parser or another helper executable.
+ lines.append(' case "$appid" in')
+ for appid in sorted(document["apps"], key=lambda value: int(value)):
+ entry = document["apps"][appid]
+ if not entry.get("shortcut_exe", "").endswith("/flatpak"):
+ continue
+ lines.append(f" {appid})")
+ lines.extend(cls._flatpak_args(entry["state"]))
+ lines.append(" ;;")
+ lines.extend([
+ " esac",
+ " fi",
+ ' exec "$shortcut_exe" "$@"',
+ "fi",
+ 'exec "$@"',
+ "",
+ ])
+ return "\n".join(lines)
+
+ def _write_document(self, document: Dict[str, Any]) -> None:
+ content = json.dumps(document, indent=2, sort_keys=True) + "\n"
+ self._write_file(self.sidecar_path, content, 0o644)
+
+ def _write_pair(self, document: Dict[str, Any]) -> None:
+ old_sidecar_exists = self.sidecar_path.exists()
+ old_sidecar = self.sidecar_path.read_text(encoding="utf-8") if old_sidecar_exists else None
+ old_wrapper_exists = self.wrapper_path.exists() or self.wrapper_path.is_symlink()
+ old_wrapper = self.wrapper_path.read_text(encoding="utf-8") if old_wrapper_exists and not self.wrapper_path.is_symlink() else None
+ try:
+ self._write_document(document)
+ self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755)
+ except Exception:
+ try:
+ if old_sidecar_exists and old_sidecar is not None:
+ self._write_file(self.sidecar_path, old_sidecar, 0o644)
+ elif self.sidecar_path.exists():
+ self.sidecar_path.unlink()
+ if old_wrapper_exists and old_wrapper is not None:
+ self._write_file(self.wrapper_path, old_wrapper, 0o755)
+ elif not old_wrapper_exists and self.wrapper_path.exists():
+ self.wrapper_path.unlink()
+ except Exception as rollback_error:
+ self.log.error(f"Could not roll back workaround wrapper update: {rollback_error}")
+ raise
+
+ def _response(self, document: Dict[str, Any], appid: str = "") -> Dict[str, Any]:
+ entry = document["apps"].get(appid)
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "appid": appid or None,
+ "state": dict(entry["state"]) if entry else None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": self._wrapper_marker() if document["apps"] else False,
+ "shortcut_exe": entry.get("shortcut_exe") if entry else None,
+ "command_token_added": entry.get("command_token_added", False) if entry else False,
+ }
+
+ def get(self, appid: str) -> Dict[str, Any]:
+ try:
+ normalized = self._valid_appid(appid)
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ return self._response(document, normalized)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "appid": str(appid),
+ "state": None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ }
+
+ def set(
+ self,
+ appid: str,
+ state: Dict[str, Any],
+ shortcut_exe: Optional[str] = None,
+ command_token_added: bool = False,
+ ) -> Dict[str, Any]:
+ try:
+ normalized = self._valid_appid(appid)
+ validated_state = self._validate_state(state)
+ if type(command_token_added) is not bool:
+ raise ValueError("command_token_added must be a boolean")
+ with self._lock:
+ self._assert_wrapper_owned_or_absent()
+ document, _, _ = self._read_document()
+ previous_entry = document["apps"].get(normalized)
+ entry: Dict[str, Any] = {
+ "state": validated_state,
+ "command_token_added": bool(command_token_added),
+ }
+ if shortcut_exe is not None:
+ entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe})
+ elif previous_entry and "shortcut_exe" in previous_entry:
+ entry["shortcut_exe"] = previous_entry["shortcut_exe"]
+ document["apps"][normalized] = entry
+ self._write_pair(document)
+ return self._response(document, normalized)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "appid": str(appid),
+ "state": None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ }
+
+ def remove(self, appid: str) -> Dict[str, Any]:
+ try:
+ normalized = self._valid_appid(appid)
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ if normalized not in document["apps"]:
+ return self._response(document, normalized)
+ document["apps"].pop(normalized, None)
+ self._write_pair(document)
+ return self._response(document, normalized)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "appid": str(appid),
+ "state": None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ }
+
+ def repair(self) -> Dict[str, Any]:
+ """Regenerate a missing owned wrapper without importing old global state."""
+ try:
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ if not document["apps"]:
+ return self._response(document)
+ self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755)
+ return self._response(document)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ }
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 8eaad98..087228c 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -60,6 +60,27 @@ export interface GameConfigResult extends ConfigUpdateResult {
config?: LsfgConfig;
}
+export interface WorkaroundState {
+ dxvkFrameRate: number;
+ disableGamescopeWsi: boolean;
+ disableHdr: boolean;
+ disableSteamdeckMode: boolean;
+ disableVkbasalt: boolean;
+ enableZink: boolean;
+}
+
+export interface WorkaroundStateResult {
+ success: boolean;
+ message?: string;
+ error?: string;
+ appid?: string;
+ state?: WorkaroundState | null;
+ wrapper_path?: string;
+ wrapper_owned?: boolean;
+ shortcut_exe?: string | null;
+ command_token_added?: boolean;
+}
+
export interface FileContentResult {
success: boolean;
content?: string;
@@ -120,3 +141,11 @@ export const getInstalledGames = callable<[], InstalledGamesResult>("get_install
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,
+], 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 c41c941..62a971b 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,6 +28,7 @@ export function ConfigurationTab({
onConfigChange,
onEnable,
onEnableAll,
+ onRepair,
onReset,
onResetAll,
}: ConfigurationTabProps) {
@@ -157,6 +159,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..59dc514 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -39,6 +39,7 @@ export function Content() {
save,
enable,
enableAll,
+ repair,
resetSelected,
resetAll,
reload,
@@ -132,6 +133,7 @@ export function Content() {
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)}
onEnable={enable}
onEnableAll={enableAll}
+ onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
/>
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 58ccd02..7025f78 100644
--- a/src/components/GameConfigurationControls.tsx
+++ b/src/components/GameConfigurationControls.tsx
@@ -11,6 +11,7 @@ interface Props {
onFpsMultiplierFocused?: () => void;
showWorkarounds?: boolean;
workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">;
+ onRepairWorkaround?: () => Promise<boolean>;
}
export function GameConfigurationControls({
@@ -20,6 +21,7 @@ export function GameConfigurationControls({
onFpsMultiplierFocused,
showWorkarounds = false,
workaroundTarget,
+ onRepairWorkaround,
}: Props) {
return (
<>
@@ -31,7 +33,11 @@ export function GameConfigurationControls({
/>
<ConfigurationSection config={config} onConfigChange={onConfigChange} />
{showWorkarounds && workaroundTarget && (
- <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} />
+ <WorkaroundsSection
+ appId={workaroundTarget.appid}
+ nonSteam={workaroundTarget.nonSteam}
+ onRepair={onRepairWorkaround}
+ />
)}
</>
);
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index d783620..dbdd6a3 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -3,11 +3,12 @@ import { useEffect, useState } from "react";
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
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;
+ onRepair?: () => Promise<boolean>;
}
const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2";
@@ -78,18 +79,28 @@ function usePersistentCollapsed() {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) {
+export function WorkaroundsSection({ appId, nonSteam, 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 [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]);
@@ -155,13 +166,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/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index 6d1fe6a..c66596a 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 { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { applyWorkaroundState, cleanupLegacySteamLaunchOptions, cleanupSteamLaunchOptions, getDefaultWorkaroundState, updateSteamLaunchOptions } 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; }
@@ -32,6 +32,19 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta
return Array.from(games.values());
}
+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 });
@@ -95,39 +108,107 @@ export function useGameConfiguration() {
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 ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
try {
- await cleanupLegacySteamLaunchOptions(Number(target.appid), target.nonSteam);
- return true;
- } catch (error) {
- showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error));
- return false;
- }
- }, [installedGames]);
+ 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;
+ 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 ? (oldShortcutExe || current.target) : undefined;
+ const initialIntegration = target.nonSteam
+ ? current.target === wrapperPath
+ : hasWrapperLaunchIntegration(current.options, wrapperPath);
+ const initialStateResult = await setWorkaroundState(
+ target.appid,
+ state,
+ originalExecutable || null,
+ oldCommandTokenAdded,
+ );
+ if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state");
- const removeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => {
- if (!installedGames.some((game) => game.appid === target.appid)) return true;
- try {
- await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam);
- return true;
+ 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 ? (integration.originalExecutable || originalExecutable || null) : null,
+ integration.commandTokenAdded,
+ );
+ 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 ? (integration?.originalExecutable || originalExecutable) : 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)
+ : 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 clean up Steam launch options", error instanceof Error ? error.message : String(error));
+ showErrorToast("Could not initialize workarounds", asError(error).message);
return false;
}
}, [installedGames]);
- const initializeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => {
+ const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
try {
- await updateSteamLaunchOptions(
- Number(target.appid),
- target.nonSteam,
- (options) => applyWorkaroundState(options, getDefaultWorkaroundState()),
- );
+ 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 initialize Steam launch options", error instanceof Error ? error.message : String(error));
+ showErrorToast("Could not clean up game workarounds", asError(error).message);
return false;
}
}, [installedGames]);
@@ -135,37 +216,46 @@ 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 initializeTargetLaunchOptions(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;
- }, [initializeTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
const enableAll = useCallback(async (): Promise<void> => {
const available = targets.filter((target) => !target.configured && target.name);
if (available.length === 0) return;
for (const target of available) {
- if (!(await initializeTargetLaunchOptions(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);
return;
}
}
await load();
- }, [initializeTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ const repair = useCallback(async (appid: string): Promise<boolean> => {
+ const target = targets.find((item) => item.appid === appid);
+ return target ? ensureTargetWorkarounds(target) : false;
+ }, [ensureTargetWorkarounds, targets]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (selectedTarget && !(await removeTargetLaunchOptions(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);
@@ -173,10 +263,10 @@ export function useGameConfiguration() {
await load();
}
}
- }, [load, removeTargetLaunchOptions, selectedAppId, targets]);
+ }, [load, removeTargetWorkarounds, selectedAppId, targets]);
const resetAll = useCallback(async () => {
for (const target of targets.filter((item) => item.configured)) {
- if (!(await removeTargetLaunchOptions(target))) return;
+ if (!(await removeTargetWorkarounds(target))) return;
}
const result = await resetAllGameConfigs();
if (result.success) {
@@ -184,7 +274,7 @@ export function useGameConfiguration() {
setSelectedAppId("");
await load();
}
- }, [load, removeTargetLaunchOptions, 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..9e283db 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -1,29 +1,50 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- applyWorkaroundChange,
- parseWorkaroundOptions,
+ getWorkaroundState,
+ removeWorkaroundState,
+ setWorkaroundState,
+ 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;
}
interface PerAppWorkarounds {
@@ -38,8 +59,84 @@ 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 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,
+ };
+}
+
+async function adoptWorkaroundState(
+ appId: string,
+ nonSteam: boolean,
+ 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 ? steam.target : null;
+ const initial = await setWorkaroundState(appId, DEFAULT_WORKAROUND_STATE, originalExecutable, false);
+ 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 ? (integration.originalExecutable || originalExecutable) : null,
+ integration.commandTokenAdded,
+ );
+ 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 ? (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): PerAppWorkarounds {
@@ -49,8 +146,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
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,
+ steam,
+ result.wrapper_path || getDefaultWrapperPath(),
+ );
+ }
+ return makeSnapshot(steam, result, nonSteam);
+ }, [appId, nonSteam, numericAppId]);
+
+ const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
+ setSnapshot(next);
setStatus("ready");
setError(null);
}, []);
@@ -59,65 +173,79 @@ 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,
);
- 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,
+ });
return true;
} catch (updateError) {
const nextError = asError(updateError);
@@ -126,14 +254,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 +270,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/types.d.ts b/src/types.d.ts
index 4b88d3d..7b5d055 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -30,6 +30,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 9449b33..0000000
--- a/src/utils/steamLaunchOptionParser.ts
+++ /dev/null
@@ -1,525 +0,0 @@
-export interface WorkaroundState {
- dxvkFrameRate: number;
- disableGamescopeWsi: boolean;
- disableHdr: 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",
- "mako-run",
- "~/.local/bin/mako-launch",
- "/home/deck/.local/bin/mako-launch",
- "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"],
- },
- disableHdr: {
- spec: ["DXVK_HDR", "0"],
- clear: ["DXVK_HDR"],
- label: "Disable 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",
- "disableHdr",
- "disableSteamdeckMode",
- "disableVkbasalt",
- "enableZink",
-];
-const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI";
-const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI";
-const WORKAROUND_ENV_KEYS = new Set(
- BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
-);
-const MANAGED_ENV_KEYS = new Set([
- ...DXVK_MANAGED_KEYS,
- ...WORKAROUND_ENV_KEYS,
-]);
-
-const EMPTY_WORKAROUND_STATE: WorkaroundState = {
- dxvkFrameRate: 0,
- disableGamescopeWsi: false,
- disableHdr: false,
- disableSteamdeckMode: false,
- disableVkbasalt: false,
- enableZink: false,
-};
-const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
- ...EMPTY_WORKAROUND_STATE,
- disableGamescopeWsi: true,
- disableHdr: true,
-};
-
-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;
-}
-
-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 = { ...EMPTY_WORKAROUND_STATE };
- 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 ["disableHdr", "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>(
- WORKAROUND_ENV_KEYS,
- );
- 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 cleanupPluginLaunchOptions(options: string): string {
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
- rewriteDxvkFrameRate(tokens, 0);
- removeAllAssignments(tokens, WORKAROUND_ENV_KEYS);
- 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 82ff94b..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, cleanupPluginLaunchOptions, 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,37 +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 = cleanupPluginLaunchOptions(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 queueSteamAppOperation(appId, nonSteam, async () => {
+ return queueSteamOperation(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
- const next = cleanupLegacyLaunchOptions(current.options);
+ const next = cleanupPluginLaunchOptions(current.options, wrapperPath);
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 clean legacy launch options");
});
}
+
+export function getDefaultWrapperPath(): string {
+ return DEFAULT_WRAPPER_PATH;
+}
diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts
index c4dede3..3170fe8 100644
--- a/tests/steamLaunchOptions.test.ts
+++ b/tests/steamLaunchOptions.test.ts
@@ -1,342 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
- applyWorkaroundChange,
- applyWorkaroundState,
- cleanupLegacyLaunchOptions,
+ cleanupPluginAssignments,
cleanupPluginLaunchOptions,
cleanupLegacyWrapper,
- getDefaultWorkaroundState,
+ hasWrapperLaunchIntegration,
+ installWrapperIntegration,
+ installWrapperLaunchOption,
isLegacyWrapperToken,
normalizeLaunchOptions,
- parseWorkaroundOptions,
readSteamLaunchOptions,
- updateSteamLaunchOptions,
+ removeWrapperIntegration,
+ removeWrapperLaunchOption,
} from "../src/utils/steamLaunchOptions.ts";
-test("maps the supported workarounds to current launch variables", () => {
- const options = applyWorkaroundState('gamemoderun %command% --profile "high quality"', {
- dxvkFrameRate: 30,
- disableGamescopeWsi: true,
- disableHdr: true,
- disableSteamdeckMode: true,
- disableVkbasalt: true,
- enableZink: true,
- });
+const wrapper = "~/.lsfg";
- assert.equal(
- options,
- 'ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"',
- );
- assert.deepEqual(parseWorkaroundOptions(options), {
- state: {
- dxvkFrameRate: 30,
- disableGamescopeWsi: true,
- disableHdr: true,
- disableSteamdeckMode: true,
- disableVkbasalt: true,
- enableZink: true,
- },
- issues: [],
+test("inserts one wrapper immediately before an existing command macro", () => {
+ assert.deepEqual(installWrapperLaunchOption('gamemoderun %command% --profile "high quality"', wrapper), {
+ options: 'gamemoderun ~/.lsfg %command% --profile "high quality"',
+ commandTokenAdded: false,
+ });
+ assert.equal(hasWrapperLaunchIntegration(`gamemoderun ${wrapper} %command%`, wrapper), true);
+ assert.deepEqual(installWrapperLaunchOption(`gamemoderun ${wrapper} %command%`, wrapper), {
+ options: `gamemoderun ${wrapper} %command%`,
+ commandTokenAdded: false,
});
});
-test("uses SteamDeck=0 before %command% without a wrapper", () => {
- assert.equal(
- applyWorkaroundChange("", "disableSteamdeckMode", true),
- "SteamDeck=0 %command%",
- );
-});
-
-test("defaults new profiles to disable Gamescope WSI and HDR", () => {
- const defaults = getDefaultWorkaroundState();
- assert.equal(defaults.disableGamescopeWsi, true);
- assert.equal(defaults.disableHdr, true);
- assert.equal(
- applyWorkaroundState("%command%", defaults),
- "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%",
- );
- assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false);
- assert.equal(parseWorkaroundOptions("%command%").state.disableHdr, false);
- assert.equal(
- parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableGamescopeWsi,
- true,
- );
- assert.equal(
- parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableHdr,
- true,
- );
- assert.equal(
- applyWorkaroundChange("%command%", "disableGamescopeWsi", true),
- "ENABLE_GAMESCOPE_WSI=0 %command%",
- );
- assert.equal(
- applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 %command%", "disableGamescopeWsi", false),
- "",
- );
-
- const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%");
- assert.equal(invalid.state.disableGamescopeWsi, false);
- assert.equal(invalid.issues.length, 1);
- const conflicting = parseWorkaroundOptions("DISABLE_GAMESCOPE_WSI=1 ENABLE_GAMESCOPE_WSI=1 %command%");
- assert.equal(conflicting.state.disableGamescopeWsi, true);
- assert.match(conflicting.issues.join(" "), /conflicting/);
-});
-
-test("manages DXVK HDR independently from Gamescope WSI", () => {
- assert.equal(
- applyWorkaroundChange("%command%", "disableHdr", true),
- "DXVK_HDR=0 %command%",
- );
- assert.equal(parseWorkaroundOptions("DXVK_HDR=0 %command%").state.disableHdr, true);
- assert.equal(parseWorkaroundOptions("DXVK_HDR=1 %command%").state.disableHdr, false);
- assert.equal(
- applyWorkaroundChange("DXVK_HDR=0 %command%", "disableHdr", false),
- "",
- );
- assert.equal(
- applyWorkaroundChange("DXVK_HDR=0 %command%", "disableGamescopeWsi", true),
- "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%",
- );
-
- const invalid = parseWorkaroundOptions("DXVK_HDR=maybe %command%");
- assert.equal(invalid.state.disableHdr, false);
- assert.match(invalid.issues.join(" "), /Disable HDR has an unsupported value/);
-});
-
-test("preserves unrelated prefixes, quoted tokens, suffix arguments, and dropped variables", () => {
- const options = applyWorkaroundChange(
- 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"',
- "disableSteamdeckMode",
- true,
- );
- assert.equal(
- options,
- 'SteamDeck=0 PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"',
- );
- assert.deepEqual(parseWorkaroundOptions(options).issues, []);
-
- assert.equal(
- applyWorkaroundChange("FOO=bar --flag", "disableSteamdeckMode", true),
- "SteamDeck=0 FOO=bar %command% --flag",
- );
- assert.equal(
- applyWorkaroundChange("FOO=1 %command% MANGOHUD=1", "disableSteamdeckMode", false),
- "FOO=1 %command% MANGOHUD=1",
- );
- assert.equal(
- applyWorkaroundChange('FOO=bar --literal "%command%"', "disableSteamdeckMode", true),
- 'SteamDeck=0 FOO=bar %command% --literal "%command%"',
- );
- assert.equal(
- applyWorkaroundChange("gamemoderun SteamDeck=1 %command%", "disableSteamdeckMode", true),
- "SteamDeck=0 gamemoderun SteamDeck=1 %command%",
- );
- assert.equal(parseWorkaroundOptions("gamemoderun SteamDeck=0 %command%").state.disableSteamdeckMode, false);
-});
-
-test("uses DXVK_CONFIG for the base cap and preserves other DXVK settings", () => {
- assert.equal(
- applyWorkaroundChange("%command%", "dxvkFrameRate", 60),
- 'DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%',
- );
- assert.equal(parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%').state.dxvkFrameRate, 60);
- assert.equal(
- applyWorkaroundChange(
- 'DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%',
- "dxvkFrameRate",
- 0,
- ),
- 'DXVK_CONFIG="dxgi.syncInterval = 0" %command%',
- );
- assert.equal(
- applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 45),
- 'DXVK_CONFIG="dxvk.maxFrameRate = 45" %command%',
- );
- assert.equal(
- applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 0),
- "",
- );
-
- const apiSpecific = parseWorkaroundOptions(
- 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 30" %command%',
- );
- assert.equal(apiSpecific.state.dxvkFrameRate, 30);
- assert.deepEqual(apiSpecific.issues, []);
- const partial = parseWorkaroundOptions('DXVK_CONFIG="dxgi.maxFrameRate = 30" %command%');
- assert.equal(partial.state.dxvkFrameRate, 30);
- assert.match(partial.issues.join(" "), /only caps one DirectX API/);
- const conflicting = parseWorkaroundOptions(
- 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 60" %command%',
- );
- assert.equal(conflicting.state.dxvkFrameRate, 0);
- assert.match(conflicting.issues.join(" "), /conflicting/);
-});
-
-test("reports invalid and malformed FPS values instead of treating them as off", () => {
- const invalid = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 61" %command%');
- assert.equal(invalid.state.dxvkFrameRate, 0);
- assert.match(invalid.issues.join(" "), /outside the supported 0-60 range/);
- const malformed = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate" %command%');
- assert.equal(malformed.state.dxvkFrameRate, 0);
- assert.match(malformed.issues.join(" "), /malformed/);
- const obsolete = parseWorkaroundOptions("DXVK_FRAME_RATE=wat %command%");
- assert.equal(obsolete.state.dxvkFrameRate, 0);
- assert.match(obsolete.issues.join(" "), /obsolete/);
- assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 61), /0 to 60/);
- assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 1.5), /0 to 60/);
-});
-
-test("keeps vkBasalt disable mutually exclusive while preserving the dropped enable flag otherwise", () => {
- assert.equal(
- applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableSteamdeckMode", true),
- "SteamDeck=0 ENABLE_VKBASALT=1 %command%",
- );
- const disabled = applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableVkbasalt", true);
- assert.equal(disabled, "DISABLE_VKBASALT=1 %command%");
- assert.equal(
- applyWorkaroundChange(disabled, "disableVkbasalt", false),
- "",
- );
- const conflict = parseWorkaroundOptions("ENABLE_VKBASALT=1 DISABLE_VKBASALT=1 %command%");
- assert.equal(conflict.state.disableVkbasalt, true);
- assert.match(conflict.issues.join(" "), /conflicting/);
+test("normalizes blank and argument-only fields while refusing ambiguous launchers", () => {
+ assert.deepEqual(installWrapperLaunchOption("", wrapper), {
+ options: `${wrapper} %command%`,
+ commandTokenAdded: true,
+ });
+ assert.deepEqual(installWrapperLaunchOption("FOO=bar --windowed", wrapper), {
+ options: `FOO=bar ${wrapper} %command% --windowed`,
+ commandTokenAdded: true,
+ });
+ assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/);
+ assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/);
});
-test("handles current and legacy Zink forms and reports partial state", () => {
- const enabled = applyWorkaroundChange("%command%", "enableZink", true);
- assert.equal(enabled, "MESA_LOADER_DRIVER_OVERRIDE=zink %command%");
- assert.equal(parseWorkaroundOptions(enabled).state.enableZink, true);
-
- const legacy = parseWorkaroundOptions(
- "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%",
- );
- assert.equal(legacy.state.enableZink, true);
- assert.deepEqual(legacy.issues, []);
-
- const partial = parseWorkaroundOptions("__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink %command%");
- assert.equal(partial.state.enableZink, true);
- assert.match(partial.issues.join(" "), /partially configured/);
+test("preserves assignments, quoting, suffixes, and unrelated values", () => {
+ const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"';
assert.equal(
- applyWorkaroundChange(
- "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%",
- "enableZink",
- false,
- ),
- "",
+ installWrapperLaunchOption(options, wrapper).options,
+ 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun ~/.lsfg %command% --flag "two words"',
);
+ assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`);
+ assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag");
});
-test("cleans only the known legacy wrapper and preserves launch options", () => {
- assert.equal(
- cleanupLegacyWrapper('FOO=bar ~/lsfg %command% --arg "~/lsfg"'),
- 'FOO=bar %command% --arg "~/lsfg"',
- );
- assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), "");
- assert.equal(cleanupLegacyWrapper("mako-run %command%"), "");
- assert.equal(cleanupLegacyWrapper("mako-launch %command%"), "");
- assert.equal(
- cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"),
- "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%",
- );
- assert.equal(
- cleanupLegacyWrapper("LSFG_PROCESS=decky-lsfg-vk %command%"),
- "LSFG_PROCESS=decky-lsfg-vk %command%",
- );
- assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false);
+test("cleans current, legacy, and bare Mako wrappers without touching suffix arguments", () => {
+ for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) {
+ assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`);
+ }
+ assert.equal(cleanupLegacyWrapper(`FOO=bar ${wrapper} %command%`), "FOO=bar %command%");
+ assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true);
+ assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false);
+ assert.equal(removeWrapperLaunchOption(`FOO=bar ${wrapper} %command% --arg`, wrapper), "FOO=bar %command% --arg");
});
-test("removes plugin-managed launch options when a profile is removed", () => {
+test("removes only old plugin assignments and preserves DXVK settings", () => {
assert.equal(
- cleanupPluginLaunchOptions(
- 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" ~/lsfg %command% --windowed',
+ cleanupPluginAssignments(
+ 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%',
),
- 'DXVK_CONFIG="dxgi.syncInterval = 0" FOO="keep this" %command% --windowed',
+ 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%',
);
assert.equal(
- cleanupPluginLaunchOptions(
- 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%',
- ),
- 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%',
+ cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper),
+ "%command%",
);
assert.equal(
- cleanupPluginLaunchOptions('DXVK_CONFIG="dxvk.maxFrameRate = 30" %command%'),
- "",
+ cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"),
+ "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%",
);
});
-test("canonicalizes a bare command token without removing real arguments", () => {
- assert.equal(normalizeLaunchOptions("%command%"), "");
- assert.equal(normalizeLaunchOptions("%COMMAND%"), "");
- assert.equal(normalizeLaunchOptions("FOO=bar %command%"), "FOO=bar %command%");
- assert.equal(normalizeLaunchOptions("%command% --windowed"), "%command% --windowed");
-});
-
-test("is idempotent", () => {
- const first = applyWorkaroundChange("gamemoderun %command%", "enableZink", true);
- assert.equal(applyWorkaroundState(first, parseWorkaroundOptions(first).state), first);
- assert.equal(applyWorkaroundChange(first, "enableZink", true), first);
- const capped = applyWorkaroundChange(first, "dxvkFrameRate", 30);
- assert.equal(applyWorkaroundChange(capped, "dxvkFrameRate", 30), capped);
-});
-
-test("reads and writes the matching Steam app-details launch-option field", async () => {
+test("reads the matching app-details field and installs/removes Steam integration", async () => {
const previousWindow = (globalThis as Record<string, unknown>).window;
const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
- let normalOptions = "FOO=bar %command%";
+ let appOptions = "FOO=bar %command%";
let shortcutOptions = "--windowed";
- const normalWrites: string[] = [];
+ let shortcutTarget = "/usr/bin/example-game";
+ const appWrites: string[] = [];
const shortcutWrites: string[] = [];
+ const targetWrites: string[] = [];
const unregisters: number[] = [];
-
- const windowShim = { setTimeout, clearTimeout };
const apps = {
RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) {
- if (appId === 42) {
- callback({ strLaunchOptions: normalOptions, strShortcutLaunchOptions: "must-not-be-read" });
- } else {
- callback({
- strShortcutExe: "/usr/bin/example-game",
- strShortcutLaunchOptions: shortcutOptions,
- strLaunchOptions: "must-not-be-read",
- });
- }
+ callback(appId === 42
+ ? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" }
+ : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" });
return { unregister: () => unregisters.push(appId) };
},
SetAppLaunchOptions(appId: number, options: string) {
assert.equal(appId, 42);
- normalWrites.push(options);
- normalOptions = options.replaceAll(" ", " ");
+ appWrites.push(options);
+ appOptions = options.replaceAll(" ", " ");
},
SetShortcutLaunchOptions(appId: number, options: string) {
assert.equal(appId, 43);
shortcutWrites.push(options);
shortcutOptions = options;
},
+ SetShortcutExe(appId: number, executable: string) {
+ assert.equal(appId, 43);
+ targetWrites.push(executable);
+ shortcutTarget = executable;
+ },
};
-
- (globalThis as Record<string, unknown>).window = windowShim;
+ (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
(globalThis as Record<string, unknown>).SteamClient = { Apps: apps };
try {
- const normalBefore = await readSteamLaunchOptions(42, false);
- assert.equal(normalBefore.options, "FOO=bar %command%");
- const normalAfter = await updateSteamLaunchOptions(
- 42,
- false,
- (options) => applyWorkaroundChange(options, "disableSteamdeckMode", true),
- );
- assert.equal(normalWrites.length, 1);
+ const normal = await readSteamLaunchOptions(42, false);
+ assert.equal(normal.options, "FOO=bar %command%");
+ const installed = await installWrapperIntegration(42, false, wrapper);
+ assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " "));
+ assert.equal(installed.commandTokenAdded, false);
+ assert.equal(appWrites.length, 1);
assert.equal(shortcutWrites.length, 0);
- assert.equal(normalAfter.options, "SteamDeck=0 FOO=bar %command%");
- const shortcutAfter = await updateSteamLaunchOptions(
- 43,
- true,
- (options) => applyWorkaroundChange(options, "disableGamescopeWsi", true),
- );
- assert.equal(shortcutWrites.length, 1);
- assert.equal(shortcutWrites[0], "ENABLE_GAMESCOPE_WSI=0 %command% --windowed");
- assert.equal(shortcutAfter.options, shortcutWrites[0]);
+ const shortcut = await installWrapperIntegration(43, true, wrapper);
+ assert.equal(shortcut.originalExecutable, "/usr/bin/example-game");
+ assert.equal(shortcut.snapshot.target, wrapper);
+ assert.deepEqual(targetWrites, [wrapper]);
+ const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable);
+ assert.equal(restored.target, "/usr/bin/example-game");
+ assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]);
+ assert.equal(shortcutWrites.length, 0);
+
+ const cleaned = await removeWrapperIntegration(42, false, wrapper, undefined, installed.commandTokenAdded);
+ assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " "));
assert.ok(unregisters.includes(42));
assert.ok(unregisters.includes(43));
} finally {
@@ -346,3 +142,69 @@ test("reads and writes the matching Steam app-details launch-option field", asyn
else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient;
}
});
+
+test("fails closed when shortcut Target ownership or setters are unavailable", async () => {
+ const previousWindow = (globalThis as Record<string, unknown>).window;
+ const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
+ (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
+ (globalThis as Record<string, unknown>).SteamClient = {
+ Apps: {
+ RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) {
+ callback({ strShortcutExe: "/usr/bin/other", strShortcutLaunchOptions: "" });
+ return { unregister() {} };
+ },
+ },
+ };
+ try {
+ await assert.rejects(installWrapperIntegration(99, true, wrapper), /Target API is unavailable/);
+ await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original"), /Target changed externally/);
+ } finally {
+ if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
+ else (globalThis as Record<string, unknown>).window = previousWindow;
+ if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient;
+ else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient;
+ }
+});
+
+test("restores launch options and shortcut Target when a setter fails after changing them", async () => {
+ const previousWindow = (globalThis as Record<string, unknown>).window;
+ const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
+ let appOptions = "FOO=bar %command%";
+ let shortcutTarget = "/usr/bin/original";
+ const appWrites: string[] = [];
+ const targetWrites: string[] = [];
+ const apps = {
+ RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) {
+ callback(appId === 42
+ ? { strLaunchOptions: appOptions }
+ : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" });
+ return { unregister() {} };
+ },
+ SetAppLaunchOptions(_appId: number, options: string) {
+ appWrites.push(options);
+ appOptions = options;
+ if (options.includes(wrapper)) throw new Error("simulated launch-option write failure");
+ },
+ SetShortcutExe(_appId: number, executable: string) {
+ targetWrites.push(executable);
+ shortcutTarget = executable;
+ if (executable === wrapper) throw new Error("simulated Target write failure");
+ },
+ };
+ (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
+ (globalThis as Record<string, unknown>).SteamClient = { Apps: apps };
+ try {
+ await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch-option write failure/);
+ assert.equal(appOptions, "FOO=bar %command%");
+ assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]);
+
+ await assert.rejects(installWrapperIntegration(43, true, wrapper), /simulated Target write failure/);
+ assert.equal(shortcutTarget, "/usr/bin/original");
+ assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]);
+ } finally {
+ if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
+ else (globalThis as Record<string, unknown>).window = previousWindow;
+ if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient;
+ else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient;
+ }
+});
diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py
new file mode 100644
index 0000000..f17a944
--- /dev/null
+++ b/tests/test_wrapper_service.py
@@ -0,0 +1,170 @@
+import os
+import subprocess
+import sys
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import Mock
+
+
+sys.modules.setdefault(
+ "decky",
+ types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()),
+)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
+
+from lsfg_vk.wrapper_service import WrapperService
+
+
+class WrapperServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.service = WrapperService()
+ self.service.user_home = self.home
+ self.service.local_bin_dir = self.home / ".local/bin"
+ self.service.config_dir = self.home / ".config/lsfg-vk"
+ self.service.sidecar_path = self.service.config_dir / "workarounds.json"
+ self.service.wrapper_path = self.service.local_bin_dir / "lsfg"
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ def _state(self, **changes):
+ state = self.service.default_state()
+ state.update(changes)
+ return state
+
+ def _run(self, appid, *args, env=None):
+ process_env = {"PATH": "/usr/bin:/bin", "SteamAppId": str(appid)}
+ if env:
+ process_env.update(env)
+ return subprocess.run(
+ [str(self.service.wrapper_path), *args],
+ env=process_env,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ def test_writes_owned_dispatcher_and_validates_shell(self):
+ response = self.service.set("123", self._state(dxvkFrameRate=60, enableZink=True))
+ self.assertTrue(response["success"])
+ self.assertEqual(response["wrapper_path"], "~/.lsfg")
+ self.assertTrue(response["wrapper_owned"])
+ self.assertEqual(response["state"]["dxvkFrameRate"], 60)
+ self.assertEqual(subprocess.run(["/bin/sh", "-n", str(self.service.wrapper_path)]).returncode, 0)
+ self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8"))
+ self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True))
+
+ def test_dispatch_clears_managed_values_preserves_other_environment_and_appends_config(self):
+ self.service.set(
+ "123",
+ self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True),
+ )
+ result = self._run(
+ 123,
+ "/usr/bin/env",
+ env={
+ "DXVK_CONFIG": "dxgi.syncInterval = 0",
+ "DXVK_FRAME_RATE": "5",
+ "ENABLE_GAMESCOPE_WSI": "1",
+ "DISABLE_VKBASALT": "0",
+ "MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe",
+ "MANGOHUD": "1",
+ },
+ )
+ values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
+ self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0")
+ self.assertEqual(values["DXVK_HDR"], "0")
+ self.assertEqual(values["SteamDeck"], "0")
+ self.assertEqual(values["DISABLE_VKBASALT"], "1")
+ self.assertEqual(values["__GLX_VENDOR_LIBRARY_NAME"], "mesa")
+ self.assertEqual(values["MESA_LOADER_DRIVER_OVERRIDE"], "zink")
+ self.assertEqual(values["GALLIUM_DRIVER"], "zink")
+ self.assertEqual(values["DXVK_CONFIG"], "dxgi.syncInterval = 0; dxvk.maxFrameRate = 30")
+ self.assertEqual(values["MANGOHUD"], "1")
+ self.assertNotIn("DXVK_FRAME_RATE", values)
+ self.assertNotIn("ENABLE_VKBASALT", values)
+
+ def test_appid_fallback_and_unmatched_passthrough(self):
+ self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False))
+ self.service.set("456", self._state(disableSteamdeckMode=True))
+ fallback = subprocess.run(
+ [str(self.service.wrapper_path), "/usr/bin/env"],
+ env={"PATH": "/usr/bin:/bin", "SteamAppId": "bad", "SteamGameId": "456"},
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line)
+ self.assertEqual(fallback_values["SteamDeck"], "0")
+ self.assertEqual(fallback_values["SteamGameId"], "456")
+
+ passthrough = subprocess.run(
+ [str(self.service.wrapper_path), "/usr/bin/env"],
+ env={"PATH": "/usr/bin:/bin", "SteamAppId": "999", "KEEP": "yes", "DXVK_HDR": "1"},
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ passthrough_values = dict(line.split("=", 1) for line in passthrough.stdout.splitlines() if "=" in line)
+ self.assertEqual(passthrough_values["KEEP"], "yes")
+ self.assertEqual(passthrough_values["DXVK_HDR"], "1")
+
+ def test_flatpak_shortcut_receives_env_arguments_and_original_target(self):
+ fake_flatpak = self.home / ".local/bin/flatpak"
+ fake_flatpak.parent.mkdir(parents=True, exist_ok=True)
+ fake_flatpak.write_text(
+ "#!/bin/sh\n"
+ "printf 'ARG:%s\\n' \"$@\"\n",
+ encoding="utf-8",
+ )
+ fake_flatpak.chmod(0o755)
+ self.service.set("123", self._state(dxvkFrameRate=20, enableZink=True), str(fake_flatpak))
+ result = self._run(123, "run", "com.example.Game", "--windowed", env={"DXVK_CONFIG": "foo=1"})
+ args = result.stdout.splitlines()
+ self.assertEqual(args[0], "ARG:run")
+ self.assertIn("ARG:--env=SteamAppId=123", args)
+ self.assertIn("ARG:--env=ENABLE_GAMESCOPE_WSI=0", args)
+ self.assertIn("ARG:--env=DXVK_HDR=0", args)
+ self.assertIn("ARG:--env=__GLX_VENDOR_LIBRARY_NAME=mesa", args)
+ self.assertIn("ARG:--env=MESA_LOADER_DRIVER_OVERRIDE=zink", args)
+ self.assertIn("ARG:--env=GALLIUM_DRIVER=zink", args)
+ self.assertIn("ARG:--env=DXVK_CONFIG=foo=1; dxvk.maxFrameRate = 20", args)
+ self.assertIn("ARG:com.example.Game", args)
+ self.assertIn("ARG:--windowed", args)
+
+ def test_invalid_state_and_foreign_wrapper_fail_closed(self):
+ invalid = self.service.set("0", self.service.default_state())
+ self.assertFalse(invalid["success"])
+ invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61})
+ self.assertFalse(invalid["success"])
+
+ self.service.local_bin_dir.mkdir(parents=True, exist_ok=True)
+ self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8")
+ response = self.service.set("123", self.service.default_state())
+ self.assertFalse(response["success"])
+ self.assertIn("unowned", response["error"])
+ self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n")
+
+ def test_remove_keeps_a_safe_owned_passthrough_wrapper(self):
+ self.service.set("123", self.service.default_state())
+ response = self.service.remove("123")
+ self.assertTrue(response["success"])
+ self.assertIsNone(self.service.get("123")["state"])
+ self.assertTrue(self.service.wrapper_path.exists())
+ result = subprocess.run(
+ [str(self.service.wrapper_path), "/usr/bin/printf", "ok"],
+ env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"},
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ self.assertEqual(result.stdout, "ok")
+
+
+if __name__ == "__main__":
+ unittest.main()