summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--assets/flatpak-target.pngbin78195 -> 0 bytes
-rw-r--r--justfile10
-rw-r--r--package.json15
-rw-r--r--py_modules/lsfg_vk/config_schema.py75
-rw-r--r--py_modules/lsfg_vk/configuration.py76
-rw-r--r--py_modules/lsfg_vk/constants.py4
-rw-r--r--py_modules/lsfg_vk/flatpak_profile_service.py381
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py785
-rw-r--r--py_modules/lsfg_vk/installation.py95
-rw-r--r--py_modules/lsfg_vk/plugin.py296
-rw-r--r--py_modules/lsfg_vk/steam_service.py233
-rw-r--r--py_modules/lsfg_vk/types.py29
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py355
-rwxr-xr-xscripts/deploy-to-deck.sh58
-rw-r--r--src/api/lsfgApi.ts146
-rw-r--r--src/components/ConfigFileTab.tsx119
-rw-r--r--src/components/ConfigurationTab.tsx128
-rw-r--r--src/components/Content.tsx181
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx39
-rw-r--r--src/components/FlatpakTab.tsx178
-rw-r--r--src/components/FlatpakWorkaroundsSection.tsx144
-rw-r--r--src/components/FlatpaksTab.tsx196
-rw-r--r--src/components/GameConfigurationControls.tsx8
-rw-r--r--src/components/GameConfigurationSelector.tsx105
-rw-r--r--src/components/InstallationButton.tsx38
-rw-r--r--src/components/NowPlayingTab.tsx27
-rw-r--r--src/components/ProfileDetails.tsx4
-rw-r--r--src/components/SetupTab.tsx78
-rw-r--r--src/components/StatusDisplay.tsx41
-rw-r--r--src/components/WorkaroundsSection.tsx66
-rw-r--r--src/components/index.ts5
-rw-r--r--src/hooks/useFlatpakConfiguration.ts108
-rw-r--r--src/hooks/useGameConfiguration.ts173
-rw-r--r--src/hooks/useInstallationActions.ts84
-rw-r--r--src/hooks/useLsfgHooks.ts87
-rw-r--r--src/hooks/usePerAppWorkarounds.ts203
-rw-r--r--src/i18n/languages.json12
-rw-r--r--src/types.d.ts2
-rw-r--r--src/utils/steamLaunchOptionParser.ts501
-rw-r--r--src/utils/steamLaunchOptions.ts413
-rw-r--r--src/utils/toastUtils.ts79
-rw-r--r--tests/steamLaunchOptions.test.ts392
-rw-r--r--tests/test_configuration_profiles.py80
-rw-r--r--tests/test_flatpak_profile_service.py210
-rw-r--r--tests/test_flatpak_service.py314
-rw-r--r--tests/test_plugin_migration.py72
-rw-r--r--tests/test_steam_service.py58
-rw-r--r--tests/test_wrapper_service.py163
48 files changed, 4468 insertions, 2398 deletions
diff --git a/assets/flatpak-target.png b/assets/flatpak-target.png
deleted file mode 100644
index 773e567..0000000
--- a/assets/flatpak-target.png
+++ /dev/null
Binary files differ
diff --git a/justfile b/justfile
index b190631..b608987 100644
--- a/justfile
+++ b/justfile
@@ -1,11 +1,15 @@
default:
- echo "Available recipes: build, test, clean"
+ echo "Available recipes: build, deploy, test, clean"
build:
- .vscode/build.sh
+ pnpm build
+
+deploy:
+ ./scripts/deploy-to-deck.sh
test:
- scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop
+ node --experimental-strip-types --test tests/steamLaunchOptions.test.ts
+ python3.12 -m unittest discover -s tests -p 'test_*.py'
watch:
ssh deck@192.168.0.6 "journalctl -f"
diff --git a/package.json b/package.json
index b5e716c..4a1780a 100644
--- a/package.json
+++ b/package.json
@@ -52,21 +52,6 @@
"name": "lsfg-vk-2.0.0.tar.xz",
"url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz",
"sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74"
- },
- {
- "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak",
- "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak",
- "sha256hash": "7eff81f96b278fde6fe395c88461c55e611547bf5991d179b9145ec6f5a778b1"
- },
- {
- "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak",
- "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak",
- "sha256hash": "cbd663b9021c355dddec61ec51fd4388c12705f16637cadd4b45aead0e5dc427"
- },
- {
- "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak",
- "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak",
- "sha256hash": "615e87c03b18368ed1cca97e036f1bf54fff95fc6126aac697bb368f33281cbf"
}
],
"pnpm": {
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index ce109f3..bf3e174 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -1,13 +1,9 @@
"""Small adapter for the upstream lsfg-vk v2 configuration format."""
import json
-import sys
import tomllib
-from pathlib import Path
from typing import Any, Dict, TypedDict
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-
ConfigurationData = Dict[str, Any]
@@ -57,8 +53,8 @@ class ConfigurationManager:
def validate_config(config: Dict[str, Any]) -> Dict[str, Any]:
result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS}
result.update({key: value for key, value in config.items() if key in result})
- result["active_in"] = _normalize_active_in(result.get("active_in"))
- result["pacing_mode"] = str(result.get("pacing_mode", "vsync")).lower()
+ result["active_in"] = _normalize_active_in(result["active_in"])
+ result["pacing_mode"] = str(result["pacing_mode"]).lower()
if result["pacing_mode"] != "vsync":
raise ValueError("pacing_mode must be vsync")
result["multiplier"] = int(result["multiplier"])
@@ -67,43 +63,24 @@ class ConfigurationManager:
result["flow_scale"] = float(result["flow_scale"])
if not 0.25 <= result["flow_scale"] <= 1.0:
raise ValueError("flow_scale must be between 0.25 and 1.0")
- for name in ("no_fp16", "performance_mode", "override_present_mode", "preserve_swapchain_image_count"):
+ for name in (
+ "no_fp16",
+ "performance_mode",
+ "override_present_mode",
+ "preserve_swapchain_image_count",
+ ):
result[name] = bool(result[name])
- result["dll"] = str(result.get("dll") or "")
+ result["dll"] = str(result["dll"] or "")
return result
@staticmethod
- def _migrate_dll_path(value: Any) -> str:
- path_value = str(value or "")
- if not path_value:
- return ""
- path = Path(path_value)
- if path.name.lower() in {"lossless.dll"}:
- return str(path.with_name("lsfg-vk.dll"))
- return path_value
-
- @staticmethod
- def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]:
- raw = dict(profile)
- if "pacing_mode" not in raw and "pacing" in raw:
- raw["pacing_mode"] = raw["pacing"]
- if "override_present_mode" not in raw and "experimental_present_mode" in raw:
- raw["override_present_mode"] = raw["experimental_present_mode"] == "fifo"
- raw["dll"] = global_config.get("dll", "")
- raw["no_fp16"] = global_config.get("no_fp16", False)
- return ConfigurationManager.validate_config(raw)
-
- @staticmethod
def generate_toml_content_multi_profile(profile_data: ProfileData) -> str:
global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})}
lines = ["version = 2", "", "[global]"]
- dll = ConfigurationManager._migrate_dll_path(global_config.get("dll"))
- if dll:
- lines.append(f"dll = {_toml_value(dll)}")
- lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}")
- profiles = sorted(profile_data["profiles"].items())
- if not profiles:
- profiles = [("", {})]
+ if global_config["dll"]:
+ lines.append(f"dll = {_toml_value(global_config['dll'])}")
+ lines.append(f"allow_fp16 = {_toml_value(not bool(global_config['no_fp16']))}")
+ profiles = sorted(profile_data["profiles"].items()) or [("", {})]
for name, raw in profiles:
config = ConfigurationManager.validate_config({**raw, **global_config})
lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"])
@@ -122,26 +99,20 @@ class ConfigurationManager:
@staticmethod
def parse_toml_content_multi_profile(content: str) -> ProfileData:
data = tomllib.loads(content)
- version = data.get("version")
- if version not in (1, 2):
+ if data.get("version") != 2:
raise ValueError("unsupported lsfg-vk configuration version")
- raw_global = dict(data.get("global", {}))
+ raw_global = data.get("global", {})
global_config = {
- "dll": ConfigurationManager._migrate_dll_path(raw_global.get("dll", "")),
+ "dll": str(raw_global.get("dll", "") or ""),
"no_fp16": not bool(raw_global.get("allow_fp16", True)),
}
profiles: Dict[str, Dict[str, Any]] = {}
- source_profiles = data.get("game", []) if version == 1 else data.get("profile", [])
- for profile in source_profiles:
- name = str(profile.get("exe" if version == 1 else "name", ""))
- config = ConfigurationManager._config_from_profile(profile, global_config)
- if config["active_in"]:
+ for profile in data.get("profile", []):
+ name = str(profile.get("name", ""))
+ config = ConfigurationManager.validate_config({
+ **profile,
+ **global_config,
+ })
+ if name or config["active_in"]:
profiles[name] = config
return {"profiles": profiles, "global_config": global_config}
-
- @staticmethod
- def is_legacy_v1(content: str) -> bool:
- try:
- return tomllib.loads(content).get("version") == 1
- except tomllib.TOMLDecodeError:
- return False
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index bec3828..17dcaf3 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -7,7 +7,7 @@ from .runtime_service import RuntimeService
class ConfigurationService(BaseService):
- """Controller-facing adapter over upstream lsfg-vk profiles."""
+ FLATPAK_PROFILE_PREFIX = "flatpak:"
def __init__(self, logger=None, runtime_service: RuntimeService = None):
super().__init__(logger)
@@ -47,6 +47,13 @@ class ConfigurationService(BaseService):
(None, None),
)
+ @classmethod
+ def flatpak_profile_name(cls, app_id: str) -> str:
+ value = str(app_id).strip()
+ if not value:
+ raise ValueError("Flatpak application ID is required")
+ return f"{cls.FLATPAK_PROFILE_PREFIX}{value}"
+
@staticmethod
def _public_config(config: Dict[str, Any]) -> Dict[str, Any]:
return ConfigurationManager.validate_config(config)
@@ -87,6 +94,64 @@ class ConfigurationService(BaseService):
except Exception as error:
return self._error_response(dict, str(error), appid=str(appid), config=None)
+ def get_flatpak_config(self, app_id: str) -> Dict[str, Any]:
+ try:
+ data = self._get_profile_data()
+ name = self.flatpak_profile_name(app_id)
+ raw = data["profiles"].get(name)
+ return self._success_response(
+ dict,
+ app_id=str(app_id),
+ profile=name,
+ exists=raw is not None,
+ config=self._public_config(raw) if raw is not None else None,
+ global_config=dict(data["global_config"]),
+ )
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False)
+
+ def update_flatpak_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ data = self._get_profile_data()
+ name = self.flatpak_profile_name(app_id)
+ merged_config = {**data["global_config"], **config}
+ if not config.get("dll"):
+ merged_config["dll"] = data["global_config"].get("dll", "")
+ validated = self._public_config(merged_config)
+ validated["active_in"] = []
+ data["global_config"] = {
+ "dll": validated["dll"],
+ "no_fp16": validated["no_fp16"],
+ }
+ data["profiles"][name] = validated
+ self._save_profile_data(data)
+ return self._success_response(dict, app_id=str(app_id), profile=name, exists=True, config=validated)
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False)
+
+ def reset_flatpak_config(self, app_id: str) -> Dict[str, Any]:
+ try:
+ data = self._get_profile_data()
+ name = self.flatpak_profile_name(app_id)
+ data["profiles"].pop(name, None)
+ self._save_profile_data(data)
+ return self._success_response(dict, app_id=str(app_id), profile=name, exists=False)
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False)
+
+ def reset_all_flatpak_configs(self) -> Dict[str, Any]:
+ try:
+ data = self._get_profile_data()
+ data["profiles"] = {
+ name: profile
+ for name, profile in data["profiles"].items()
+ if not name.startswith(self.FLATPAK_PROFILE_PREFIX)
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]))
+ except Exception as error:
+ return self._error_response(dict, str(error))
+
def reset_game_config(self, appid: str) -> Dict[str, Any]:
try:
data = self._get_profile_data()
@@ -101,7 +166,14 @@ class ConfigurationService(BaseService):
def reset_all_game_configs(self) -> Dict[str, Any]:
try:
data = self._get_profile_data()
- data["profiles"] = {}
+ data["profiles"] = {
+ name: profile
+ for name, profile in data["profiles"].items()
+ if not (
+ len(profile.get("active_in", [])) == 1
+ and re.fullmatch(r"-?[0-9]+", str(profile.get("active_in", [""])[0]))
+ )
+ }
self._save_profile_data(data)
return self._success_response(dict, global_config=dict(data["global_config"]), games=[])
except Exception as error:
diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py
index 19df278..45ac4c5 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"
@@ -15,9 +16,6 @@ CLI_FILENAME = "lsfg-vk-cli"
UI_FILENAME = "lsfg-vk-ui"
UI_DESKTOP_FILENAME = "gay.pancake.lsfg-vk-ui.desktop"
UI_ICON_FILENAME = "gay.pancake.lsfg-vk-ui.png"
-FLATPAK_23_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak"
-FLATPAK_24_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak"
-FLATPAK_25_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak"
STEAM_LOSSLESS_SCALING_APP_ID = "993090"
STEAM_LOSSLESS_SCALING_BRANCH = "lsfg-vk"
diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py
new file mode 100644
index 0000000..ddec116
--- /dev/null
+++ b/py_modules/lsfg_vk/flatpak_profile_service.py
@@ -0,0 +1,381 @@
+from __future__ import annotations
+
+import re
+from typing import Any, Dict
+
+from .configuration import ConfigurationService
+from .flatpak_service import FlatpakService
+
+
+class FlatpakProfileService:
+ STATE_FIELDS = (
+ "dxvkFrameRate",
+ "disableGamescopeWsi",
+ "disableHdr",
+ "disableSteamdeckMode",
+ "disableVkbasalt",
+ "enableZink",
+ )
+ BOOLEAN_FIELDS = STATE_FIELDS[1:]
+ DXVK_FRAME_RATE_SEGMENT = re.compile(
+ r"^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=",
+ re.IGNORECASE,
+ )
+
+ def __init__(
+ self,
+ flatpak_service: FlatpakService,
+ configuration_service: ConfigurationService,
+ ):
+ self.flatpak_service = flatpak_service
+ self.configuration_service = configuration_service
+
+ @classmethod
+ def default_state(cls) -> Dict[str, Any]:
+ return {
+ "dxvkFrameRate": 0,
+ "disableGamescopeWsi": True,
+ "disableHdr": True,
+ "disableSteamdeckMode": False,
+ "disableVkbasalt": False,
+ "enableZink": False,
+ }
+
+ @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
+
+ def _state_entry(self, app_id: str) -> tuple[Dict[str, object], Dict[str, Any]]:
+ state = self.flatpak_service._read_state()
+ entry = state["prepared_apps"].get(app_id)
+ if not isinstance(entry, dict):
+ raise RuntimeError("Flatpak application is not owned by this plugin")
+ return state, entry
+
+ def _baseline_content(self, app_id: str, entry: Dict[str, Any]) -> str:
+ if not entry.get("override_existed"):
+ return ""
+ path = self.flatpak_service._backup_path(app_id)
+ if path.is_symlink() or not path.is_file():
+ raise RuntimeError("Flatpak override backup is unavailable")
+ return path.read_text(encoding="utf-8")
+
+ @staticmethod
+ def _environment_value(content: str, key: str) -> str:
+ section = None
+ for raw_line in content.splitlines():
+ line = raw_line.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1]
+ continue
+ if section != "Environment":
+ continue
+ name, separator, value = line.partition("=")
+ if separator and name == key:
+ return value
+ return ""
+
+ @classmethod
+ def _dxvk_config(cls, baseline: str, frame_rate: int) -> str:
+ existing = cls._environment_value(baseline, "DXVK_CONFIG")
+ parts = [part.strip() for part in existing.split(";") if part.strip()]
+ parts = [part for part in parts if not cls.DXVK_FRAME_RATE_SEGMENT.match(part)]
+ if frame_rate > 0:
+ parts.append(f"dxvk.maxFrameRate = {frame_rate}")
+ return "; ".join(parts)
+
+ def _restore_baseline(self, app_id: str, entry: Dict[str, Any]) -> None:
+ existed, current = self.flatpak_service._snapshot_override(app_id)
+ current_hash = self.flatpak_service._sha256(current) if existed else self.flatpak_service._sha256(b"")
+ if current_hash != entry.get("managed_sha256"):
+ raise RuntimeError("Flatpak override changed after preparation; refusing to overwrite unrelated settings")
+ path = self.flatpak_service._override_path(app_id)
+ if entry.get("override_existed"):
+ self.flatpak_service._write_file(path, self._baseline_content(app_id, entry))
+ else:
+ path.unlink(missing_ok=True)
+
+ def _apply_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]:
+ workaround_state = self._validate_state(workaround_state)
+ _, entry = self._state_entry(app_id)
+ baseline = self._baseline_content(app_id, entry)
+ self._restore_baseline(app_id, entry)
+ prepared = self.flatpak_service.prepare_app(app_id)
+ if not prepared.get("success") or not prepared.get("owned"):
+ raise RuntimeError(prepared.get("error") or "Could not restore plugin-owned Flatpak preparation")
+ profile = self.configuration_service.flatpak_profile_name(app_id)
+ args = [
+ "override",
+ "--user",
+ f"--env=LSFGVK_PROFILE={profile}",
+ "--unset-env=DISABLE_LSFGVK",
+ "--unset-env=DISABLE_LSFG",
+ ]
+ if workaround_state["disableGamescopeWsi"]:
+ args.extend(["--env=ENABLE_GAMESCOPE_WSI=0", "--unset-env=DISABLE_GAMESCOPE_WSI"])
+ if workaround_state["disableHdr"]:
+ args.append("--env=DXVK_HDR=0")
+ if workaround_state["disableSteamdeckMode"]:
+ args.append("--env=SteamDeck=0")
+ if workaround_state["disableVkbasalt"]:
+ args.extend(["--env=DISABLE_VKBASALT=1", "--unset-env=ENABLE_VKBASALT"])
+ if workaround_state["enableZink"]:
+ args.extend([
+ "--env=__GLX_VENDOR_LIBRARY_NAME=mesa",
+ "--env=MESA_LOADER_DRIVER_OVERRIDE=zink",
+ "--env=GALLIUM_DRIVER=zink",
+ ])
+ dxvk_config = self._dxvk_config(baseline, workaround_state["dxvkFrameRate"])
+ if dxvk_config:
+ args.append(f"--env=DXVK_CONFIG={dxvk_config}")
+ args.append(app_id)
+ result = self.flatpak_service._run_flatpak_command(args, capture_output=True, text=True)
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or f"Could not apply Flatpak workarounds for {app_id}")
+ existed, managed = self.flatpak_service._snapshot_override(app_id)
+ if not existed:
+ raise RuntimeError(f"Flatpak override for {app_id} was not created")
+ state = self.flatpak_service._read_state()
+ entry = state["prepared_apps"].get(app_id)
+ if not isinstance(entry, dict):
+ raise RuntimeError("Flatpak application ownership state disappeared")
+ entry["managed_sha256"] = self.flatpak_service._sha256(managed)
+ entry["workaround_state"] = workaround_state
+ self.flatpak_service._write_state(state)
+ return workaround_state
+
+ def enable_app(self, app_id: str) -> Dict[str, Any]:
+ created_profile = False
+ newly_owned = False
+ try:
+ existing = self.configuration_service.get_flatpak_config(app_id)
+ before = self.flatpak_service._read_state()
+ was_owned = app_id in before["prepared_apps"]
+ prepared = self.flatpak_service.prepare_app(app_id)
+ if not prepared.get("success"):
+ raise RuntimeError(prepared.get("error") or "Could not prepare Flatpak application")
+ if not prepared.get("owned"):
+ raise RuntimeError("Flatpak application is prepared outside this plugin and cannot be managed safely")
+ newly_owned = not was_owned
+ if not existing.get("exists"):
+ config = {
+ **self.configuration_service._public_config({}),
+ **(existing.get("global_config") or {}),
+ }
+ config["active_in"] = []
+ saved = self.configuration_service.update_flatpak_config(app_id, config)
+ if not saved.get("success"):
+ raise RuntimeError(saved.get("error") or "Could not create Flatpak profile")
+ created_profile = True
+ _, entry = self._state_entry(app_id)
+ workaround_state = self._validate_state(entry.get("workaround_state", self.default_state()))
+ self._apply_state(app_id, workaround_state)
+ return self.get_app(app_id)
+ except Exception as error:
+ if created_profile:
+ self.configuration_service.reset_flatpak_config(app_id)
+ if newly_owned:
+ self.flatpak_service.remove_app_override(app_id)
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+
+ def update_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ self._state_entry(app_id)
+ result = self.configuration_service.update_flatpak_config(app_id, config)
+ if not result.get("success"):
+ raise RuntimeError(result.get("error") or "Could not update Flatpak profile")
+ return self.get_app(app_id)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+
+ def get_workaround_state(self, app_id: str) -> Dict[str, Any]:
+ try:
+ _, entry = self._state_entry(app_id)
+ state = self._validate_state(entry.get("workaround_state", self.default_state()))
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "app_id": app_id,
+ "state": state,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "state": None,
+ }
+
+ def set_workaround_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ state = self._apply_state(app_id, workaround_state)
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "app_id": app_id,
+ "state": state,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "state": None,
+ }
+
+ def remove_app(self, app_id: str) -> Dict[str, Any]:
+ try:
+ removed = self.flatpak_service.remove_app_override(app_id)
+ if not removed.get("success"):
+ raise RuntimeError(removed.get("error") or "Could not remove Flatpak preparation")
+ reset = self.configuration_service.reset_flatpak_config(app_id)
+ if not reset.get("success"):
+ raise RuntimeError(reset.get("error") or "Could not remove Flatpak profile")
+ return {
+ "success": True,
+ "message": "Flatpak profile removed",
+ "error": None,
+ "app_id": app_id,
+ "enabled": False,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "enabled": True,
+ }
+
+ def get_app(self, app_id: str) -> Dict[str, Any]:
+ apps = self.get_apps()
+ if not apps.get("success"):
+ return {
+ "success": False,
+ "message": "",
+ "error": apps.get("error"),
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+ app = next((item for item in apps.get("apps", []) if item.get("app_id") == app_id), None)
+ if app is None:
+ return {
+ "success": False,
+ "message": "",
+ "error": "Flatpak application is not installed",
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+ return {"success": True, "message": "", "error": None, **app}
+
+ def get_apps(self) -> Dict[str, Any]:
+ try:
+ result = self.flatpak_service.get_flatpak_apps()
+ if not result.get("success"):
+ raise RuntimeError(result.get("error") or "Could not list Flatpak applications")
+ ownership = self.flatpak_service._read_state()
+ apps = []
+ for item in result.get("apps", []):
+ app_id = item["app_id"]
+ config_result = self.configuration_service.get_flatpak_config(app_id)
+ entry = ownership["prepared_apps"].get(app_id)
+ workarounds = self.default_state()
+ if isinstance(entry, dict):
+ workarounds = self._validate_state(entry.get("workaround_state", workarounds))
+ profile = self.configuration_service.flatpak_profile_name(app_id)
+ selector_ready = False
+ if item.get("prepared"):
+ shown = self.flatpak_service._run_flatpak_command(
+ ["override", "--user", "--show", app_id],
+ capture_output=True,
+ text=True,
+ )
+ selector_ready = shown.returncode == 0 and f"LSFGVK_PROFILE={profile}" in shown.stdout.splitlines()
+ apps.append({
+ **item,
+ "profile": profile,
+ "enabled": bool(item.get("owned") and config_result.get("exists") and selector_ready),
+ "config": config_result.get("config"),
+ "workarounds": workarounds,
+ })
+ return {
+ "success": True,
+ "message": result.get("message", ""),
+ "error": None,
+ "apps": apps,
+ }
+ except Exception as error:
+ return {"success": False, "message": "", "error": str(error), "apps": []}
+
+ def get_running_apps(self) -> Dict[str, Any]:
+ try:
+ state = self.flatpak_service._read_state()
+ enabled = set()
+ for app_id, entry in state["prepared_apps"].items():
+ if not isinstance(entry, dict):
+ continue
+ config = self.configuration_service.get_flatpak_config(app_id)
+ if not config.get("exists"):
+ continue
+ existed, content = self.flatpak_service._snapshot_override(app_id)
+ if not existed or self.flatpak_service._sha256(content) != entry.get("managed_sha256"):
+ continue
+ profile = self.configuration_service.flatpak_profile_name(app_id)
+ try:
+ text = content.decode("utf-8")
+ except UnicodeDecodeError:
+ continue
+ if self._environment_value(text, "LSFGVK_PROFILE") == profile:
+ enabled.add(app_id)
+ if not enabled:
+ return {"success": True, "message": "", "error": None, "apps": []}
+ result = self.flatpak_service._run_flatpak_command(
+ ["ps", "--columns=application,active,pid"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Could not inspect running Flatpak applications")
+ running = []
+ for line in result.stdout.splitlines():
+ fields = line.split("\t") if "\t" in line else line.split()
+ if not fields or fields[0] not in enabled:
+ continue
+ active = len(fields) > 1 and fields[1].strip().lower() in {"1", "true", "yes", "active"}
+ running.append({
+ "app_id": fields[0],
+ "active": active,
+ "pid": fields[2].strip() if len(fields) > 2 else "",
+ })
+ running.sort(key=lambda item: (not item["active"], item["app_id"]))
+ return {"success": True, "message": "", "error": None, "apps": running}
+ except Exception as error:
+ return {"success": False, "message": "", "error": str(error), "apps": []}
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py
index 6aebf11..4f04382 100644
--- a/py_modules/lsfg_vk/flatpak_service.py
+++ b/py_modules/lsfg_vk/flatpak_service.py
@@ -1,219 +1,441 @@
+from __future__ import annotations
+
+import hashlib
+import json
import os
import pwd
+import re
import shutil
import subprocess
+import threading
from pathlib import Path
-from typing import Any, Dict, List
+from typing import Dict, Optional, Set
from .base_service import BaseService
-from .config_schema import ConfigurationManager
-from .constants import (
- BIN_DIR,
- FLATPAK_23_08_FILENAME,
- FLATPAK_24_08_FILENAME,
- FLATPAK_25_08_FILENAME,
-)
-from .types import BaseResponse
class FlatpakService(BaseService):
EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk"
- SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08")
+ FLATHUB_REMOTE = "flathub"
+ SUPPORTED_RUNTIMES = ("24.08", "25.08")
+ DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"}
+ RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL"
+ OWNERSHIP_FILENAME = "flatpak_state.json"
+ OWNERSHIP_VERSION = 2
+ APP_ID_PATTERN = re.compile(
+ r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$"
+ )
def __init__(self, logger=None):
super().__init__(logger)
- self.flatpak_command = None
+ self.flatpak_command: Optional[str] = None
+ self._verified_branches: Set[str] = set()
+ self._lock = threading.RLock()
+
+ @property
+ def ownership_path(self) -> Path:
+ return self.config_dir / self.OWNERSHIP_FILENAME
+
+ @property
+ def backup_dir(self) -> Path:
+ return self.config_dir / "flatpak-overrides"
- def _get_clean_env(self) -> Dict[str, str]:
+ def _clean_env(self) -> Dict[str, str]:
env = os.environ.copy()
env.pop("LD_LIBRARY_PATH", None)
env["HOME"] = str(self.user_home)
- path_entries = [entry for entry in env.get("PATH", "").split(":") if entry]
+ path = [entry for entry in env.get("PATH", "").split(":") if entry]
for entry in ("/usr/bin", "/usr/local/bin", "/bin"):
- if entry not in path_entries:
- path_entries.insert(0, entry)
- env["PATH"] = ":".join(path_entries)
+ if entry not in path:
+ path.insert(0, entry)
+ env["PATH"] = ":".join(path)
return env
- def _flatpak_user(self) -> pwd.struct_passwd:
- try:
- return pwd.getpwuid(self.user_home.stat().st_uid)
- except (KeyError, OSError) as error:
- raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error
-
def check_flatpak_available(self) -> bool:
- env = self._get_clean_env()
+ env = self._clean_env()
self.flatpak_command = shutil.which("flatpak", path=env["PATH"])
return self.flatpak_command is not None
- def _run_flatpak_command(self, args: List[str], **kwargs):
+ def _run_flatpak_command(self, args, **kwargs):
if self.flatpak_command is None and not self.check_flatpak_available():
raise FileNotFoundError("Flatpak command not available")
+ env = self._clean_env()
command = [self.flatpak_command, *args]
- target_user = self._flatpak_user()
- if os.geteuid() != target_user.pw_uid:
- runuser = shutil.which("runuser", path=self._get_clean_env()["PATH"])
+ try:
+ user = pwd.getpwuid(self.user_home.stat().st_uid)
+ except (KeyError, OSError) as error:
+ raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error
+ if os.geteuid() != user.pw_uid:
+ runuser = shutil.which("runuser", path=env["PATH"])
if runuser is None:
raise FileNotFoundError("runuser command not available")
- command = [runuser, "--user", target_user.pw_name, "--", *command]
- return subprocess.run(
- command,
- env=self._get_clean_env(),
- **kwargs,
- )
+ command = [runuser, "--user", user.pw_name, "--", *command]
+ return subprocess.run(command, env=env, **kwargs)
@classmethod
- def _extension_ref(cls, version: str) -> str:
- return f"{cls.EXTENSION_ID}/x86_64/{version}"
+ def _validate_app_id(cls, app_id: str) -> str:
+ if not isinstance(app_id, str) or not cls.APP_ID_PATTERN.fullmatch(app_id):
+ raise ValueError("Invalid Flatpak application ID")
+ return app_id
@classmethod
- def _validate_runtime(cls, version: str) -> None:
- if version not in cls.SUPPORTED_RUNTIMES:
- raise ValueError("Unsupported Flatpak runtime")
+ def _validate_runtime(cls, branch: str) -> str:
+ if branch not in cls.SUPPORTED_RUNTIMES:
+ raise ValueError(
+ f"Unsupported Flatpak runtime branch {branch}; supported branches are "
+ + ", ".join(cls.SUPPORTED_RUNTIMES)
+ )
+ return branch
@classmethod
- def _bundle_filename(cls, version: str) -> str:
- return {
- "23.08": FLATPAK_23_08_FILENAME,
- "24.08": FLATPAK_24_08_FILENAME,
- "25.08": FLATPAK_25_08_FILENAME,
- }[version]
+ def runtime_branch_from_metadata(cls, metadata: str) -> str:
+ section = None
+ versions = []
+ for raw_line in metadata.splitlines() if isinstance(metadata, str) else []:
+ line = raw_line.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1].strip()
+ continue
+ if section != cls.RUNTIME_METADATA_SECTION:
+ continue
+ key, separator, value = line.partition("=")
+ if separator and key.strip() == "versions":
+ versions.extend(part.strip() for part in value.split(";"))
+ for value in versions:
+ for branch in cls.SUPPORTED_RUNTIMES:
+ if value == branch or value.startswith(f"{branch}-"):
+ return branch
+ raise ValueError("Could not determine a supported Freedesktop base runtime from Flatpak metadata")
- def _bundled_extension_path(self, version: str) -> Path:
- self._validate_runtime(version)
- return Path(__file__).resolve().parent.parent.parent / BIN_DIR / self._bundle_filename(version)
-
- def get_extension_status(self) -> Dict[str, Any]:
- try:
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
+ @classmethod
+ def _extension_ref(cls, branch: str) -> str:
+ return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}"
+ def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]:
+ scopes = ("user", "system") if scope is None else (scope,)
+ installed = set()
+ for item in scopes:
result = self._run_flatpak_command(
- ["list", "--user", "--runtime", "--columns=application,arch,branch"],
+ ["list", f"--{item}", "--runtime", "--columns=application,arch,branch"],
capture_output=True,
text=True,
check=True,
)
- installed = {
- tuple(line.split("\t")[:3])
- for line in result.stdout.splitlines()
- if line.strip()
- }
+ for line in result.stdout.splitlines():
+ fields = line.split("\t") if "\t" in line else line.split()
+ if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64":
+ installed.add(fields[2])
+ return installed
+
+ def _user_extension_origin(self, branch: str) -> str:
+ result = self._run_flatpak_command(
+ ["info", "--user", "--show-origin", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ return result.stdout.strip() if result.returncode == 0 else ""
+
+ def _empty_state(self) -> Dict[str, object]:
+ return {
+ "version": self.OWNERSHIP_VERSION,
+ "plugin_owned_branches": [],
+ "prepared_apps": {},
+ }
+
+ def _read_state(self) -> Dict[str, object]:
+ if not self.ownership_path.exists():
+ return self._empty_state()
+ if self.ownership_path.is_symlink() or not self.ownership_path.is_file():
+ raise RuntimeError("Flatpak ownership metadata is not a regular file")
+ try:
+ data = json.loads(self.ownership_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise RuntimeError(f"Could not read Flatpak ownership metadata: {error}") from error
+ if data.get("version") != self.OWNERSHIP_VERSION:
+ raise RuntimeError("Unsupported Flatpak ownership metadata version")
+ branches = data.get("plugin_owned_branches")
+ apps = data.get("prepared_apps")
+ if not isinstance(branches, list) or not isinstance(apps, dict):
+ raise RuntimeError("Invalid Flatpak ownership metadata")
+ for branch in branches:
+ self._validate_runtime(branch)
+ for app_id, entry in apps.items():
+ self._validate_app_id(app_id)
+ if not isinstance(entry, dict):
+ raise RuntimeError("Invalid Flatpak app ownership metadata")
+ if type(entry.get("override_existed")) is not bool:
+ raise RuntimeError("Invalid Flatpak app ownership metadata")
+ if not isinstance(entry.get("managed_sha256"), str):
+ raise RuntimeError("Invalid Flatpak app ownership metadata")
+ return data
+
+ def _write_state(self, state: Dict[str, object]) -> None:
+ branches = state.get("plugin_owned_branches", [])
+ apps = state.get("prepared_apps", {})
+ if not branches and not apps:
+ self.ownership_path.unlink(missing_ok=True)
+ if self.backup_dir.exists() and not any(self.backup_dir.iterdir()):
+ self.backup_dir.rmdir()
+ return
+ self._write_file(
+ self.ownership_path,
+ json.dumps(state, indent=2, sort_keys=True) + "\n",
+ )
+
+ def _owned_branches(self, state: Optional[Dict[str, object]] = None) -> Set[str]:
+ current = state if state is not None else self._read_state()
+ return {self._validate_runtime(branch) for branch in current["plugin_owned_branches"]}
+
+ def _override_path(self, app_id: str) -> Path:
+ return self.user_home / ".local/share/flatpak/overrides" / self._validate_app_id(app_id)
+
+ def _backup_path(self, app_id: str) -> Path:
+ return self.backup_dir / f"{self._validate_app_id(app_id)}.ini"
+
+ @staticmethod
+ def _sha256(content: bytes) -> str:
+ return hashlib.sha256(content).hexdigest()
+
+ def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]:
+ path = self._override_path(app_id)
+ if path.is_symlink():
+ raise RuntimeError("Flatpak override path is a symlink")
+ if not path.exists():
+ return False, b""
+ if not path.is_file():
+ raise RuntimeError("Flatpak override path is not a regular file")
+ return True, path.read_bytes()
+
+ def _resolve_runtime(self, app_id: str) -> tuple[str, str]:
+ self._validate_app_id(app_id)
+ if not self.check_flatpak_available():
+ raise FileNotFoundError("Flatpak is not available on this system")
+ result = self._run_flatpak_command(
+ ["info", "--show-runtime", app_id],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or f"Could not inspect Flatpak app {app_id}")
+ runtime = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
+ parts = runtime.split("/")
+ if len(parts) != 3:
+ raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}")
+ if parts[0] == "org.freedesktop.Platform":
+ return runtime, self._validate_runtime(parts[2])
+ if parts[0] not in self.DERIVED_RUNTIME_IDS:
+ raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}")
+ metadata_result = self._run_flatpak_command(
+ ["info", "--show-metadata", runtime],
+ capture_output=True,
+ text=True,
+ )
+ if metadata_result.returncode != 0:
+ raise OSError(metadata_result.stderr.strip() or f"Could not inspect Flatpak runtime {runtime}")
+ return runtime, self.runtime_branch_from_metadata(metadata_result.stdout)
+
+ def _dll_directory(self) -> Path:
+ if self.config_file_path.exists():
+ try:
+ content = self.config_file_path.read_text(encoding="utf-8")
+ match = re.search(
+ r'(?m)^[ \t]*dll[ \t]*=[ \t]*"((?:\\.|[^"\\])*)"',
+ content,
+ )
+ if match:
+ configured_dll = json.loads('"' + match.group(1) + '"')
+ if configured_dll:
+ return Path(configured_dll).parent
+ except Exception:
+ pass
+ return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling"
+
+ def _filesystem_present(self, entries: str, host_path: Path) -> bool:
+ accepted = {str(host_path)}
+ try:
+ accepted.add(f"~/{host_path.relative_to(self.user_home).as_posix()}")
+ except ValueError:
+ pass
+ enabled = False
+ for raw in entries.split(";"):
+ value = raw.strip()
+ if not value:
+ continue
+ denied = value.startswith("!")
+ path = value[1:] if denied else value
+ path = path.split(":", 1)[0]
+ if path in accepted:
+ if denied:
+ return False
+ enabled = True
+ return enabled
+
+ def _app_override_status(self, app_id: str) -> Dict[str, object]:
+ result = self._run_flatpak_command(
+ ["override", "--user", "--show", app_id],
+ capture_output=True,
+ text=True,
+ )
+ output = result.stdout if result.returncode == 0 else ""
+ section = None
+ filesystems = ""
+ unset_environment = set()
+ environment = {}
+ for raw_line in output.splitlines():
+ line = raw_line.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1]
+ continue
+ key, separator, value = line.partition("=")
+ if not separator:
+ continue
+ if section == "Context" and key == "filesystems":
+ filesystems = value
+ elif section == "Context" and key == "unset-environment":
+ unset_environment.update(item for item in value.split(";") if item)
+ elif section == "Environment":
+ environment[key] = value
+ config_ready = self._filesystem_present(filesystems, self.config_dir)
+ dll_ready = self._filesystem_present(filesystems, self._dll_directory())
+ env_ready = (
+ environment.get("LSFGVK_CONFIG") == str(self.config_file_path)
+ and environment.get("LSFGVK_FLATPAK") == "1"
+ and "DISABLE_LSFGVK" in unset_environment
+ and "DISABLE_LSFG" in unset_environment
+ )
+ return {
+ "filesystem_ready": config_ready and dll_ready,
+ "environment_ready": env_ready,
+ "prepared": config_ready and dll_ready and env_ready,
+ }
+
+ def get_extension_status(self):
+ try:
+ available = self.check_flatpak_available()
+ installed = self._installed_extension_branches() if available else set()
return self._success_response(
- BaseResponse,
- "Flatpak runtime status retrieved",
- installed_23_08=(self.EXTENSION_ID, "x86_64", "23.08") in installed,
- installed_24_08=(self.EXTENSION_ID, "x86_64", "24.08") in installed,
- installed_25_08=(self.EXTENSION_ID, "x86_64", "25.08") in installed,
+ dict,
+ "Flatpak runtime extension status retrieved" if available else "Flatpak is not available",
+ available=available,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=sorted(installed),
)
except Exception as error:
return self._error_response(
- BaseResponse,
+ dict,
str(error),
- installed_23_08=False,
- installed_24_08=False,
- installed_25_08=False,
+ available=False,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=[],
)
- def install_extension(self, version: str) -> Dict[str, Any]:
- try:
- self._validate_runtime(version)
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
- bundle_path = self._bundled_extension_path(version)
- if not bundle_path.is_file():
- raise FileNotFoundError(
- f"Bundled Flatpak extension not found at {bundle_path}; reinstall the plugin"
- )
- result = self._run_flatpak_command(
- [
- "install",
- "--user",
- "--noninteractive",
- "--or-update",
- str(bundle_path),
- ],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Flatpak installation failed")
- return self._success_response(
- BaseResponse,
- f"lsfg-vk {version} runtime extension installed from the bundled asset",
- )
- except Exception as error:
- return self._error_response(BaseResponse, str(error))
+ get_flatpak_support_status = get_extension_status
- def uninstall_extension(self, version: str) -> Dict[str, Any]:
+ def install_extension(self, branch: str):
try:
- self._validate_runtime(version)
+ branch = self._validate_runtime(branch)
if not self.check_flatpak_available():
raise FileNotFoundError("Flatpak is not available on this system")
- result = self._run_flatpak_command(
- ["uninstall", "--user", "--noninteractive", self._extension_ref(version)],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Flatpak uninstall failed")
- return self._success_response(
- BaseResponse,
- f"lsfg-vk {version} runtime extension uninstalled",
- )
+ with self._lock:
+ if branch in self._verified_branches:
+ return self._extension_result(branch, True, False, "is ready")
+ user_installed = self._installed_extension_branches("user")
+ system_installed = self._installed_extension_branches("system")
+ if branch in system_installed and branch not in user_installed:
+ return self._extension_result(branch, True, False, "is ready")
+ if branch in user_installed and self._user_extension_origin(branch) != self.FLATHUB_REMOTE:
+ result = self._run_flatpak_command(
+ ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Could not replace the existing Flatpak extension")
+ result = self._run_flatpak_command(
+ [
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ self.FLATHUB_REMOTE,
+ f"{self.EXTENSION_ID}//{branch}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Flatpak installation failed")
+ if branch not in self._installed_extension_branches("user"):
+ raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards")
+ state = self._read_state()
+ owned = self._owned_branches(state)
+ owned.add(branch)
+ state["plugin_owned_branches"] = sorted(owned)
+ self._write_state(state)
+ self._verified_branches.add(branch)
+ return self._extension_result(branch, True, False, "installed")
except Exception as error:
- return self._error_response(BaseResponse, str(error))
+ return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False)
- def _override_output(self, app_id: str) -> str:
+ def _remove_extension(self, branch: str) -> bool:
+ if branch not in self._installed_extension_branches("user"):
+ return False
result = self._run_flatpak_command(
- ["override", "--user", "--show", app_id],
+ ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)],
capture_output=True,
text=True,
)
- return result.stdout if result.returncode == 0 else ""
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Flatpak uninstall failed")
+ return True
- def _dll_directory(self) -> Path:
- if self.config_file_path.exists():
- try:
- profile_data = ConfigurationManager.parse_toml_content_multi_profile(
- self.config_file_path.read_text(encoding="utf-8")
- )
- dll_path = profile_data["global_config"].get("dll")
- if dll_path:
- return Path(dll_path).parent
- except Exception:
- pass
+ def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str):
+ return self._success_response(
+ dict,
+ f"lsfg-vk {branch} runtime extension {verb}",
+ runtime_branch=branch,
+ installed=installed,
+ enabled=installed,
+ removed=removed,
+ )
- return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling"
+ def uninstall_extension(self, branch: str):
+ try:
+ branch = self._validate_runtime(branch)
+ if not self.check_flatpak_available():
+ raise FileNotFoundError("Flatpak is not available on this system")
+ with self._lock:
+ state = self._read_state()
+ owned = self._owned_branches(state)
+ if branch not in owned:
+ installed = branch in self._installed_extension_branches()
+ return self._extension_result(branch, installed, False, "preserved (not plugin-owned)")
+ removed = self._remove_extension(branch)
+ owned.remove(branch)
+ state["plugin_owned_branches"] = sorted(owned)
+ self._write_state(state)
+ installed = branch in self._installed_extension_branches()
+ return self._extension_result(branch, installed, removed, "uninstalled")
+ except Exception as error:
+ return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False)
- def _override_paths(self) -> Dict[str, str]:
- return {
- "config_dir": str(self.config_dir),
- "config_file": str(self.config_file_path),
- "dll_dir": str(self._dll_directory()),
- "legacy_home": str(self.user_home),
- "legacy_dll": str(
- self.user_home
- / ".local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"
- ),
- "legacy_script": str(self.legacy_script_path),
- }
+ def ensure_extension(self, branch: str):
+ return self.install_extension(branch)
- def _check_app_override_status(self, app_id: str) -> Dict[str, bool]:
- output = self._override_output(app_id)
- paths = self._override_paths()
- return {
- "filesystem": (
- paths["config_dir"] in output
- and paths["dll_dir"] in output
- ),
- "env": f"LSFGVK_CONFIG={paths['config_file']}" in output,
- }
+ def set_extension_enabled(self, branch: str, enabled: bool):
+ if type(enabled) is not bool:
+ return self._error_response(dict, "enabled must be a boolean", runtime_branch=branch, installed=False, enabled=False)
+ return self.install_extension(branch) if enabled else self.uninstall_extension(branch)
- def get_flatpak_apps(self) -> Dict[str, Any]:
+ def get_flatpak_apps(self):
try:
if not self.check_flatpak_available():
raise FileNotFoundError("Flatpak is not available on this system")
+ installed_extensions = self._installed_extension_branches()
+ state = self._read_state()
+ owned_apps = state["prepared_apps"]
result = self._run_flatpak_command(
["list", "--app", "--columns=name,application"],
capture_output=True,
@@ -222,104 +444,189 @@ class FlatpakService(BaseService):
)
apps = []
for line in result.stdout.splitlines():
- parts = line.split("\t", 1)
- if len(parts) != 2:
+ fields = line.split("\t")
+ if len(fields) < 2:
+ continue
+ name, app_id = fields[0].strip(), fields[1].strip()
+ if not app_id:
continue
- status = self._check_app_override_status(parts[1])
- apps.append(
- {
- "app_id": parts[1],
- "app_name": parts[0],
- "has_filesystem_override": status["filesystem"],
- "has_env_override": status["env"],
+ item = {
+ "app_id": app_id,
+ "app_name": name or app_id,
+ "runtime": None,
+ "runtime_branch": None,
+ "runtime_ready": False,
+ "prepared": False,
+ "owned": app_id in owned_apps,
+ "error": None,
+ }
+ try:
+ runtime, branch = self._resolve_runtime(app_id)
+ status = self._app_override_status(app_id)
+ item.update({
+ "runtime": runtime,
+ "runtime_branch": branch,
+ "runtime_ready": branch in installed_extensions,
+ "prepared": status["prepared"],
+ })
+ except Exception as error:
+ item["error"] = str(error)
+ apps.append(item)
+ apps.sort(key=lambda item: str(item["app_name"]).lower())
+ return self._success_response(dict, f"Found {len(apps)} Flatpak applications", apps=apps)
+ except Exception as error:
+ return self._error_response(dict, str(error), apps=[])
+
+ def prepare_app(self, app_id: str):
+ try:
+ app_id = self._validate_app_id(app_id)
+ with self._lock:
+ runtime, branch = self._resolve_runtime(app_id)
+ extension = self.ensure_extension(branch)
+ if not extension.get("success") or not extension.get("installed"):
+ raise RuntimeError(extension.get("error") or f"Could not install Flatpak runtime {branch}")
+ state = self._read_state()
+ apps = state["prepared_apps"]
+ status = self._app_override_status(app_id)
+ if status["prepared"] and app_id not in apps:
+ return self._success_response(
+ dict,
+ "Flatpak application is already prepared outside this plugin",
+ app_id=app_id,
+ runtime=runtime,
+ runtime_branch=branch,
+ prepared=True,
+ owned=False,
+ )
+ if app_id not in apps:
+ existed, original = self._snapshot_override(app_id)
+ backup = self._backup_path(app_id)
+ if existed:
+ self._write_file(backup, original.decode("utf-8"))
+ else:
+ backup.unlink(missing_ok=True)
+ apps[app_id] = {
+ "override_existed": existed,
+ "managed_sha256": "",
}
+ result = self._run_flatpak_command(
+ [
+ "override",
+ "--user",
+ f"--filesystem={self.config_dir}:ro",
+ f"--filesystem={self._dll_directory()}:ro",
+ f"--env=LSFGVK_CONFIG={self.config_file_path}",
+ "--env=LSFGVK_FLATPAK=1",
+ "--unset-env=DISABLE_LSFGVK",
+ "--unset-env=DISABLE_LSFG",
+ app_id,
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or f"Could not prepare Flatpak app {app_id}")
+ status = self._app_override_status(app_id)
+ if not status["prepared"]:
+ raise RuntimeError(f"Flatpak preparation did not become visible for {app_id}")
+ existed, managed = self._snapshot_override(app_id)
+ if not existed:
+ raise RuntimeError(f"Flatpak override for {app_id} was not created")
+ apps[app_id]["managed_sha256"] = self._sha256(managed)
+ self._write_state(state)
+ return self._success_response(
+ dict,
+ "Flatpak application prepared for lsfg-vk",
+ app_id=app_id,
+ runtime=runtime,
+ runtime_branch=branch,
+ prepared=True,
+ owned=True,
)
- return self._success_response(
- BaseResponse,
- f"Found {len(apps)} Flatpak applications",
- apps=apps,
- total_apps=len(apps),
- )
except Exception as error:
- return self._error_response(
- BaseResponse,
- str(error),
- apps=[],
- total_apps=0,
- )
+ return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=False)
- def set_app_override(self, app_id: str) -> Dict[str, Any]:
+ def remove_app_override(self, app_id: str):
try:
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
- paths = self._override_paths()
- result = self._run_flatpak_command(
- [
- "override",
- "--user",
- f"--filesystem={paths['config_dir']}:rw",
- f"--filesystem={paths['dll_dir']}:ro",
- f"--env=LSFGVK_CONFIG={paths['config_file']}",
- # Remove permissions/env from the pre-v2 plugin when an
- # existing app is explicitly migrated or reconfigured.
- f"--nofilesystem={paths['legacy_home']}",
- f"--nofilesystem={paths['legacy_dll']}",
- f"--nofilesystem={paths['legacy_script']}",
- "--unset-env=LSFG_CONFIG",
- app_id,
- ],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Failed to set Flatpak overrides")
- return self._success_response(
- BaseResponse,
- f"lsfg-vk overrides set for {app_id}",
- app_id=app_id,
- operation="set",
- )
+ app_id = self._validate_app_id(app_id)
+ with self._lock:
+ state = self._read_state()
+ apps = state["prepared_apps"]
+ entry = apps.get(app_id)
+ if entry is None:
+ return self._success_response(
+ dict,
+ "Flatpak application is not plugin-owned; existing overrides were preserved",
+ app_id=app_id,
+ prepared=self._app_override_status(app_id)["prepared"],
+ owned=False,
+ )
+ existed, current = self._snapshot_override(app_id)
+ current_hash = self._sha256(current) if existed else self._sha256(b"")
+ if current_hash != entry["managed_sha256"]:
+ raise RuntimeError(
+ "Flatpak override changed after preparation; refusing to overwrite unrelated settings"
+ )
+ override_path = self._override_path(app_id)
+ backup_path = self._backup_path(app_id)
+ if entry["override_existed"]:
+ if not backup_path.is_file() or backup_path.is_symlink():
+ raise RuntimeError("Flatpak override backup is unavailable")
+ self._write_file(override_path, backup_path.read_text(encoding="utf-8"))
+ else:
+ override_path.unlink(missing_ok=True)
+ backup_path.unlink(missing_ok=True)
+ apps.pop(app_id, None)
+ self._write_state(state)
+ return self._success_response(
+ dict,
+ "Plugin-owned Flatpak preparation removed",
+ app_id=app_id,
+ prepared=False,
+ owned=False,
+ )
except Exception as error:
- return self._error_response(
- BaseResponse,
- str(error),
- app_id=app_id,
- operation="set",
- )
+ return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=True)
- def remove_app_override(self, app_id: str) -> Dict[str, Any]:
+ def remove_plugin_owned_environment(self):
try:
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
- paths = self._override_paths()
- result = self._run_flatpak_command(
- [
- "override",
- "--user",
- f"--nofilesystem={paths['config_dir']}",
- f"--nofilesystem={paths['dll_dir']}",
- f"--nofilesystem={paths['legacy_home']}",
- f"--nofilesystem={paths['legacy_dll']}",
- f"--nofilesystem={paths['legacy_script']}",
- "--unset-env=LSFGVK_CONFIG",
- "--unset-env=LSFG_CONFIG",
- app_id,
- ],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Failed to remove Flatpak overrides")
- return self._success_response(
- BaseResponse,
- f"lsfg-vk overrides removed for {app_id}",
- app_id=app_id,
- operation="remove",
- )
+ with self._lock:
+ state = self._read_state()
+ failures = []
+ removed_apps = []
+ for app_id in list(state["prepared_apps"]):
+ result = self.remove_app_override(app_id)
+ if result.get("success"):
+ removed_apps.append(app_id)
+ else:
+ failures.append(f"{app_id}: {result.get('error')}")
+ if failures:
+ return self._error_response(
+ dict,
+ "; ".join(failures),
+ removed_apps=removed_apps,
+ removed_branches=[],
+ )
+ state = self._read_state()
+ removed_branches = []
+ for branch in sorted(self._owned_branches(state)):
+ result = self.uninstall_extension(branch)
+ if result.get("success"):
+ removed_branches.append(branch)
+ else:
+ failures.append(f"{branch}: {result.get('error')}")
+ if failures:
+ return self._error_response(
+ dict,
+ "; ".join(failures),
+ removed_apps=removed_apps,
+ removed_branches=removed_branches,
+ )
+ return self._success_response(
+ dict,
+ "Plugin-owned Flatpak state removed",
+ removed_apps=removed_apps,
+ removed_branches=removed_branches,
+ )
except Exception as error:
- return self._error_response(
- BaseResponse,
- str(error),
- app_id=app_id,
- operation="remove",
- )
+ return self._error_response(dict, str(error), removed_apps=[], removed_branches=[])
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index b583cc7..a73706c 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -48,26 +48,20 @@ class InstallationService(BaseService):
def install(self) -> InstallationResponse:
try:
- plugin_dir = Path(__file__).parent.parent.parent
- archive_path = plugin_dir / BIN_DIR / ARCHIVE_FILENAME
+ archive_path = Path(__file__).parent.parent.parent / BIN_DIR / ARCHIVE_FILENAME
if not archive_path.exists():
raise FileNotFoundError(f"{ARCHIVE_FILENAME} not found at {archive_path}")
-
self._ensure_directories()
profile_data = self._prepare_config()
self._install_archive(archive_path)
- config_content = ConfigurationManager.generate_toml_content_multi_profile(profile_data)
- self.runtime_service.validate_config_content(config_content)
- self._write_file(
- self.config_file_path,
- config_content,
- 0o644,
- )
+ content = ConfigurationManager.generate_toml_content_multi_profile(profile_data)
+ self.runtime_service.validate_config_content(content)
+ self._write_file(self.config_file_path, content, 0o644)
self._remove_legacy_layer_files()
return self._success_response(InstallationResponse, "lsfg-vk 2.0.0 installed successfully")
except Exception as error:
self.log.error(f"Error installing lsfg-vk: {error}")
- return self._error_response(InstallationResponse, str(error), message="")
+ return self._error_response(InstallationResponse, str(error))
def _payload_destinations(self) -> Dict[str, tuple[Path, int]]:
return {
@@ -82,7 +76,7 @@ class InstallationService(BaseService):
0o644,
),
f"share/icons/hicolor/256x256/apps/{UI_ICON_FILENAME}": (
- self.user_home / LOCAL_SHARE / "icons" / "hicolor" / "256x256" / "apps" / UI_ICON_FILENAME,
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME,
0o644,
),
}
@@ -124,35 +118,33 @@ class InstallationService(BaseService):
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise
-
missing = sorted(set(destinations) - found)
if missing:
raise OSError("Archive is missing required files: " + ", ".join(missing))
+ def _default_config(self) -> ProfileData:
+ defaults = ConfigurationManager.get_defaults()
+ return ProfileData(
+ profiles={},
+ global_config={"dll": defaults["dll"], "no_fp16": defaults["no_fp16"]},
+ )
+
def _prepare_config(self) -> ProfileData:
- if self.config_file_path.exists():
- content = self.config_file_path.read_text(encoding="utf-8")
- legacy = ConfigurationManager.is_legacy_v1(content)
- profile_data = ConfigurationManager.parse_toml_content_multi_profile(content)
- if legacy:
- backup_path = self.config_file_path.with_name(f"{self.config_file_path.name}.v1.bak")
- if not backup_path.exists():
- self._write_file(backup_path, content, 0o644)
- else:
- default = dict(ConfigurationManager.get_defaults())
- profile_data = ProfileData(
- profiles={},
- global_config={
- "dll": default.get("dll", ""),
- "no_fp16": default.get("no_fp16", False),
- },
+ try:
+ profile_data = (
+ ConfigurationManager.parse_toml_content_multi_profile(
+ self.config_file_path.read_text(encoding="utf-8")
+ )
+ if self.config_file_path.exists()
+ else self._default_config()
)
-
+ except ValueError:
+ profile_data = self._default_config()
self._resolve_dll_path(profile_data)
- defaults = dict(ConfigurationManager.get_defaults())
- for profile_name, raw_profile in list(profile_data["profiles"].items()):
- profile_data["profiles"][profile_name] = ConfigurationManager.validate_config(
- {**defaults, **raw_profile, **profile_data["global_config"]}
+ defaults = ConfigurationManager.get_defaults()
+ for name, profile in profile_data["profiles"].items():
+ profile_data["profiles"][name] = ConfigurationManager.validate_config(
+ {**defaults, **profile, **profile_data["global_config"]}
)
return profile_data
@@ -160,7 +152,6 @@ class InstallationService(BaseService):
current_path = str(profile_data["global_config"].get("dll") or "")
if current_path and Path(current_path).is_file():
return False
-
dll_path = self.steam_service.find_lsfg_vk_dll()
if dll_path and current_path != dll_path:
profile_data["global_config"]["dll"] = dll_path
@@ -179,7 +170,6 @@ class InstallationService(BaseService):
except Exception as error:
installed = False
installation_error = str(error)
-
lossless_scaling = self.runtime_service.check_lossless_scaling()
return {
"installed": installed,
@@ -197,22 +187,22 @@ class InstallationService(BaseService):
def uninstall(self) -> UninstallationResponse:
try:
- removed = []
- for path in (
- self.lib_file,
- self.lib_x86_file,
- self.json_file,
- self.json_x86_file,
- self.cli_file,
- self.local_bin_dir / UI_FILENAME,
- self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME,
- 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))
+ removed = [
+ str(path)
+ for path in (
+ self.lib_file,
+ self.lib_x86_file,
+ self.json_file,
+ self.json_x86_file,
+ self.cli_file,
+ self.local_bin_dir / UI_FILENAME,
+ self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME,
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME,
+ self.legacy_lib_file,
+ self.legacy_json_file,
+ )
+ if self._remove_if_exists(path)
+ ]
if not removed:
return self._success_response(
UninstallationResponse,
@@ -228,7 +218,6 @@ class InstallationService(BaseService):
return self._error_response(
UninstallationResponse,
str(error),
- message="",
removed_files=None,
)
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index 0472e42..d20fabf 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -1,33 +1,19 @@
-"""
-Main plugin class for the lsfg-vk Decky Loader plugin.
-
-This plugin provides services for installing and managing the lsfg-vk
-Vulkan layer for frame generation on Steam Deck.
-"""
-
import os
-from typing import Dict, Any
+from typing import Any, Dict
import decky
-from .installation import InstallationService
from .configuration import ConfigurationService
+from .flatpak_profile_service import FlatpakProfileService
from .flatpak_service import FlatpakService
+from .installation import InstallationService
from .runtime_service import RuntimeService
from .steam_service import SteamService
+from .wrapper_service import WrapperService
class Plugin:
- """
- Main plugin class for lsfg-vk management.
-
- This class provides a unified interface for installation, configuration,
- and Flatpak management services. It implements the Decky Loader plugin lifecycle
- methods (_main, _unload, _uninstall, _migration).
- """
-
def __init__(self):
- """Initialize the plugin with all necessary services"""
self.runtime_service = RuntimeService()
self.steam_service = SteamService()
self.installation_service = InstallationService(
@@ -36,204 +22,180 @@ class Plugin:
)
self.configuration_service = ConfigurationService(runtime_service=self.runtime_service)
self.flatpak_service = FlatpakService()
+ self.flatpak_profile_service = FlatpakProfileService(
+ self.flatpak_service,
+ self.configuration_service,
+ )
+ self.wrapper_service = WrapperService()
- async def install_lsfg_vk(self) -> Dict[str, Any]:
- """Install the bundled lsfg-vk runtime to ~/.local
-
- Returns:
- InstallationResponse dict with success status and message/error
- """
+ async def install_lsfg_vk(self):
return self.installation_service.install()
- async def check_lsfg_vk_installed(self) -> Dict[str, Any]:
- """Check if lsfg-vk is already installed
-
- Returns:
- InstallationCheckResponse dict with installation status and paths
- """
+ async def check_lsfg_vk_installed(self):
return self.installation_service.check_installation()
- async def uninstall_lsfg_vk(self) -> Dict[str, Any]:
- """Uninstall lsfg-vk by removing the installed files
-
- Returns:
- UninstallationResponse dict with success status and removed files
- """
+ async def uninstall_lsfg_vk(self):
+ flatpak = self.flatpak_service.remove_plugin_owned_environment()
+ if not flatpak.get("success"):
+ return {
+ "success": False,
+ "message": "",
+ "error": flatpak.get("error") or "Could not clean up Flatpak support",
+ "removed_files": None,
+ }
+ self.configuration_service.reset_all_flatpak_configs()
return self.installation_service.uninstall()
- async def get_game_configs(self) -> Dict[str, Any]:
+ async def get_game_configs(self):
return self.configuration_service.get_game_configs()
- async def get_installed_games(self) -> Dict[str, Any]:
+ async def get_installed_games(self):
return self.steam_service.get_installed_games()
- async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]):
return self.configuration_service.update_game_config(appid, game_name, config)
- async def reset_game_config(self, appid: str) -> Dict[str, Any]:
+ async def reset_game_config(self, appid: str):
return self.configuration_service.reset_game_config(appid)
- async def reset_all_game_configs(self) -> Dict[str, Any]:
+ async def reset_all_game_configs(self):
return self.configuration_service.reset_all_game_configs()
- async def get_config_file_content(self) -> Dict[str, Any]:
- """Get the current config file content
-
- Returns:
- Dict containing the config file content or error message
- """
+ async def get_workaround_state(self, appid: str):
+ return self.wrapper_service.get(appid)
+
+ async def set_workaround_state(
+ self,
+ appid: str,
+ state: Dict[str, Any],
+ command_token_added: bool = False,
+ ):
+ return self.wrapper_service.set(appid, state, command_token_added)
+
+ async def remove_workaround_state(self, appid: str):
+ return self.wrapper_service.remove(appid)
+
+ async def get_config_file_content(self):
+ path = self.configuration_service.config_file_path
try:
- config_path = self.configuration_service.config_file_path
- if not config_path.exists():
+ if not path.exists():
return {
"success": False,
"content": None,
- "path": str(config_path),
- "error": "Config file does not exist"
+ "path": str(path),
+ "error": "Config file does not exist",
}
-
- content = config_path.read_text(encoding='utf-8')
return {
"success": True,
- "content": content,
- "path": str(config_path),
- "error": None
+ "content": path.read_text(encoding="utf-8"),
+ "path": str(path),
+ "error": None,
}
- except Exception as e:
+ except Exception as error:
return {
"success": False,
"content": None,
- "path": str(config_path) if 'config_path' in locals() else "unknown",
- "error": f"Error reading config file: {str(e)}"
+ "path": str(path),
+ "error": f"Error reading config file: {error}",
}
- async def check_flatpak_extension_status(self) -> Dict[str, Any]:
- """Check status of lsfg-vk Flatpak runtime extensions
-
- Returns:
- FlatpakExtensionStatus dict with installation status for all supported runtime versions
- """
- return self.flatpak_service.get_extension_status()
-
- async def install_flatpak_extension(self, version: str) -> Dict[str, Any]:
- """Install lsfg-vk Flatpak runtime extension
-
- Args:
- version: Runtime version to install ("23.08", "24.08", or "25.08")
-
- Returns:
- BaseResponse dict with success status and message/error
- """
- return self.flatpak_service.install_extension(version)
-
- async def uninstall_flatpak_extension(self, version: str) -> Dict[str, Any]:
- """Uninstall lsfg-vk Flatpak runtime extension
-
- Args:
- version: Runtime version to uninstall ("23.08", "24.08", or "25.08")
-
- Returns:
- BaseResponse dict with success status and message/error
- """
- return self.flatpak_service.uninstall_extension(version)
-
- async def get_flatpak_apps(self) -> Dict[str, Any]:
- """Get list of installed Flatpak apps and their lsfg-vk override status
-
- Returns:
- FlatpakAppInfo dict with apps list and override status
- """
- return self.flatpak_service.get_flatpak_apps()
-
- async def get_lossless_scaling_branch_status(self) -> Dict[str, Any]:
+ async def get_debug_file_contents(self):
+ files = (
+ ("config", "LSFG-VK configuration", self.configuration_service.config_file_path),
+ ("workarounds", "Per-app workarounds", self.wrapper_service.sidecar_path),
+ ("wrapper", "Generated launch wrapper", self.wrapper_service.wrapper_path),
+ ("flatpak", "Flatpak ownership state", self.flatpak_service.ownership_path),
+ )
+ contents = []
+ for file_id, label, path in files:
+ item = {
+ "id": file_id,
+ "label": label,
+ "path": str(path),
+ "exists": False,
+ "content": None,
+ "error": None,
+ }
+ try:
+ if path.is_symlink():
+ item["error"] = "Path is a symlink; refusing to read it"
+ elif not path.exists():
+ item["error"] = "File does not exist"
+ elif not path.is_file():
+ item["error"] = "Path is not a regular file"
+ else:
+ item["exists"] = True
+ item["content"] = path.read_text(encoding="utf-8")
+ except Exception as error:
+ item["error"] = f"Error reading file: {error}"
+ contents.append(item)
+ return {
+ "success": True,
+ "message": "Debug file contents retrieved",
+ "error": None,
+ "files": contents,
+ }
+
+ async def get_lossless_scaling_branch_status(self):
return self.steam_service.get_branch_status()
- async def set_flatpak_app_override(self, app_id: str) -> Dict[str, Any]:
- """Set lsfg-vk overrides for a Flatpak app
-
- Args:
- app_id: Flatpak application ID
-
- Returns:
- FlatpakOverrideResponse dict with operation result
- """
- return self.flatpak_service.set_app_override(app_id)
-
- async def remove_flatpak_app_override(self, app_id: str) -> Dict[str, Any]:
- """Remove lsfg-vk overrides for a Flatpak app
-
- Args:
- app_id: Flatpak application ID
-
- Returns:
- FlatpakOverrideResponse dict with operation result
- """
- return self.flatpak_service.remove_app_override(app_id)
-
+ async def get_flatpak_apps(self):
+ return self.flatpak_profile_service.get_apps()
+
+ async def enable_flatpak_app(self, flatpak_app_id: str):
+ return self.flatpak_profile_service.enable_app(flatpak_app_id)
+
+ async def update_flatpak_config(self, flatpak_app_id: str, config: Dict[str, Any]):
+ return self.flatpak_profile_service.update_config(flatpak_app_id, config)
+
+ async def get_flatpak_workaround_state(self, flatpak_app_id: str):
+ return self.flatpak_profile_service.get_workaround_state(flatpak_app_id)
+
+ async def set_flatpak_workaround_state(self, flatpak_app_id: str, state: Dict[str, Any]):
+ return self.flatpak_profile_service.set_workaround_state(flatpak_app_id, state)
+
+ async def remove_flatpak_app(self, flatpak_app_id: str):
+ return self.flatpak_profile_service.remove_app(flatpak_app_id)
+
+ async def get_running_flatpak_apps(self):
+ return self.flatpak_profile_service.get_running_apps()
+
async def _main(self):
- """
- Main entry point for the 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):
- """
- Cleanup tasks when the plugin is unloaded.
-
- This method is called by Decky Loader when the plugin is being unloaded.
- Any cleanup code should go here.
- """
decky.logger.info("decky-lsfg-vk plugin unloaded")
async def _uninstall(self):
- """
- Called when the plugin is uninstalled.
-
- This method is called by Decky Loader when the plugin is being uninstalled.
- Performs cleanup of plugin files and flatpak extensions.
- """
decky.logger.info("decky-lsfg-vk plugin being uninstalled")
-
- # Clean up lsfg-vk files when the plugin is uninstalled
- self.installation_service.cleanup_on_uninstall()
-
try:
- extension_status = self.flatpak_service.get_extension_status()
- for version, key in (
- ("23.08", "installed_23_08"),
- ("24.08", "installed_24_08"),
- ("25.08", "installed_25_08"),
- ):
- if extension_status.get(key):
- result = self.flatpak_service.uninstall_extension(version)
- if not result.get("success"):
- decky.logger.warning(result.get("error"))
+ result = self.flatpak_service.remove_plugin_owned_environment()
+ if result.get("success"):
+ self.configuration_service.reset_all_flatpak_configs()
+ else:
+ decky.logger.warning(result.get("error"))
except Exception as error:
decky.logger.error(f"Error during Flatpak cleanup: {error}")
-
+ self.installation_service.cleanup_on_uninstall()
decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed")
async def _migration(self):
- """
- Migrations that should be performed before entering `_main()`.
-
- This method is called by Decky Loader for plugin migrations.
- Currently migrates logs, settings, and runtime data from old locations.
- """
decky.logger.info("Running decky-lsfg-vk plugin migrations")
-
- decky.migrate_logs(os.path.join(decky.DECKY_USER_HOME,
- ".config", "decky-lossless-scaling-vk", "lossless-scaling-vk.log"))
-
+ decky.migrate_logs(os.path.join(
+ decky.DECKY_USER_HOME,
+ ".config",
+ "decky-lossless-scaling-vk",
+ "lossless-scaling-vk.log",
+ ))
decky.migrate_settings(
os.path.join(decky.DECKY_HOME, "settings", "lossless-scaling-vk.json"),
- os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk"))
-
+ os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk"),
+ )
decky.migrate_runtime(
os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"),
- os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk"))
-
+ os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk"),
+ )
decky.logger.info("decky-lsfg-vk plugin migrations completed")
diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py
index 9a6570f..2108071 100644
--- a/py_modules/lsfg_vk/steam_service.py
+++ b/py_modules/lsfg_vk/steam_service.py
@@ -3,73 +3,62 @@ from pathlib import Path
from typing import Dict, Optional, Tuple
from .base_service import BaseService
-from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH
+from .constants import (
+ STEAM_LOSSLESS_SCALING_APP_ID,
+ STEAM_LOSSLESS_SCALING_BRANCH,
+)
class SteamService(BaseService):
DEFAULT_BRANCH = "public"
MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf"
- # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG.
GAME_SELECTOR_EXCLUDED_APPIDS = {
- "858280", # Proton 3.7
- "961940", # Proton 3.16
- "1054830", # Proton 4.2
- "1113280", # Proton 4.11
- "1245040", # Proton 5.0
- "1420170", # Proton 5.13
- "1493710", # Proton Experimental
- "1580130", # Proton 6.3
- "1887720", # Proton 7
- "2180100", # Proton Hotfix
- "228980", # Steamworks Common Redistributables
- "2348590", # Proton 8
- "2805730", # Proton 9
- "3029110", # Lepton
- "3127680", # fex
- "3658110", # Proton 10
- "4183110", # Steam Linux Runtime 4.0
- "4185400", # Steam Linux Runtime 4.0 for arm64
- "4427310", # Proton Experimental (ARM64)
- "4628710", # Proton 11 / Proton Next
- "4628740", # Proton 11 (ARM64)
- "4690330", # Legacy Steam Runtime
- "993090", # Lossless Scaling
- "1070560", # Steam Linux Runtime 1.0
- "1391110", # Steam Linux Runtime 2.0
- "1628350", # Steam Linux Runtime 3.0
+ "858280", "961940", "1054830", "1113280", "1245040", "1420170",
+ "1493710", "1580130", "1887720", "2180100", "228980", "2348590",
+ "2805730", "3029110", "3127680", "3658110", "4183110", "4185400",
+ "4427310", "4628710", "4628740", "4690330", "993090", "1070560",
+ "1391110", "1628350",
}
def _steam_roots(self):
- candidates = (
+ seen = set()
+ for candidate in (
self.user_home / ".local/share/Steam",
self.user_home / ".steam/steam",
self.user_home / ".steam/root",
self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
- )
- seen = set()
-
- for candidate in candidates:
+ ):
yield from self._unique_existing_root(candidate, seen)
def _steam_library_roots(self):
seen = set()
- for candidate in self._steam_roots():
- yield from self._unique_existing_root(candidate, seen)
-
+ for root in self._steam_roots():
+ yield from self._unique_existing_root(root, seen)
for library_file in (
- candidate / "steamapps/libraryfolders.vdf",
- candidate / "config/libraryfolders.vdf",
+ root / "steamapps/libraryfolders.vdf",
+ root / "config/libraryfolders.vdf",
):
try:
content = library_file.read_text(encoding="utf-8")
except OSError:
continue
-
for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content):
path = raw_path.replace(r'\"', '"').replace(r'\\', '\\')
yield from self._unique_existing_root(Path(path), seen)
@staticmethod
+ def _unique_existing_root(path: Path, seen: set[str]):
+ if not path.exists():
+ return
+ try:
+ resolved = str(path.resolve())
+ except OSError:
+ resolved = str(path)
+ if resolved not in seen:
+ seen.add(resolved)
+ yield path
+
+ @staticmethod
def _read_shortcuts(data: bytes) -> Dict[str, object]:
def read_string(offset: int) -> Tuple[str, int]:
end = data.index(b"\0", offset)
@@ -86,16 +75,12 @@ class SteamService(BaseService):
value, offset = read_object(offset)
elif value_type == 1:
value, offset = read_string(offset)
- elif value_type == 2:
- if offset + 4 > len(data):
+ elif value_type in (2, 7):
+ width = 4 if value_type == 2 else 8
+ if offset + width > len(data):
raise ValueError("truncated binary VDF integer")
- value = int.from_bytes(data[offset:offset + 4], "little", signed=True)
- offset += 4
- elif value_type == 7:
- if offset + 8 > len(data):
- raise ValueError("truncated binary VDF 64-bit integer")
- value = int.from_bytes(data[offset:offset + 8], "little", signed=True)
- offset += 8
+ value = int.from_bytes(data[offset:offset + width], "little", signed=True)
+ offset += width
else:
raise ValueError(f"unsupported binary VDF type {value_type}")
values[key] = value
@@ -114,17 +99,20 @@ class SteamService(BaseService):
name = shortcut.get("AppName") or shortcut.get("appname")
if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name:
return None
- return {"appid": str(appid & 0xffffffff), "name": name, "nonSteam": True}
+ return {
+ "appid": str(appid & 0xFFFFFFFF),
+ "name": name,
+ "nonSteam": True,
+ }
def _shortcut_games(self):
games = {}
- for steam_root in self._steam_roots():
- for shortcuts_file in sorted((steam_root / "userdata").glob("*/config/shortcuts.vdf")):
+ for root in self._steam_roots():
+ for path in sorted((root / "userdata").glob("*/config/shortcuts.vdf")):
try:
- root = self._read_shortcuts(shortcuts_file.read_bytes())
+ shortcuts = self._read_shortcuts(path.read_bytes()).get("shortcuts", {})
except (OSError, ValueError):
continue
- shortcuts = root.get("shortcuts", {})
if not isinstance(shortcuts, dict):
continue
for shortcut in shortcuts.values():
@@ -133,25 +121,12 @@ class SteamService(BaseService):
games.setdefault(game["appid"], game)
return list(games.values())
- @staticmethod
- def _unique_existing_root(path: Path, seen: set[str]):
- if not path.exists():
- return
- try:
- resolved = str(path.resolve())
- except OSError:
- resolved = str(path)
- if resolved in seen:
- return
- seen.add(resolved)
- yield path
-
def _manifest_path(self) -> Optional[Path]:
- for library_root in self._steam_library_roots():
- manifest = library_root / "steamapps" / self.MANIFEST_FILENAME
- if manifest.is_file():
- return manifest
- return None
+ return next((
+ path
+ for root in self._steam_library_roots()
+ if (path := root / "steamapps" / self.MANIFEST_FILENAME).is_file()
+ ), None)
@staticmethod
def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]:
@@ -161,7 +136,6 @@ class SteamService(BaseService):
)
if section is None:
return None
-
depth = 1
in_string = False
escaped = False
@@ -174,9 +148,7 @@ class SteamService(BaseService):
escaped = True
elif character == '"':
in_string = False
- continue
-
- if character == '"':
+ elif character == '"':
in_string = True
elif character == "{":
depth += 1
@@ -191,98 +163,82 @@ class SteamService(BaseService):
bounds = cls._section_bounds(content, section_name)
if bounds is None:
return None
- body_start, body_end, _ = bounds
- pattern = re.compile(
- r'(?m)^[ \t]*"(?P<key>[^"]+)"[ \t]+"(?P<value>(?:\\.|[^"\\])*)"'
- )
- for match in pattern.finditer(content, body_start, body_end):
- if match.group("key") == key:
- return match.group("value")
- return None
+ start, end, _ = bounds
+ pattern = re.compile(r'(?m)^[ \t]*"(?P<key>[^"]+)"[ \t]+"(?P<value>(?:\\.|[^"\\])*)"')
+ return next((
+ match.group("value")
+ for match in pattern.finditer(content, start, end)
+ if match.group("key") == key
+ ), None)
@classmethod
def _branch_or_default(cls, branch: Optional[str]) -> str:
return branch or cls.DEFAULT_BRANCH
def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]:
- selected_branch = self._branch_or_default(
- self._section_value(content, "UserConfig", "BetaKey")
- )
- current_branch = self._branch_or_default(
+ selected = self._branch_or_default(self._section_value(content, "UserConfig", "BetaKey"))
+ current = self._branch_or_default(
self._section_value(content, "MountedConfig", "BetaKey")
or self._section_value(content, "UserConfig", "BetaKey")
)
- needs_switch = (
- selected_branch != STEAM_LOSSLESS_SCALING_BRANCH
- or current_branch != STEAM_LOSSLESS_SCALING_BRANCH
- )
+ needs_switch = selected != STEAM_LOSSLESS_SCALING_BRANCH or current != STEAM_LOSSLESS_SCALING_BRANCH
return {
"installed": True,
"manifest_path": str(manifest_path),
- "selected_branch": selected_branch,
- "current_branch": current_branch,
+ "selected_branch": selected,
+ "current_branch": current,
"target_branch": STEAM_LOSSLESS_SCALING_BRANCH,
"needs_switch": needs_switch,
- "restart_required": (
- selected_branch == STEAM_LOSSLESS_SCALING_BRANCH
- and current_branch != STEAM_LOSSLESS_SCALING_BRANCH
- ),
+ "restart_required": selected == STEAM_LOSSLESS_SCALING_BRANCH and current != STEAM_LOSSLESS_SCALING_BRANCH,
+ }
+
+ @staticmethod
+ def _missing_branch_fields() -> Dict[str, object]:
+ return {
+ "installed": False,
+ "manifest_path": None,
+ "selected_branch": None,
+ "current_branch": None,
+ "target_branch": STEAM_LOSSLESS_SCALING_BRANCH,
+ "needs_switch": False,
+ "restart_required": False,
}
def find_lsfg_vk_dll(self) -> Optional[str]:
- """Find the branch-specific upstream DLL in any Steam library."""
if self.get_branch_status().get("needs_switch"):
return None
- for library_root in self._steam_library_roots():
- dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll"
- if dll_path.is_file():
- return str(dll_path)
- return None
+ return next((
+ str(path)
+ for root in self._steam_library_roots()
+ if (path := root / "steamapps/common/Lossless Scaling/lsfg-vk.dll").is_file()
+ ), None)
def get_branch_status(self) -> Dict[str, object]:
try:
- manifest_path = self._manifest_path()
- if manifest_path is None:
+ manifest = self._manifest_path()
+ if manifest is None:
return self._success_response(
dict,
"Lossless Scaling is not installed through Steam",
- installed=False,
- manifest_path=None,
- selected_branch=None,
- current_branch=None,
- target_branch=STEAM_LOSSLESS_SCALING_BRANCH,
- needs_switch=False,
- restart_required=False,
+ **self._missing_branch_fields(),
)
-
- content = manifest_path.read_text(encoding="utf-8")
- fields = self._status_fields(manifest_path, content)
- if not fields["needs_switch"]:
- message = "Lossless Scaling is using the lsfg-vk Steam branch"
- elif fields["restart_required"]:
- message = "lsfg-vk is selected; restart Steam to finish the branch switch"
- else:
- message = "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas"
+ fields = self._status_fields(manifest, manifest.read_text(encoding="utf-8"))
+ message = (
+ "Lossless Scaling is using the lsfg-vk Steam branch"
+ if not fields["needs_switch"]
+ else "lsfg-vk is selected; restart Steam to finish the branch switch"
+ if fields["restart_required"]
+ else "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas"
+ )
return self._success_response(dict, message, **fields)
except Exception as error:
- return self._error_response(
- dict,
- str(error),
- installed=False,
- manifest_path=None,
- selected_branch=None,
- current_branch=None,
- target_branch=STEAM_LOSSLESS_SCALING_BRANCH,
- needs_switch=False,
- restart_required=False,
- )
+ return self._error_response(dict, str(error), **self._missing_branch_fields())
def get_installed_games(self) -> Dict[str, object]:
- """Return installed Steam app IDs and names for the Game Mode selector."""
try:
games: Dict[str, Dict[str, object]] = {}
- for library_root in self._steam_library_roots():
- for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"):
+ for root in self._steam_library_roots():
+ for manifest in (root / "steamapps").glob("appmanifest_*.acf"):
match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name)
if not match:
continue
@@ -293,8 +249,11 @@ class SteamService(BaseService):
appid = match.group(1)
if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS:
continue
- name = self._section_value(content, "AppState", "name") or f"App {appid}"
- games[appid] = {"appid": appid, "name": name, "nonSteam": False}
+ games[appid] = {
+ "appid": appid,
+ "name": self._section_value(content, "AppState", "name") or f"App {appid}",
+ "nonSteam": False,
+ }
for game in self._shortcut_games():
games.setdefault(str(game["appid"]), game)
return self._success_response(
diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py
index 7b85708..ce541ec 100644
--- a/py_modules/lsfg_vk/types.py
+++ b/py_modules/lsfg_vk/types.py
@@ -1,40 +1,17 @@
-"""
-Type definitions for the lsfg-vk plugin responses.
-"""
+from typing import List, Optional, TypedDict
-from typing import TypedDict, Optional, List
-
-class BaseResponse(TypedDict):
- """Base response structure"""
+class InstallationResponse(TypedDict):
success: bool
-
-
-class ErrorResponse(BaseResponse):
- """Response structure for errors"""
- error: str
-
-
-class MessageResponse(BaseResponse):
- """Response structure with message"""
- message: str
-
-
-class InstallationResponse(BaseResponse):
- """Response for installation operations"""
message: str
error: Optional[str]
-class UninstallationResponse(BaseResponse):
- """Response for uninstallation operations"""
- message: str
+class UninstallationResponse(InstallationResponse):
removed_files: Optional[List[str]]
- error: Optional[str]
class InstallationCheckResponse(TypedDict):
- """Response for installation check"""
installed: bool
lossless_scaling_installed: bool
lossless_scaling_status: str
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
new file mode 100644
index 0000000..ebe9526
--- /dev/null
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -0,0 +1,355 @@
+from __future__ import annotations
+
+import json
+import re
+import shlex
+import threading
+from typing import Any, Dict, Optional, Tuple
+
+from .base_service import BaseService
+from .constants import WRAPPER_FILENAME
+
+
+class WrapperService(BaseService):
+ LEGACY_FORMAT_VERSION = 1
+ FORMAT_VERSION = 2
+ LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1"
+ MARKER = "# lsfg-vk-wrapper-format: 2"
+ 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_LSFGVK",
+ "DISABLE_LSFG",
+ "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")
+ return entry
+
+ @classmethod
+ def _validate_document(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict) or raw.get("version") not in (
+ cls.LEGACY_FORMAT_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:
+ content = self.sidecar_path.read_text(encoding="utf-8")
+ raw = json.loads(content)
+ except (OSError, json.JSONDecodeError) as error:
+ raise RuntimeError(f"Could not read workaround state: {error}") from error
+ return self._validate_document(raw), True, content
+
+ 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 or self.LEGACY_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)
+
+ def _state_lines(self, state: Dict[str, Any]) -> list[str]:
+ lines = [" unset " + " ".join(self.MANAGED_ENV_KEYS)]
+ lines.extend([
+ ' SteamAppId="$appid"',
+ " export SteamAppId",
+ f" LSFGVK_CONFIG={self._shell(str(self.config_file_path))}",
+ " export LSFGVK_CONFIG",
+ ])
+ 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",
+ ])
+ return lines
+
+ def _render_wrapper(self, document: Dict[str, Any]) -> str:
+ lines = [
+ "#!/bin/sh",
+ self.MARKER,
+ "",
+ "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",
+ 'case "$appid" in',
+ ]
+ for appid in sorted(document["apps"], key=lambda value: int(value)):
+ lines.append(f" {appid})")
+ lines.extend(self._state_lines(document["apps"][appid]["state"]))
+ lines.append(" ;;")
+ lines.extend([
+ "esac",
+ 'exec "$@"',
+ "",
+ ])
+ return "\n".join(lines)
+
+ def _write_document(self, document: Dict[str, Any]) -> None:
+ self._write_file(
+ self.sidecar_path,
+ json.dumps(document, indent=2, sort_keys=True) + "\n",
+ 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,
+ "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],
+ 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()
+ document["apps"][normalized] = {
+ "state": validated_state,
+ "command_token_added": command_token_added,
+ }
+ 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]:
+ 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/scripts/deploy-to-deck.sh b/scripts/deploy-to-deck.sh
new file mode 100755
index 0000000..b5cb4f7
--- /dev/null
+++ b/scripts/deploy-to-deck.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+deck_host="deck@192.168.0.241"
+plugin_root="Decky LSFG-VK"
+install_path="/home/deck/homebrew/plugins/Decky LSFG-VK"
+
+package_dir="$(mktemp -d "${TMPDIR:-/tmp}/decky-plugin-package.XXXXXX")"
+run_id="$(basename "$package_dir")"
+remote_archive="/tmp/decky-lsfg-vk-${run_id}.zip"
+remote_stage="/tmp/decky-lsfg-vk-stage-${run_id}"
+trap 'rm -rf -- "$package_dir"' EXIT INT TERM
+
+pnpm build
+./cli/decky plugin build . \
+ --output-path "$package_dir" \
+ --tmp-output-path "$package_dir/tmp"
+
+package_zip="$(find "$package_dir" -maxdepth 1 -type f -name '*.zip' -print -quit)"
+test -n "$package_zip"
+
+scp "$package_zip" "$deck_host:$remote_archive"
+
+remote_script="$(cat <<'REMOTE'
+set -euo pipefail
+
+archive="$1"
+stage="$2"
+install="$3"
+plugin_root="$4"
+
+cleanup() {
+ rm -rf -- "$archive" "$stage"
+}
+trap cleanup EXIT INT TERM
+
+mkdir -p -- "$stage"
+unzip -q "$archive" -d "$stage"
+test -f "$stage/$plugin_root/plugin.json"
+test -f "$stage/$plugin_root/dist/index.js"
+
+sudo -v
+sudo rm -rf -- "$install"
+sudo mv -- "$stage/$plugin_root" "$install"
+sudo chown -R deck:deck -- "$install"
+sudo systemctl restart plugin_loader.service
+sleep 2
+sudo chown -R deck:deck -- "$install"
+
+test "$(systemctl is-active plugin_loader.service)" = active
+echo "Deck is ready to test"
+REMOTE
+)"
+
+printf -v remote_command 'bash -c %q -- %q %q %q %q' \
+ "$remote_script" "$remote_archive" "$remote_stage" "$install_path" "$plugin_root"
+
+ssh -tt "$deck_host" "$remote_command"
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 8eaad98..3d4890e 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -1,11 +1,13 @@
import { callable } from "@decky/api";
import { ConfigurationData } from "../config/configSchema";
-// Type definitions for API responses
-export interface InstallationResult {
+interface ApiResult {
success: boolean;
- error?: string;
message?: string;
+ error?: string | null;
+}
+
+export interface InstallationResult extends ApiResult {
removed_files?: string[];
}
@@ -16,10 +18,8 @@ export interface InstallationStatus {
error?: string;
}
-export interface SteamBranchStatus {
- success: boolean;
+export interface SteamBranchStatus extends ApiResult {
message: string;
- error?: string;
installed: boolean;
manifest_path?: string;
selected_branch?: string;
@@ -29,94 +29,130 @@ export interface SteamBranchStatus {
restart_required: boolean;
}
-// Use centralized configuration data type
export type LsfgConfig = ConfigurationData;
-export interface ConfigUpdateResult {
- success: boolean;
- message?: string;
- error?: string;
-}
-
export interface GameConfigEntry {
appid: string;
profile: string;
config: LsfgConfig;
}
-export interface InstalledGame { appid: string; name: string; nonSteam: boolean; }
-export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; }
-export interface GlobalConfig { dll: string; no_fp16: boolean; }
-export interface GameConfigsResult {
- success: boolean;
+export interface InstalledGame {
+ appid: string;
+ name: string;
+ nonSteam: boolean;
+}
+
+export interface GlobalConfig {
+ dll: string;
+ no_fp16: boolean;
+}
+
+export interface WorkaroundState {
+ dxvkFrameRate: number;
+ disableGamescopeWsi: boolean;
+ disableHdr: boolean;
+ disableSteamdeckMode: boolean;
+ disableVkbasalt: boolean;
+ enableZink: boolean;
+}
+
+export interface WorkaroundStateResult extends ApiResult {
+ appid?: string;
+ app_id?: string;
+ state?: WorkaroundState | null;
+ wrapper_path?: string;
+ wrapper_owned?: boolean;
+ command_token_added?: boolean;
+}
+
+export interface GameConfigsResult extends ApiResult {
global_config?: GlobalConfig;
games?: GameConfigEntry[];
- error?: string;
}
-export interface GameConfigResult extends ConfigUpdateResult {
+export interface GameConfigResult extends ApiResult {
appid?: string;
exists?: boolean;
config?: LsfgConfig;
}
-export interface FileContentResult {
- success: boolean;
+export interface InstalledGamesResult extends ApiResult {
+ games?: InstalledGame[];
+}
+
+export interface FileContentResult extends ApiResult {
content?: string;
path?: string;
- error?: string;
}
-// Flatpak management interfaces
-export interface FlatpakExtensionStatus {
- success: boolean;
- message: string;
- error?: string;
- installed_23_08: boolean;
- installed_24_08: boolean;
- installed_25_08: boolean;
+export interface DebugFileContent {
+ id: string;
+ label: string;
+ path: string;
+ exists: boolean;
+ content?: string | null;
+ error?: string | null;
+}
+
+export interface DebugFileContentsResult extends ApiResult {
+ files?: DebugFileContent[];
}
export interface FlatpakApp {
app_id: string;
app_name: string;
- has_filesystem_override: boolean;
- has_env_override: boolean;
+ runtime?: string | null;
+ runtime_branch?: string | null;
+ runtime_ready: boolean;
+ prepared: boolean;
+ owned: boolean;
+ enabled: boolean;
+ profile: string;
+ config?: LsfgConfig | null;
+ workarounds: WorkaroundState;
+ error?: string | null;
}
-export interface FlatpakAppInfo {
- success: boolean;
- message: string;
- error?: string;
- apps: FlatpakApp[];
- total_apps: number;
+export interface RunningFlatpakApp {
+ app_id: string;
+ active: boolean;
+ pid?: string;
}
-export interface FlatpakOperationResult {
- success: boolean;
- message: string;
- error?: string;
- app_id?: string;
- operation?: string;
+export interface FlatpakAppsResult extends ApiResult {
+ apps?: FlatpakApp[];
+}
+
+export interface RunningFlatpakAppsResult extends ApiResult {
+ apps?: RunningFlatpakApp[];
+}
+
+export interface FlatpakAppResult extends ApiResult, Partial<FlatpakApp> {
+ app_id: string;
}
-// API functions
export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk");
export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk");
export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed");
export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status");
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
-
-// Flatpak management API functions
-export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status");
-export const installFlatpakExtension = callable<[string], FlatpakOperationResult>("install_flatpak_extension");
-export const uninstallFlatpakExtension = callable<[string], FlatpakOperationResult>("uninstall_flatpak_extension");
-export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps");
-export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override");
-export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override");
-
+export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps");
+export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app");
+export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config");
+export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state");
+export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app");
+export const getRunningFlatpakApps = callable<[], RunningFlatpakAppsResult>("get_running_flatpak_apps");
export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs");
export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games");
export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config");
export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config");
export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs");
+export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state");
+export const setWorkaroundState = callable<[
+ string,
+ WorkaroundState,
+ boolean,
+], WorkaroundStateResult>("set_workaround_state");
+export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state");
+export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents");
diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx
index e3cc09d..07408d7 100644
--- a/src/components/ConfigFileTab.tsx
+++ b/src/components/ConfigFileTab.tsx
@@ -1,13 +1,79 @@
+import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui";
import { useEffect, useState } from "react";
-import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui";
-import { getConfigFileContent, FileContentResult } from "../api/lsfgApi";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi";
import t from "../i18n/i18n";
+function usePersistentCollapsed(key: string) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(key) !== "false";
+ } catch {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(collapsed));
+ } catch {
+ // Persisting the view preference is optional.
+ }
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
+function DebugFileSection({ file }: { file: DebugFileContent }) {
+ const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`);
+ const status = file.exists ? "Present" : "Not present";
+
+ return (
+ <>
+ <PanelSectionRow>
+ <Field
+ label={file.label}
+ description={`${file.path} · ${status}`}
+ bottomSeparator="none"
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div
+ className="LSFG_DebugFileCollapseButton_Container"
+ style={{ marginTop: "-2px", marginBottom: "4px" }}
+ >
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={toggleCollapsed}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+ {!collapsed && (
+ <PanelSectionRow>
+ {file.exists && file.content !== null && file.content !== undefined ? (
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
+ {file.content}
+ </pre>
+ ) : (
+ <Field
+ label="File unavailable"
+ description={file.error || "The file has not been created yet."}
+ />
+ )}
+ </PanelSectionRow>
+ )}
+ </>
+ );
+}
+
export function ConfigFileTab() {
- const [result, setResult] = useState<FileContentResult | null>(null);
+ const [result, setResult] = useState<DebugFileContentsResult | null>(null);
useEffect(() => {
- getConfigFileContent().then(setResult).catch((error) => {
+ getDebugFileContents().then(setResult).catch((error) => {
setResult({ success: false, error: String(error) });
});
}, []);
@@ -23,24 +89,35 @@ export function ConfigFileTab() {
}
return (
- <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
- {result.error && (
- <PanelSectionRow>
- <Field label="Error" description={result.error} />
- </PanelSectionRow>
- )}
- {result.success && result.content && (
- <>
- <PanelSectionRow>
- <Field label="Config file" description={result.path} />
- </PanelSectionRow>
+ <>
+ <style>
+ {`
+ .LSFG_DebugFileCollapseButton_Container > div > div > div > button,
+ .LSFG_DebugFileCollapseButton_Container > div > div > div > div > button {
+ height: 24px !important;
+ min-height: 24px !important;
+ padding: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+
+ .LSFG_DebugFileCollapseButton_Container svg {
+ display: block;
+ margin: 0;
+ }
+ `}
+ </style>
+ <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
+ {result.error && (
<PanelSectionRow>
- <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
- {result.content}
- </pre>
+ <Field label="Error" description={result.error} />
</PanelSectionRow>
- </>
- )}
- </PanelSection>
+ )}
+ {result.success && result.files?.map((file) => (
+ <DebugFileSection key={file.id} file={file} />
+ ))}
+ </PanelSection>
+ </>
);
}
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 2bb0f26..a6d4c2a 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,4 +1,4 @@
-import { ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui";
+import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, ToggleField, gamepadDialogClasses, showModal } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
@@ -11,10 +11,13 @@ interface ConfigurationTabProps {
config: ConfigurationData;
targets: GameTarget[];
runningGame: GameTarget | null;
+ showDebugTab: boolean;
+ onShowDebugTabChange: (value: boolean) => void;
onSelect: (appid: string) => void;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
onEnableAll: () => Promise<void>;
+ onRepair: (appid: string) => Promise<boolean>;
onReset: () => Promise<void>;
onResetAll: () => Promise<void>;
}
@@ -23,24 +26,28 @@ export function ConfigurationTab({
config,
targets,
runningGame,
+ showDebugTab,
+ onShowDebugTabChange,
onSelect,
onConfigChange,
onEnable,
onEnableAll,
+ onRepair,
onReset,
onResetAll,
}: ConfigurationTabProps) {
const [detailAppId, setDetailAppId] = useState<string | null>(null);
const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false);
const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null);
+ const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false);
const enableRef = useRef<HTMLDivElement>(null);
- const promptedRunningAppId = useRef<string | null>(null);
const closeDetails = useCallback(() => {
setFocusFpsMultiplier(false);
setFocusDetailAction(null);
setDetailAppId(null);
}, []);
const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []);
+ const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []);
useEffect(() => {
if (!focusDetailAction) return;
@@ -56,49 +63,75 @@ export function ConfigurationTab({
return () => cancelAnimationFrame(frame);
}, [focusDetailAction]);
- useEffect(() => {
- if (!runningGame || runningGame.configured) {
- promptedRunningAppId.current = null;
- return;
- }
- if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) {
- promptedRunningAppId.current = runningGame.appid;
- setFocusDetailAction("enable");
- setDetailAppId(runningGame.appid);
- }
- }, [detailAppId, runningGame?.appid, runningGame?.configured]);
-
const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null;
if (detailAppId === null) {
return (
- <PanelSection title="Games">
- <GameConfigurationSelector
- targets={targets}
- runningGame={runningGame}
- onSelect={(appid) => {
- setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
- onSelect(appid);
- setDetailAppId(appid);
- }}
- onEnableAll={onEnableAll}
- onResetAll={onResetAll}
- />
- </PanelSection>
+ <>
+ <PanelSection title="Games">
+ <GameConfigurationSelector
+ targets={targets}
+ runningGame={runningGame}
+ onSelect={(appid) => {
+ setFocusConfiguredToggle(false);
+ setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
+ onSelect(appid);
+ setDetailAppId(appid);
+ }}
+ onEnableAll={onEnableAll}
+ onResetAll={onResetAll}
+ focusConfiguredToggle={focusConfiguredToggle}
+ onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
+ />
+ </PanelSection>
+ <PanelSection title="Settings">
+ <PanelSectionRow>
+ <ToggleField
+ label="Show debug tab"
+ description="Show the raw configuration and generated files tab for troubleshooting."
+ checked={showDebugTab}
+ onChange={onShowDebugTabChange}
+ />
+ </PanelSectionRow>
+ </PanelSection>
+ </>
);
}
const profileLabel = selectedTarget?.name || "Game profile";
+ const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam";
const profileDescription = selectedTarget
- ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
+ ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
+ const enableProfile = async (appid: string, quitRunningGame = false) => {
+ if (!(await onEnable(appid))) return;
+ if (quitRunningGame) SteamClient.Apps.TerminateApp(appid, false);
+ setFocusFpsMultiplier(true);
+ };
const handleProfileAction = async () => {
if (selectedTarget?.configured) {
- promptedRunningAppId.current = detailAppId;
await onReset();
+ setFocusConfiguredToggle(true);
closeDetails();
- } else if (detailAppId && await onEnable(detailAppId)) {
- setFocusFpsMultiplier(true);
+ } else if (detailAppId) {
+ const isRunningUnconfigured = runningGame?.appid === detailAppId
+ && runningGame.nonSteam === false
+ && selectedTarget?.nonSteam === false
+ && !runningGame.configured;
+ if (isRunningUnconfigured) {
+ showModal(
+ <ConfirmModal
+ strTitle="Game is running"
+ strDescription="Quit the game now so LSFG-VK is used on its next launch?"
+ strOKButtonText="Quit and enable"
+ strCancelButtonText="Enable without quitting"
+ onOK={() => void enableProfile(detailAppId, true)}
+ onCancel={() => void enableProfile(detailAppId)}
+ />,
+ );
+ } else {
+ await enableProfile(detailAppId);
+ }
}
};
@@ -108,22 +141,22 @@ export function ConfigurationTab({
<PanelSectionRow>
<div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<Focusable noFocusRing style={{ flex: "none" }}>
- <DialogButton
- aria-label="Back to games"
- onClick={closeDetails}
- style={{
- width: "48px",
- minWidth: "48px",
- height: "24px",
- minHeight: "24px",
- padding: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <FaArrowLeft />
- </DialogButton>
+ <DialogButton
+ aria-label="Back to games"
+ onClick={closeDetails}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
</Focusable>
<div
className={gamepadDialogClasses.FieldLabel}
@@ -151,6 +184,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..ce3c018 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,81 +1,118 @@
import { Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
-import { FaFileAlt, FaGamepad, FaLayerGroup, FaList, FaTools } from "react-icons/fa";
+import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
-import { tabStyles } from "../styles";
+import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
-import { useInstallationActions } from "../hooks/useInstallationActions";
-import { useInstallationStatus } from "../hooks/useLsfgHooks";
+import { useInstallation } from "../hooks/useLsfgHooks";
+import { tabStyles } from "../styles";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
-import { FlatpaksTab } from "./FlatpaksTab";
+import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab";
+import { FlatpakTab } from "./FlatpakTab";
import { NowPlayingTab } from "./NowPlayingTab";
import { SetupTab } from "./SetupTab";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
- configuration: <FaList size={18} />,
- flatpak: <FaLayerGroup size={18} />,
+ games: <FaList size={18} />,
+ flatpak: <FaCube size={18} />,
configFile: <FaFileAlt size={18} />,
setup: <FaTools size={18} />,
};
+const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1";
+
+function usePersistentBoolean(key: string, defaultValue: boolean) {
+ const [value, setValue] = useState(() => {
+ try {
+ const stored = localStorage.getItem(key);
+ return stored === null ? defaultValue : stored === "true";
+ } catch {
+ return defaultValue;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(value));
+ } catch {}
+ }, [key, value]);
+
+ return [value, setValue] as const;
+}
+
export function Content() {
const {
- isInstalled,
- installationStatus,
- setIsInstalled,
- setInstallationStatus,
- losslessScalingInstalled,
- losslessScalingStatus,
- steamBranchStatus,
- checkInstallation,
- } = useInstallationStatus();
- const {
config,
+ runningConfig,
targets,
runningGame,
setSelectedAppId,
save,
+ saveFor,
enable,
enableAll,
+ repair,
resetSelected,
resetAll,
reload,
} = useGameConfiguration();
- const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions();
- const [tab, setTab] = useState("Setup");
+ const {
+ isInstalled,
+ installationStatus,
+ losslessScalingInstalled,
+ losslessScalingStatus,
+ steamBranchStatus,
+ isInstalling,
+ isUninstalling,
+ install,
+ uninstall,
+ } = useInstallation(reload);
const setupComplete =
isInstalled &&
losslessScalingInstalled &&
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
- const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null);
+ const flatpak = useFlatpakConfiguration(setupComplete);
+ const [tab, setTab] = useState("Setup");
+ const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true);
+ const previousRunningWorkload = useRef<string | null>(null);
+ const runningFlatpak = flatpak.runningApp;
+ const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak);
+ const runningWorkload = runningGame?.configured
+ ? `steam:${runningGame.appid}`
+ : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null;
useEffect(() => {
if (!setupComplete) {
setTab("Setup");
return;
}
- setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current);
- }, [runningGame?.configured, setupComplete]);
+ setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current);
+ }, [hasNowPlaying, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
- const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null;
- const previous = previousRunningState.current;
- previousRunningState.current = current;
- if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) {
- setTab(current.configured ? "NowPlaying" : "Configuration");
- } else if (!current && previous) {
- setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab);
+ const previous = previousRunningWorkload.current;
+ previousRunningWorkload.current = runningWorkload;
+ if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying");
+ else if (!runningWorkload && previous) {
+ setTab((current) => current === "NowPlaying" ? "Games" : current);
+ }
+ }, [runningWorkload, setupComplete]);
+
+ useEffect(() => {
+ if (isInstalled) {
+ void reload();
+ void flatpak.reload();
}
- }, [runningGame?.appid, runningGame?.configured, setupComplete]);
+ }, [isInstalled, reload, flatpak.reload]);
useEffect(() => {
- if (isInstalled) void reload();
- }, [isInstalled, reload]);
+ if (!showDebugTab && tab === "ConfigFile") setTab("Games");
+ }, [showDebugTab, tab]);
const handleConfigChange = async (
fieldName: keyof ConfigurationData,
@@ -85,15 +122,7 @@ export function Content() {
await save({ ...config, [fieldName]: value }, cleanupLaunchOptions);
};
- const onInstall = () => {
- void handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation);
- };
-
- const onUninstall = () => {
- void handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation);
- };
-
- const setupContent = (
+ const setup = (
<SetupTab
isInstalled={isInstalled}
installationStatus={installationStatus}
@@ -102,48 +131,72 @@ export function Content() {
steamBranchStatus={steamBranchStatus}
isInstalling={isInstalling}
isUninstalling={isUninstalling}
- onInstall={onInstall}
- onUninstall={onUninstall}
+ onInstall={() => void install()}
+ onUninstall={() => void uninstall()}
/>
);
+ const nowPlaying = runningGame?.configured ? (
+ <NowPlayingTab
+ game={runningGame}
+ config={runningConfig}
+ onConfigChange={async (field, value) => {
+ await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true);
+ }}
+ />
+ ) : runningFlatpak ? (
+ <FlatpakNowPlayingTab
+ app={runningFlatpak}
+ busy={flatpak.busyAppId === runningFlatpak.app_id}
+ onConfigChange={flatpak.updateConfig}
+ onWorkaroundChange={flatpak.updateWorkarounds}
+ />
+ ) : null;
+
const tabs = setupComplete
? [
- ...(runningGame?.configured ? [{
- id: "NowPlaying",
- title: tabIcons.nowPlaying,
- content: (
- <NowPlayingTab
- game={runningGame}
- config={config}
- onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)}
- />
- ),
- }] : []),
+ ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []),
{
- id: "Configuration",
- title: tabIcons.configuration,
+ id: "Games",
+ title: tabIcons.games,
content: (
<ConfigurationTab
config={config}
targets={targets}
runningGame={runningGame}
+ showDebugTab={showDebugTab}
+ onShowDebugTabChange={setShowDebugTab}
onSelect={setSelectedAppId}
- onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)}
+ onConfigChange={(field, value) => handleConfigChange(field, value, true)}
onEnable={enable}
onEnableAll={enableAll}
+ onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
/>
),
},
- { id: "Flatpak", title: tabIcons.flatpak, content: <FlatpaksTab /> },
- { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, // comment out for prod
- { id: "Setup", title: tabIcons.setup, content: setupContent },
+ {
+ id: "Flatpak",
+ title: tabIcons.flatpak,
+ content: (
+ <FlatpakTab
+ apps={flatpak.apps}
+ runningApp={runningFlatpak}
+ loading={flatpak.loading}
+ busyAppId={flatpak.busyAppId}
+ onRefresh={flatpak.reload}
+ onEnable={flatpak.enableApp}
+ onRemove={flatpak.removeApp}
+ onConfigChange={flatpak.updateConfig}
+ onWorkaroundChange={flatpak.updateWorkarounds}
+ />
+ ),
+ },
+ ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }] : []),
+ { id: "Setup", title: tabIcons.setup, content: setup },
]
- : [
- { id: "Setup", title: tabIcons.setup, content: setupContent },
- ];
+ : [{ id: "Setup", title: tabIcons.setup, content: setup }];
return (
<div
@@ -151,7 +204,7 @@ export function Content() {
style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }}
>
<style>{tabStyles}</style>
- <Tabs activeTab={tab} onShowTab={setTab} tabs={tabs} />
+ <Tabs activeTab={!showDebugTab && tab === "ConfigFile" ? "Games" : tab} onShowTab={setTab} tabs={tabs} />
</div>
);
}
diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx
new file mode 100644
index 0000000..9e5a0db
--- /dev/null
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -0,0 +1,39 @@
+import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+
+interface Props {
+ app: FlatpakApp;
+ busy: boolean;
+ onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
+ onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
+}
+
+export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) {
+ if (!app.config) return null;
+ const changeConfig = async (
+ field: keyof LsfgConfig,
+ value: boolean | number | string | string[],
+ ) => {
+ await onConfigChange(app.app_id, { ...app.config!, [field]: value });
+ };
+
+ return (
+ <Focusable>
+ <PanelSection title="Now Playing">
+ <PanelSectionRow>
+ <Field label={app.app_name} description={`Flatpak · ${app.app_id}`} />
+ </PanelSectionRow>
+ </PanelSection>
+ <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} />
+ <ConfigurationSection config={app.config} onConfigChange={changeConfig} />
+ <FlatpakWorkaroundsSection
+ state={app.workarounds}
+ disabled={busy}
+ onChange={(state) => onWorkaroundChange(app.app_id, state)}
+ />
+ </Focusable>
+ );
+}
diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx
new file mode 100644
index 0000000..c5e27f5
--- /dev/null
+++ b/src/components/FlatpakTab.tsx
@@ -0,0 +1,178 @@
+import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui";
+import { useCallback, useMemo, useState } from "react";
+import { FaArrowLeft } from "react-icons/fa";
+import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { ProfileDetails } from "./ProfileDetails";
+
+interface Props {
+ apps: FlatpakApp[];
+ runningApp: FlatpakApp | null;
+ loading: boolean;
+ busyAppId: string;
+ onRefresh: () => Promise<void>;
+ onEnable: (appId: string) => Promise<boolean>;
+ onRemove: (appId: string) => Promise<boolean>;
+ onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
+ onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
+}
+
+export function FlatpakTab({
+ apps,
+ runningApp,
+ loading,
+ busyAppId,
+ onRefresh,
+ onEnable,
+ onRemove,
+ onConfigChange,
+ onWorkaroundChange,
+}: Props) {
+ const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
+ const selected = useMemo(
+ () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null,
+ [apps, selectedAppId],
+ );
+ const close = useCallback(() => setSelectedAppId(null), []);
+
+ if (!selectedAppId) {
+ return (
+ <PanelSection title="Flatpak">
+ {/* <PanelSectionRow>
+ <Field
+ label="Flatpak applications"
+ description="Enable LSFG-VK directly for a Flatpak. Steam shortcuts and launcher scripts are not modified."
+ />
+ </PanelSectionRow> */}
+ {apps.map((app) => {
+ const status = app.enabled
+ ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled"
+ : app.prepared && !app.owned ? "Prepared externally" : "Available";
+ return (
+ <PanelSectionRow key={app.app_id}>
+ <Field
+ label={app.app_name}
+ description={`${app.app_id} · ${status}`}
+ onActivate={() => setSelectedAppId(app.app_id)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ );
+ })}
+ {apps.length === 0 && !loading && (
+ <PanelSectionRow>
+ <Field label="No Flatpak applications found" />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={loading || Boolean(busyAppId)} onClick={() => void onRefresh()}>
+ {loading ? "Refreshing..." : "Refresh Flatpaks"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ );
+ }
+
+ if (!selected) {
+ return (
+ <PanelSection title="Flatpak">
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={close}>Back</ButtonItem>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <Field label="Flatpak application is no longer installed" />
+ </PanelSectionRow>
+ </PanelSection>
+ );
+ }
+
+ const busy = busyAppId === selected.app_id;
+ const config = selected.config;
+ const external = selected.prepared && !selected.owned;
+ const profileDescription = [
+ selected.app_id,
+ selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null,
+ selected.enabled ? `profile ${selected.profile}` : null,
+ selected.app_id === runningApp?.app_id ? "Running" : null,
+ ].filter(Boolean).join(" · ");
+
+ const changeConfig = async (
+ field: keyof LsfgConfig,
+ value: boolean | number | string | string[],
+ ) => {
+ if (!config) return;
+ await onConfigChange(selected.app_id, { ...config, [field]: value });
+ };
+
+ return (
+ <Focusable onCancelButton={close}>
+ <PanelSection>
+ <PanelSectionRow>
+ <div style={{ display: "flex", alignItems: "center", width: "100%" }}>
+ <Focusable noFocusRing style={{ flex: "none" }}>
+ <DialogButton
+ aria-label="Back to Flatpaks"
+ onClick={close}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
+ </Focusable>
+ <div
+ className={gamepadDialogClasses.FieldLabel}
+ style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
+ >
+ {selected.app_name}
+ </div>
+ </div>
+ </PanelSectionRow>
+ </PanelSection>
+ {!selected.enabled && (
+ <PanelSection>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy || external || Boolean(selected.error)}
+ onClick={() => void onEnable(selected.app_id)}
+ >
+ {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"}
+ </ButtonItem>
+ </PanelSectionRow>
+ {selected.error && (
+ <PanelSectionRow>
+ <Field label="Unavailable" description={selected.error} />
+ </PanelSectionRow>
+ )}
+ </PanelSection>
+ )}
+ {selected.enabled && config && (
+ <>
+ <FpsMultiplierControl config={config} onConfigChange={changeConfig} />
+ <ConfigurationSection config={config} onConfigChange={changeConfig} />
+ <FlatpakWorkaroundsSection
+ state={selected.workarounds}
+ disabled={busy}
+ onChange={(state) => onWorkaroundChange(selected.app_id, state)}
+ />
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void onRemove(selected.app_id)}>
+ {busy ? "Removing..." : "Remove Flatpak profile"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </>
+ )}
+ <ProfileDetails description={profileDescription} />
+ </Focusable>
+ );
+}
diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx
new file mode 100644
index 0000000..7d9d880
--- /dev/null
+++ b/src/components/FlatpakWorkaroundsSection.tsx
@@ -0,0 +1,144 @@
+import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui";
+import { useEffect, useRef, useState } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import type { WorkaroundState } from "../api/lsfgApi";
+import t from "../i18n/i18n";
+
+interface Props {
+ state: WorkaroundState;
+ disabled?: boolean;
+ onChange: (state: WorkaroundState) => Promise<boolean>;
+}
+
+const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1";
+
+export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false";
+ } catch {
+ return true;
+ }
+ });
+ const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate);
+ const timer = useRef<number | null>(null);
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed));
+ } catch {}
+ }, [collapsed]);
+
+ useEffect(() => {
+ setFpsValue(state.dxvkFrameRate);
+ }, [state.dxvkFrameRate]);
+
+ useEffect(() => () => {
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ }, []);
+
+ const update = (field: keyof WorkaroundState, value: boolean | number) => {
+ void onChange({ ...state, [field]: value });
+ };
+
+ const updateFps = (value: number) => {
+ setFpsValue(value);
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => {
+ timer.current = null;
+ void onChange({ ...state, dxvkFrameRate: value });
+ }, 250);
+ };
+
+ const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off");
+
+ return (
+ <>
+ <PanelSectionRow>
+ <div
+ style={{
+ fontSize: "14px",
+ fontWeight: "bold",
+ marginTop: "8px",
+ marginBottom: "6px",
+ borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
+ paddingBottom: "3px",
+ color: "white",
+ }}
+ >
+ {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")}
+ </div>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={() => setCollapsed((value) => !value)}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </PanelSectionRow>
+ {!collapsed && (
+ <>
+ <PanelSectionRow>
+ <SliderField
+ label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`}
+ description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires app restart to apply.")}
+ value={fpsValue}
+ min={0}
+ max={60}
+ step={1}
+ onChange={updateFps}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_STEAMDECK_MODE", "Disable Steam Deck Mode")}
+ description={t("CONFIG_DISABLE_STEAMDECK_MODE_DESC", "Disables a game-specific Steam Deck compatibility switch. Requires app restart to apply.")}
+ checked={state.disableSteamdeckMode}
+ onChange={(value) => update("disableSteamdeckMode", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_GAMESCOPE_WSI", "Disable Gamescope WSI")}
+ description={t("CONFIG_DISABLE_GAMESCOPE_WSI_DESC", "Adds ENABLE_GAMESCOPE_WSI=0. Requires app restart to apply.")}
+ checked={state.disableGamescopeWsi}
+ onChange={(value) => update("disableGamescopeWsi", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_HDR", "Disable HDR")}
+ description={t("CONFIG_DISABLE_HDR_DESC", "Prevents DXVK from exposing HDR to the app. Requires app restart to apply.")}
+ checked={state.disableHdr}
+ onChange={(value) => update("disableHdr", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_VKBASALT", "Disable vkBasalt")}
+ description={t("CONFIG_DISABLE_VKBASALT_DESC", "Disables vkBasalt which can conflict with LSFG-VK.")}
+ checked={state.disableVkbasalt}
+ onChange={(value) => update("disableVkbasalt", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_ENABLE_ZINK", "Force Zink for OpenGL Games")}
+ description={t("CONFIG_ENABLE_ZINK_DESC", "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes. Requires app restart to apply.")}
+ checked={state.enableZink}
+ onChange={(value) => update("enableZink", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ </>
+ )}
+ </>
+ );
+}
diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx
deleted file mode 100644
index 8aab940..0000000
--- a/src/components/FlatpaksTab.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useEffect, useState } from "react";
-import {
- ConfirmModal,
- Field,
- PanelSection,
- PanelSectionRow,
- ToggleField,
- showModal,
-} from "@decky/ui";
-import {
- checkFlatpakExtensionStatus,
- FlatpakApp,
- FlatpakAppInfo,
- FlatpakExtensionStatus,
- getFlatpakApps,
- installFlatpakExtension,
- removeFlatpakAppOverride,
- setFlatpakAppOverride,
- uninstallFlatpakExtension,
-} from "../api/lsfgApi";
-import { showErrorToast } from "../utils/toastUtils";
-import t from "../i18n/i18n";
-
-const runtimeVersions = [
- { version: "23.08", key: "installed_23_08" },
- { version: "24.08", key: "installed_24_08" },
- { version: "25.08", key: "installed_25_08" },
-] as const;
-
-interface RuntimeRowProps {
- version: string;
- installed: boolean;
- busy: boolean;
- onAction: () => void;
-}
-
-function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) {
- return (
- <PanelSectionRow>
- <ToggleField
- label={`Runtime ${version}`}
- description={busy ? "Updating..." : installed ? t("FLATPAK_INSTALLED", "Installed") : t("FLATPAK_NOT_INSTALLED", "Not installed")}
- checked={installed}
- onChange={() => onAction()}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-interface AppRowProps {
- app: FlatpakApp;
- runtimeReady: boolean;
- busy: boolean;
- onToggle: () => void;
-}
-
-function AppRow({ app, runtimeReady, busy, onToggle }: AppRowProps) {
- const configured = app.has_filesystem_override && app.has_env_override;
- const partial = app.has_filesystem_override || app.has_env_override;
- const status = configured
- ? runtimeReady
- ? t("FLATPAK_STATUS_READY", "Ready")
- : t("FLATPAK_STATUS_RUNTIME_MISSING", "Runtime missing")
- : partial
- ? t("FLATPAK_STATUS_PARTIAL", "Partial")
- : t("FLATPAK_STATUS_NOT_ENABLED", "Not enabled");
-
- return (
- <PanelSectionRow>
- <ToggleField
- label={app.app_name || app.app_id}
- description={`${app.app_id} - ${status}`}
- checked={configured}
- onChange={onToggle}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-export function FlatpaksTab() {
- const [extensionStatus, setExtensionStatus] = useState<FlatpakExtensionStatus | null>(null);
- const [apps, setApps] = useState<FlatpakAppInfo | null>(null);
- const [loading, setLoading] = useState(true);
- const [operation, setOperation] = useState<string | null>(null);
- const [error, setError] = useState<string | null>(null);
- const runtimeReady = extensionStatus?.success === true
- && runtimeVersions.some(({ key }) => extensionStatus[key]);
-
- const load = async () => {
- setLoading(true);
- try {
- const [nextStatus, nextApps] = await Promise.all([
- checkFlatpakExtensionStatus(),
- getFlatpakApps(),
- ]);
- setExtensionStatus(nextStatus);
- setApps(nextApps);
- } catch (loadError) {
- setError(String(loadError));
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- void load();
- }, []);
-
- const runExtensionOperation = async (version: string, installed: boolean) => {
- const action = installed ? "uninstall" : "install";
- setOperation(`${action}-${version}`);
- setError(null);
- try {
- const result = installed
- ? await uninstallFlatpakExtension(version)
- : await installFlatpakExtension(version);
- if (!result.success) throw new Error(result.error || result.message);
- setExtensionStatus(await checkFlatpakExtensionStatus());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak operation failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- const confirmExtensionOperation = (version: string, installed: boolean) => {
- if (!installed) {
- void runExtensionOperation(version, false);
- return;
- }
- showModal(
- <ConfirmModal
- strTitle={t("FLATPAK_UNINSTALL_TITLE", "Uninstall Runtime Extension")}
- strDescription={`${t("FLATPAK_UNINSTALL_CONFIRM_PREFIX", "Are you sure you want to uninstall the")} ${version} ${t("FLATPAK_UNINSTALL_CONFIRM_SUFFIX", "runtime extension?")}`}
- onOK={() => void runExtensionOperation(version, true)}
- onCancel={() => {}}
- />,
- );
- };
-
- const toggleApp = async (app: FlatpakApp) => {
- const configured = app.has_filesystem_override && app.has_env_override;
- setOperation(`app-${app.app_id}`);
- setError(null);
- try {
- const result = configured
- ? await removeFlatpakAppOverride(app.app_id)
- : await setFlatpakAppOverride(app.app_id);
- if (!result.success) throw new Error(result.error || result.message);
- setApps(await getFlatpakApps());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak override failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- if (loading) {
- return <PanelSection title="Flatpak Runtimes" spinner />;
- }
-
- return (
- <>
- <PanelSection title="Flatpak Runtimes">
- {error && <PanelSectionRow><Field label={t("FLATPAK_OPERATION_ERROR", "Operation failed")} description={error} /></PanelSectionRow>}
- {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => (
- <RuntimeRow
- key={version}
- version={version}
- installed={extensionStatus[key]}
- busy={operation === `${extensionStatus[key] ? "uninstall" : "install"}-${version}`}
- onAction={() => confirmExtensionOperation(version, extensionStatus[key])}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={extensionStatus?.error || error || t("FLATPAK_ERROR_STATUS", "Failed to check extension status")} /></PanelSectionRow>}
- </PanelSection>
-
- <PanelSection title="Applications">
- {apps?.success ? apps.apps.length ? apps.apps.map((app) => (
- <AppRow
- key={app.app_id}
- app={app}
- runtimeReady={runtimeReady}
- busy={operation === `app-${app.app_id}`}
- onToggle={() => void toggleApp(app)}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_NO_APPS", "No Flatpak Apps Found")} description={t("FLATPAK_NO_APPS_DESC", "No Flatpak applications are currently installed")} /></PanelSectionRow> : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={apps?.error || error || t("FLATPAK_ERROR_APPS", "Failed to load Flatpak applications")} /></PanelSectionRow>}
- </PanelSection>
- </>
- );
-}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 58ccd02..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/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 5f23b91..1f0bc73 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,5 +1,5 @@
import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState, type RefObject } from "react";
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
import { GameTarget } from "../hooks/useGameConfiguration";
@@ -9,10 +9,12 @@ interface Props {
onSelect: (appid: string) => void;
onEnableAll: () => Promise<void>;
onResetAll: () => Promise<void>;
+ focusConfiguredToggle?: boolean;
+ onConfiguredToggleFocused?: () => void;
}
-const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3";
-const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v2";
+const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4";
+const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3";
function usePersistentCollapsed(key: string) {
const [collapsed, setCollapsed] = useState(() => {
@@ -26,36 +28,41 @@ function usePersistentCollapsed(key: string) {
useEffect(() => {
try {
localStorage.setItem(key, String(collapsed));
- } catch {
- // Persisting the view preference is optional.
- }
+ } catch {}
}, [collapsed, key]);
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
+function targetDescription(game: GameTarget): string {
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
function GameGroup({
title,
games,
collapsed,
onToggle,
onSelect,
+ toggleRef,
}: {
title: string;
games: GameTarget[];
collapsed: boolean;
onToggle: () => void;
onSelect: (appid: string) => void;
+ toggleRef?: RefObject<HTMLDivElement>;
}) {
if (games.length === 0) return null;
return (
<>
<PanelSectionRow>
- <Field label={`${title} (${games.length})`} bottomSeparator="none" />
+ <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
</PanelSectionRow>
<PanelSectionRow>
<div
+ ref={toggleRef}
className="LSFG_GameGroupCollapseButton_Container"
style={{ marginTop: "-2px", marginBottom: "4px" }}
>
@@ -64,11 +71,7 @@ function GameGroup({
bottomSeparator={collapsed ? "standard" : "none"}
onClick={onToggle}
>
- {collapsed ? (
- <RiArrowDownSFill />
- ) : (
- <RiArrowUpSFill />
- )}
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
</ButtonItem>
</div>
</PanelSectionRow>
@@ -76,7 +79,7 @@ function GameGroup({
<PanelSectionRow key={game.appid}>
<Field
label={game.name}
- description={game.nonSteam ? "Non-Steam" : "Steam"}
+ description={targetDescription(game)}
onActivate={() => onSelect(game.appid)}
highlightOnFocus
/>
@@ -86,22 +89,35 @@ function GameGroup({
);
}
-export function GameConfigurationSelector({ targets, runningGame, onSelect, onEnableAll, onResetAll }: Props) {
+export function GameConfigurationSelector({
+ targets,
+ runningGame,
+ onSelect,
+ onEnableAll,
+ onResetAll,
+ focusConfiguredToggle = false,
+ onConfiguredToggleFocused,
+}: Props) {
const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => {
if (a.appid === runningGame?.appid) return -1;
if (b.appid === runningGame?.appid) return 1;
return a.name.localeCompare(b.name);
});
- const configuredGames = sortGames(targets.filter((game) => game.configured));
+ const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
- const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam);
- const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam);
- const availableSteamGames = availableGames.filter((game) => !game.nonSteam);
- const availableNonSteamGames = availableGames.filter((game) => game.nonSteam);
- const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY);
- const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
- const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`);
+ const enabledToggleRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!focusConfiguredToggle) return;
+ const frame = requestAnimationFrame(() => {
+ enabledToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus();
+ onConfiguredToggleFocused?.();
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]);
+
const confirmResetAll = () => {
showModal(
<ConfirmModal
@@ -113,11 +129,12 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn
/>,
);
};
+
const confirmEnableAll = () => {
showModal(
<ConfirmModal
strTitle="Enable all available games?"
- strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults."
+ strDescription="Create individual LSFG-VK profiles for every available Steam game and non-Steam shortcut. Flatpak profiles are managed separately in the Flatpak tab."
strOKButtonText="Enable all"
strCancelButtonText="Cancel"
onOK={() => void onEnableAll()}
@@ -133,6 +150,7 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn
.LSFG_GameGroupCollapseButton_Container > div > div > div > button,
.LSFG_GameGroupCollapseButton_Container > div > div > div > div > button {
height: 24px !important;
+ min-height: 24px !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
@@ -150,41 +168,28 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
- {availableGames.length > 0 && (
- <PanelSectionRow>
- <ButtonItem layout="below" onClick={confirmEnableAll}>
- Enable all available games
- </ButtonItem>
- </PanelSectionRow>
- )}
- <GameGroup
- title="LSFG-VK Enabled"
- games={configuredSteamGames}
- collapsed={configuredCollapsed}
- onToggle={toggleConfigured}
- onSelect={onSelect}
- />
<GameGroup
- title="LSFG-VK Enabled (Non-Steam)"
- games={configuredNonSteamGames}
- collapsed={configuredNonSteamCollapsed}
- onToggle={toggleConfiguredNonSteam}
+ title="Enabled"
+ games={enabledGames}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
onSelect={onSelect}
+ toggleRef={enabledToggleRef}
/>
<GameGroup
- title="Available games"
- games={availableSteamGames}
+ title="Available"
+ games={availableGames}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
/>
- <GameGroup
- title="Available games (Non-Steam)"
- games={availableNonSteamGames}
- collapsed={availableNonSteamCollapsed}
- onToggle={toggleAvailableNonSteam}
- onSelect={onSelect}
- />
+ {availableGames.length > 0 && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={confirmEnableAll}>
+ Enable all available games
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
<PanelSectionRow>
<ButtonItem
layout="below"
diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx
deleted file mode 100644
index 1bf10ac..0000000
--- a/src/components/InstallationButton.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ButtonItem, PanelSectionRow } from "@decky/ui";
-import t from '../i18n/i18n';
-
-interface InstallationButtonProps {
- isInstalled: boolean;
- isInstalling: boolean;
- isUninstalling: boolean;
- onInstall: () => void;
- onUninstall: () => void;
-}
-
-export function InstallationButton({
- isInstalled,
- isInstalling,
- isUninstalling,
- onInstall,
- onUninstall
-}: InstallationButtonProps) {
- const label = isInstalling
- ? t('INSTALL_INSTALLING', 'Installing...')
- : isUninstalling
- ? t('INSTALL_UNINSTALLING', 'Uninstalling...')
- : isInstalled
- ? t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK')
- : t('INSTALL_INSTALL_BTN', 'Install LSFG-VK');
-
- return (
- <PanelSectionRow>
- <ButtonItem
- layout="below"
- onClick={isInstalled ? onUninstall : onInstall}
- disabled={isInstalling || isUninstalling}
- >
- {label}
- </ButtonItem>
- </PanelSectionRow>
- );
-}
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 188c56a..c067dc0 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -6,20 +6,33 @@ import { GameConfigurationControls } from "./GameConfigurationControls";
interface Props {
game: GameTarget;
config: ConfigurationData;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onConfigChange: (
+ fieldName: keyof ConfigurationData,
+ value: boolean | number | string | string[],
+ ) => Promise<void>;
}
-export function NowPlayingTab({ game, config, onConfigChange }: Props) {
+function targetDescription(game: GameTarget): string {
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
+export function NowPlayingTab({
+ game,
+ config,
+ onConfigChange,
+}: Props) {
return (
<Focusable>
- <PanelSection>
+ <PanelSection title="Now Playing">
<PanelSectionRow>
- <Field
- label={game.name}
- />
+ <Field label={game.name} description={targetDescription(game)} />
</PanelSectionRow>
</PanelSection>
- <GameConfigurationControls config={config} onConfigChange={onConfigChange} showWorkarounds={false} />
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={onConfigChange}
+ showWorkarounds={false}
+ />
</Focusable>
);
}
diff --git a/src/components/ProfileDetails.tsx b/src/components/ProfileDetails.tsx
index 056abbb..4dd8891 100644
--- a/src/components/ProfileDetails.tsx
+++ b/src/components/ProfileDetails.tsx
@@ -24,12 +24,12 @@ export function ProfileDetails({ description }: ProfileDetailsProps) {
bottomSeparator={expanded ? "none" : "standard"}
onClick={() => setExpanded((value) => !value)}
>
- {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Details
+ {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Game Details
</ButtonItem>
</PanelSectionRow>
{expanded && (
<PanelSectionRow>
- <Field label="Details" description={description} />
+ <Field label="Game Details" description={description} />
</PanelSectionRow>
)}
</Focusable>
diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx
index 98d6e78..d769200 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SetupTab.tsx
@@ -1,7 +1,6 @@
-import { PanelSection } from "@decky/ui";
-import type { SteamBranchStatus } from "../api/lsfgApi";
-import { InstallationButton } from "./InstallationButton";
-import { StatusDisplay } from "./StatusDisplay";
+import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui";
+import { type SteamBranchStatus } from "../api/lsfgApi";
+import t from "../i18n/i18n";
interface SetupTabProps {
isInstalled: boolean;
@@ -15,32 +14,55 @@ interface SetupTabProps {
onUninstall: () => void;
}
-export function SetupTab({
- isInstalled,
- installationStatus,
- losslessScalingInstalled,
- losslessScalingStatus,
- steamBranchStatus,
- isInstalling,
- isUninstalling,
- onInstall,
- onUninstall,
-}: SetupTabProps) {
+export function SetupTab(props: SetupTabProps) {
+ const {
+ isInstalled,
+ installationStatus,
+ losslessScalingInstalled,
+ losslessScalingStatus,
+ steamBranchStatus,
+ isInstalling,
+ isUninstalling,
+ onInstall,
+ onUninstall,
+ } = props;
+ const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true;
+ const buttonLabel = isInstalling
+ ? t("INSTALL_INSTALLING", "Installing...")
+ : isUninstalling
+ ? t("INSTALL_UNINSTALLING", "Uninstalling...")
+ : isInstalled
+ ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK")
+ : t("INSTALL_INSTALL_BTN", "Install LSFG-VK");
+
return (
<PanelSection title="Setup">
- <StatusDisplay
- installationStatus={installationStatus}
- losslessScalingInstalled={losslessScalingInstalled}
- losslessScalingStatus={losslessScalingStatus}
- steamBranchStatus={steamBranchStatus}
- />
- <InstallationButton
- isInstalled={isInstalled}
- isInstalling={isInstalling}
- isUninstalling={isUninstalling}
- onInstall={onInstall}
- onUninstall={onUninstall}
- />
+ <PanelSectionRow>
+ <Field
+ label="Lossless Scaling"
+ description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <Field label="LSFG-VK" description={installationStatus} />
+ </PanelSectionRow>
+ {steamBranchStatus?.installed && (
+ <PanelSectionRow>
+ <Field
+ label="Steam branch"
+ description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`}
+ />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={isInstalled ? onUninstall : onInstall}
+ disabled={isInstalling || isUninstalling}
+ >
+ {buttonLabel}
+ </ButtonItem>
+ </PanelSectionRow>
</PanelSection>
);
}
diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx
deleted file mode 100644
index b1a98e5..0000000
--- a/src/components/StatusDisplay.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Field, PanelSectionRow } from "@decky/ui";
-import type { SteamBranchStatus } from "../api/lsfgApi";
-
-interface StatusDisplayProps {
- installationStatus: string;
- losslessScalingInstalled: boolean;
- losslessScalingStatus: string;
- steamBranchStatus: SteamBranchStatus | null;
-}
-
-export function StatusDisplay({
- installationStatus,
- losslessScalingInstalled,
- losslessScalingStatus,
- steamBranchStatus
-}: StatusDisplayProps) {
- const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true;
-
- return (
- <>
- <PanelSectionRow>
- <Field
- label="Lossless Scaling"
- description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"}
- />
- </PanelSectionRow>
- <PanelSectionRow>
- <Field label="LSFG-VK" description={installationStatus} />
- </PanelSectionRow>
-
- {steamBranchStatus?.installed && (
- <PanelSectionRow>
- <Field
- label="Steam branch"
- description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`}
- />
- </PanelSectionRow>
- )}
- </>
- );
-}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index e990784..3de5ec1 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -3,14 +3,15 @@ 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";
+const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2";
type ToggleWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">;
const TOGGLE_ROWS: readonly {
@@ -21,18 +22,25 @@ const TOGGLE_ROWS: readonly {
description: string;
}[] = [
{
+ field: "disableSteamdeckMode",
+ labelKey: "CONFIG_DISABLE_STEAMDECK_MODE",
+ label: "Disable Steam Deck Mode",
+ descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC",
+ description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
+ },
+ {
field: "disableGamescopeWsi",
labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI",
label: "Disable Gamescope WSI",
descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC",
- description: "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
+ description: "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.",
},
{
- field: "disableSteamdeckMode",
- labelKey: "CONFIG_DISABLE_STEAMDECK_MODE",
- label: "Disable Steam Deck Mode",
- descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC",
- description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
+ field: "disableHdr",
+ labelKey: "CONFIG_DISABLE_HDR",
+ label: "Disable HDR",
+ descriptionKey: "CONFIG_DISABLE_HDR_DESC",
+ description: "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.",
},
{
field: "disableVkbasalt",
@@ -63,26 +71,34 @@ function usePersistentCollapsed() {
useEffect(() => {
try {
localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed));
- } catch {
- // Persisting the view preference is optional.
- }
+ } catch {}
}, [collapsed]);
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam }: 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]);
@@ -148,13 +164,19 @@ export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps)
</PanelSectionRow>
</>
)}
- {status === "ready" && issues.length > 0 && (
- <PanelSectionRow>
- <Field
- label="Launch options need attention"
- description={`${issues.join(" ")} Adjusting a workaround will normalize its managed values.`}
- />
- </PanelSectionRow>
+ {status === "ready" && snapshot && (!snapshot.wrapperOwned || !snapshot.integrationInstalled) && (
+ <>
+ <PanelSectionRow>
+ <Field label="Wrapper needs to be reinstalled" />
+ </PanelSectionRow>
+ {onRepair && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={repairing} onClick={() => void handleRepair()}>
+ {repairing ? "Reinstalling..." : "Reinstall wrapper"}
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
+ </>
)}
<PanelSectionRow>
diff --git a/src/components/index.ts b/src/components/index.ts
index 37a8edb..bca6f6f 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,12 +1,9 @@
export { Content } from "./Content";
-export { StatusDisplay } from "./StatusDisplay";
-export { InstallationButton } from "./InstallationButton";
export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
export { ConfigurationTab } from "./ConfigurationTab";
-export { SetupTab } from "./SetupTab";
export { ConfigFileTab } from "./ConfigFileTab";
-export { FlatpaksTab } from "./FlatpaksTab";
+export { SetupTab } from "./SetupTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
export { GameConfigurationControls } from "./GameConfigurationControls";
export { NowPlayingTab } from "./NowPlayingTab";
diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts
new file mode 100644
index 0000000..d5e6f7e
--- /dev/null
+++ b/src/hooks/useFlatpakConfiguration.ts
@@ -0,0 +1,108 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ enableFlatpakApp,
+ getFlatpakApps,
+ getRunningFlatpakApps,
+ removeFlatpakApp,
+ setFlatpakWorkaroundState,
+ updateFlatpakConfig,
+ type FlatpakApp,
+ type LsfgConfig,
+ type RunningFlatpakApp,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import { showErrorToast } from "../utils/toastUtils";
+
+export function useFlatpakConfiguration(enabled: boolean) {
+ const [apps, setApps] = useState<FlatpakApp[]>([]);
+ const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [busyAppId, setBusyAppId] = useState("");
+
+ const reload = useCallback(async () => {
+ if (!enabled) {
+ setApps([]);
+ return;
+ }
+ setLoading(true);
+ try {
+ const result = await getFlatpakApps();
+ if (!result.success) throw new Error(result.error || "Could not list Flatpak applications");
+ setApps(result.apps || []);
+ } catch (error) {
+ showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error));
+ } finally {
+ setLoading(false);
+ }
+ }, [enabled]);
+
+ const pollRunning = useCallback(async () => {
+ if (!enabled) {
+ setRunningApps([]);
+ return;
+ }
+ try {
+ const result = await getRunningFlatpakApps();
+ if (result.success) setRunningApps(result.apps || []);
+ } catch {}
+ }, [enabled]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ useEffect(() => {
+ void pollRunning();
+ if (!enabled) return;
+ const interval = window.setInterval(() => void pollRunning(), 2000);
+ return () => window.clearInterval(interval);
+ }, [enabled, pollRunning]);
+
+ const operate = useCallback(async (appId: string, operation: () => Promise<{ success: boolean; error?: string | null }>) => {
+ if (busyAppId) return false;
+ setBusyAppId(appId);
+ try {
+ const result = await operation();
+ if (!result.success) throw new Error(result.error || "Flatpak operation failed");
+ await reload();
+ await pollRunning();
+ return true;
+ } catch (error) {
+ showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error));
+ return false;
+ } finally {
+ setBusyAppId("");
+ }
+ }, [busyAppId, pollRunning, reload]);
+
+ const enableApp = useCallback((appId: string) => operate(appId, () => enableFlatpakApp(appId)), [operate]);
+ const removeApp = useCallback((appId: string) => operate(appId, () => removeFlatpakApp(appId)), [operate]);
+ const updateConfig = useCallback(
+ (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)),
+ [operate],
+ );
+ const updateWorkarounds = useCallback(
+ (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)),
+ [operate],
+ );
+
+ const runningApp = useMemo(() => {
+ if (runningApps.length === 0) return null;
+ const running = runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null);
+ if (!running) return null;
+ return apps.find((app) => app.app_id === running.app_id) || null;
+ }, [apps, runningApps]);
+
+ return {
+ apps,
+ runningApps,
+ runningApp,
+ loading,
+ busyAppId,
+ reload,
+ enableApp,
+ removeApp,
+ updateConfig,
+ updateWorkarounds,
+ };
+}
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index 46607cb..2e39f5e 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 { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions";
+import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
@@ -19,7 +19,11 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
const appid = Number(shortcut?.appid);
const name = shortcut?.data?.strAppName;
if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return [];
- return [{ appid: String(appid >>> 0), name, nonSteam: true }];
+ return [{
+ appid: String(appid >>> 0),
+ name,
+ nonSteam: true,
+ }];
});
} catch {
return [];
@@ -28,10 +32,26 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) {
const games = new Map(backendGames.map((game) => [game.appid, game]));
- for (const game of shortcutGames) games.set(game.appid, game);
+ for (const game of shortcutGames) {
+ const existing = games.get(game.appid);
+ games.set(game.appid, existing ? { ...existing, name: game.name, nonSteam: true } : game);
+ }
return Array.from(games.values());
}
+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 });
@@ -59,6 +79,7 @@ export function useGameConfiguration() {
previousQuickAccessVisible.current = quickAccessVisible;
if (initialLoad || becameVisible) void load();
}, [load, quickAccessVisible]);
+
useEffect(() => {
const poll = () => {
if (!configsLoaded) return;
@@ -68,16 +89,25 @@ export function useGameConfiguration() {
const installed = installedGames.find((game) => game.appid === appid);
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
- setRunningGame((current) => current?.appid === appid ? current : {
+ const next: GameTarget = {
...(installed || { appid, name, nonSteam: false }),
name,
configured: games.some((game) => game.appid === appid),
- });
+ };
+ setRunningGame((current) => (
+ current?.appid === next.appid
+ && current.name === next.name
+ && current.nonSteam === next.nonSteam
+ && current.configured === next.configured
+ ? current
+ : next
+ ));
};
poll();
const interval = window.setInterval(poll, 2000);
return () => window.clearInterval(interval);
}, [configsLoaded, games, installedGames]);
+
useEffect(() => {
const appid = runningGame?.appid || null;
if (appid !== previousRunningAppId.current) {
@@ -94,52 +124,144 @@ export function useGameConfiguration() {
}, [games, installedGames, runningGame]);
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
+ const runningConfig = runningGame
+ ? games.find((game) => game.appid === runningGame.appid)?.config || template
+ : 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);
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ let newState = false;
+ let stateWriteAttempted = false;
+ let wrapperPath = getDefaultWrapperPath();
try {
- await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam);
+ const existing = await getWorkaroundState(target.appid);
+ if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
+ wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ const state = existing.state || { ...DEFAULT_WORKAROUND_STATE };
+ const commandTokenAdded = existing.command_token_added === true;
+ newState = !existing.state;
+ integration = await installWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ commandTokenAdded,
+ );
+ stateWriteAttempted = true;
+ const saved = await setWorkaroundState(
+ target.appid,
+ state,
+ integration.commandTokenAdded,
+ );
+ if (!saved.success) throw new Error(saved.error || "Could not save workaround state");
return true;
} catch (error) {
- showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error));
+ let rollbackSucceeded = true;
+ if (integration?.changed) {
+ try {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ integration.commandTokenAdded,
+ );
+ } catch (rollbackError) {
+ showErrorToast("Workaround rollback failed", asError(rollbackError).message);
+ rollbackSucceeded = false;
+ }
+ }
+ if (rollbackSucceeded && newState && stateWriteAttempted) {
+ const restored = await removeWorkaroundState(target.appid);
+ if (!restored.success) {
+ showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state");
+ rollbackSucceeded = false;
+ }
+ }
+ showErrorToast("Could not initialize workarounds", asError(error).message);
return false;
}
}, [installedGames]);
- 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;
- const result = await updateGameConfig(selectedAppId, selectedTarget.name, next);
+ const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ const appId = Number(target.appid);
+ try {
+ const existing = await getWorkaroundState(target.appid);
+ if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
+ const wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ existing.command_token_added === true,
+ );
+ const removed = await removeWorkaroundState(target.appid);
+ if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
+ return true;
+ } catch (error) {
+ showErrorToast("Could not clean up game workarounds", asError(error).message);
+ return false;
+ }
+ }, [installedGames]);
+
+ const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target?.name) return false;
+ if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false;
+ const result = await updateGameConfig(appid, target.name, next);
if (result.success) await load();
- }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
+ return result.success;
+ }, [ensureTargetWorkarounds, load, targets]);
+
+ const save = useCallback(
+ async (next: ConfigurationData, cleanupLaunchOptions = false) => {
+ if (!selectedAppId) return false;
+ return saveFor(selectedAppId, next, cleanupLaunchOptions);
+ },
+ [saveFor, selectedAppId],
+ );
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
- if (!(await cleanupTargetLaunchOptions(target))) return false;
+ if (!(await ensureTargetWorkarounds(target))) return false;
const result = await updateGameConfig(appid, target.name, template);
if (result.success) await load();
+ else await removeTargetWorkarounds(target);
return result.success;
- }, [cleanupTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+
const enableAll = useCallback(async (): Promise<void> => {
const available = targets.filter((target) => !target.configured && target.name);
if (available.length === 0) return;
for (const target of available) {
- if (!(await cleanupTargetLaunchOptions(target))) return;
+ if (!(await ensureTargetWorkarounds(target))) return;
const result = await updateGameConfig(target.appid, target.name, template);
if (!result.success) {
- showErrorToast("Could not enable all games", result.error || "A game profile could not be created");
+ await removeTargetWorkarounds(target);
+ showErrorToast(
+ "Could not enable all games",
+ result.error || `Could not create a profile for ${target.name}`,
+ );
return;
}
}
await load();
- }, [cleanupTargetLaunchOptions, load, targets, template]);
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+
+ const repair = useCallback(async (appid: string): Promise<boolean> => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target) return false;
+ const success = await ensureTargetWorkarounds(target);
+ if (success) await load();
+ return success;
+ }, [ensureTargetWorkarounds, load, targets]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return;
+ if (selectedTarget && !(await removeTargetWorkarounds(selectedTarget))) return;
const result = await resetGameConfig(selectedAppId);
if (result.success) {
setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current);
@@ -147,10 +269,11 @@ export function useGameConfiguration() {
await load();
}
}
- }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
+ }, [load, removeTargetWorkarounds, selectedAppId, targets]);
+
const resetAll = useCallback(async () => {
for (const target of targets.filter((item) => item.configured)) {
- if (!(await cleanupTargetLaunchOptions(target))) return;
+ if (!(await removeTargetWorkarounds(target))) return;
}
const result = await resetAllGameConfigs();
if (result.success) {
@@ -158,7 +281,7 @@ export function useGameConfiguration() {
setSelectedAppId("");
await load();
}
- }, [cleanupTargetLaunchOptions, load, targets]);
+ }, [load, removeTargetWorkarounds, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load };
+ return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, enable, enableAll, repair, resetSelected, resetAll, reload: load };
}
diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts
deleted file mode 100644
index 41189bd..0000000
--- a/src/hooks/useInstallationActions.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useState } from "react";
-import { installLsfgVk, uninstallLsfgVk } from "../api/lsfgApi";
-import {
- showInstallSuccessToast,
- showInstallErrorToast,
- showUninstallSuccessToast,
- showUninstallErrorToast
-} from "../utils/toastUtils";
-
-export function useInstallationActions() {
- const [isInstalling, setIsInstalling] = useState<boolean>(false);
- const [isUninstalling, setIsUninstalling] = useState<boolean>(false);
-
- const handleInstall = async (
- setIsInstalled: (value: boolean) => void,
- setInstallationStatus: (value: string) => void,
- reloadConfig?: () => Promise<void>,
- reloadStatus?: () => Promise<boolean>
- ) => {
- setIsInstalling(true);
- setInstallationStatus("Installing lsfg-vk...");
-
- try {
- const result = await installLsfgVk();
- if (result.success) {
- setIsInstalled(true);
- setInstallationStatus("lsfg-vk installed");
- showInstallSuccessToast();
-
- // Reload lsfg config after installation
- if (reloadConfig) {
- await reloadConfig();
- }
- if (reloadStatus) {
- await reloadStatus();
- }
- } else {
- setInstallationStatus(`Installation failed: ${result.error}`);
- showInstallErrorToast(result.error);
- }
- } catch (error) {
- setInstallationStatus(`Installation failed: ${error}`);
- showInstallErrorToast(String(error));
- } finally {
- setIsInstalling(false);
- }
- };
-
- const handleUninstall = async (
- setIsInstalled: (value: boolean) => void,
- setInstallationStatus: (value: string) => void,
- reloadStatus?: () => Promise<boolean>
- ) => {
- setIsUninstalling(true);
- setInstallationStatus("Uninstalling lsfg-vk...");
-
- try {
- const result = await uninstallLsfgVk();
- if (result.success) {
- setIsInstalled(false);
- setInstallationStatus("lsfg-vk uninstalled successfully!");
- if (reloadStatus) {
- await reloadStatus();
- }
- showUninstallSuccessToast();
- } else {
- setInstallationStatus(`Uninstallation failed: ${result.error}`);
- showUninstallErrorToast(result.error);
- }
- } catch (error) {
- setInstallationStatus(`Uninstallation failed: ${error}`);
- showUninstallErrorToast(String(error));
- } finally {
- setIsUninstalling(false);
- }
- };
-
- return {
- isInstalling,
- isUninstalling,
- handleInstall,
- handleUninstall
- };
-}
diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts
index 0b71ee9..9beb749 100644
--- a/src/hooks/useLsfgHooks.ts
+++ b/src/hooks/useLsfgHooks.ts
@@ -1,16 +1,26 @@
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
import {
checkLsfgVkInstalled,
getLosslessScalingBranchStatus,
- type SteamBranchStatus
+ installLsfgVk,
+ uninstallLsfgVk,
+ type SteamBranchStatus,
} from "../api/lsfgApi";
+import {
+ showInstallErrorToast,
+ showInstallSuccessToast,
+ showUninstallErrorToast,
+ showUninstallSuccessToast,
+} from "../utils/toastUtils";
-export function useInstallationStatus() {
- const [isInstalled, setIsInstalled] = useState<boolean>(false);
- const [installationStatus, setInstallationStatus] = useState<string>("");
- const [losslessScalingInstalled, setLosslessScalingInstalled] = useState<boolean>(false);
- const [losslessScalingStatus, setLosslessScalingStatus] = useState<string>("");
+export function useInstallation(reloadConfig?: () => Promise<void>) {
+ const [isInstalled, setIsInstalled] = useState(false);
+ const [installationStatus, setInstallationStatus] = useState("");
+ const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false);
+ const [losslessScalingStatus, setLosslessScalingStatus] = useState("");
const [steamBranchStatus, setSteamBranchStatus] = useState<SteamBranchStatus | null>(null);
+ const [isInstalling, setIsInstalling] = useState(false);
+ const [isUninstalling, setIsUninstalling] = useState(false);
const checkInstallation = async () => {
try {
@@ -25,13 +35,9 @@ export function useInstallationStatus() {
setIsInstalled(status.installed);
setLosslessScalingInstalled(status.lossless_scaling_installed);
setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed");
- if (status.installed) {
- setInstallationStatus("lsfg-vk Installed");
- } else {
- setInstallationStatus("lsfg-vk Not Installed");
- }
+ setInstallationStatus(status.installed ? "lsfg-vk Installed" : "lsfg-vk Not Installed");
return status.installed;
- } catch (error) {
+ } catch {
setSteamBranchStatus(null);
setLosslessScalingInstalled(false);
setLosslessScalingStatus("Lossless Scaling Not Installed");
@@ -41,17 +47,64 @@ export function useInstallationStatus() {
};
useEffect(() => {
- checkInstallation();
+ void checkInstallation();
}, []);
+ const install = async () => {
+ setIsInstalling(true);
+ setInstallationStatus("Installing lsfg-vk...");
+ try {
+ const result = await installLsfgVk();
+ if (!result.success) {
+ setInstallationStatus(`Installation failed: ${result.error}`);
+ showInstallErrorToast(result.error ?? undefined);
+ return;
+ }
+ setIsInstalled(true);
+ setInstallationStatus("lsfg-vk installed");
+ showInstallSuccessToast();
+ await reloadConfig?.();
+ await checkInstallation();
+ } catch (error) {
+ setInstallationStatus(`Installation failed: ${error}`);
+ showInstallErrorToast(String(error));
+ } finally {
+ setIsInstalling(false);
+ }
+ };
+
+ const uninstall = async () => {
+ setIsUninstalling(true);
+ setInstallationStatus("Uninstalling lsfg-vk...");
+ try {
+ const result = await uninstallLsfgVk();
+ if (!result.success) {
+ setInstallationStatus(`Uninstallation failed: ${result.error}`);
+ showUninstallErrorToast(result.error ?? undefined);
+ return;
+ }
+ setIsInstalled(false);
+ setInstallationStatus("lsfg-vk uninstalled successfully!");
+ await checkInstallation();
+ showUninstallSuccessToast();
+ } catch (error) {
+ setInstallationStatus(`Uninstallation failed: ${error}`);
+ showUninstallErrorToast(String(error));
+ } finally {
+ setIsUninstalling(false);
+ }
+ };
+
return {
isInstalled,
installationStatus,
- setIsInstalled,
- setInstallationStatus,
losslessScalingInstalled,
losslessScalingStatus,
steamBranchStatus,
- checkInstallation
+ isInstalling,
+ isUninstalling,
+ install,
+ uninstall,
+ checkInstallation,
};
}
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index a937780..c2b8904 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -1,29 +1,48 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- applyWorkaroundChange,
- parseWorkaroundOptions,
+ getWorkaroundState,
+ removeWorkaroundState,
+ setWorkaroundState,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import {
+ getDefaultWrapperPath,
+ installWrapperIntegration,
+ isWrapperIntegrationInstalled,
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;
}
interface PerAppWorkarounds {
@@ -38,8 +57,58 @@ 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 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();
+ return {
+ steam,
+ state: result.state,
+ wrapperPath,
+ wrapperOwned: result.wrapper_owned === true,
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath),
+ commandTokenAdded: result.command_token_added === true,
+ };
+}
+
+async function adoptWorkaroundState(
+ appId: string,
+ nonSteam: boolean,
+ wrapperPath: string,
+): Promise<WorkaroundSnapshot> {
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ try {
+ integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false);
+ const finalized = await setWorkaroundState(
+ appId,
+ DEFAULT_WORKAROUND_STATE,
+ 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?.changed) {
+ try {
+ await removeWrapperIntegration(
+ Number(appId),
+ nonSteam,
+ wrapperPath,
+ integration.commandTokenAdded,
+ );
+ } catch {
+ 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 +118,24 @@ 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,
+ 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 +144,77 @@ 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: isWrapperIntegrationInstalled(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.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,
+ commandTokenAdded: result.command_token_added === true,
+ });
return true;
} catch (updateError) {
const nextError = asError(updateError);
@@ -126,14 +223,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 +239,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
if (field === "dxvkFrameRate") {
setError(null);
return new Promise<boolean>((resolve) => {
- const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] };
+ const pending = pendingSliderUpdate.current || { timer: 0, value: 0, waiters: [] };
window.clearTimeout(pending.timer);
pending.value = Number(value);
pending.waiters.push(resolve);
- pending.timer = window.setTimeout(() => {
- void flushSliderUpdate();
- }, SLIDER_DEBOUNCE_MS);
+ pending.timer = window.setTimeout(() => { void flushSliderUpdate(); }, SLIDER_DEBOUNCE_MS);
pendingSliderUpdate.current = pending;
});
}
-
const sliderSuccess = await flushSliderUpdate();
if (!sliderSuccess) return false;
return persistUpdate(field, value);
}, [flushSliderUpdate, persistUpdate]);
- useEffect(() => {
- return () => {
- const pending = pendingSliderUpdate.current;
- if (!pending) return;
- window.clearTimeout(pending.timer);
- pendingSliderUpdate.current = null;
- pending.waiters.forEach((resolve) => resolve(false));
- };
+ useEffect(() => () => {
+ const pending = pendingSliderUpdate.current;
+ if (!pending) return;
+ window.clearTimeout(pending.timer);
+ pendingSliderUpdate.current = null;
+ pending.waiters.forEach((resolve) => resolve(false));
}, [numericAppId, nonSteam]);
return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]);
diff --git a/src/i18n/languages.json b/src/i18n/languages.json
index 3f1992c..7e5676b 100644
--- a/src/i18n/languages.json
+++ b/src/i18n/languages.json
@@ -22,7 +22,9 @@
"CONFIG_ENABLE_WSI": "WSIを有効化",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。",
"CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化",
- "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。",
+ "CONFIG_DISABLE_HDR": "HDRを無効化",
+ "CONFIG_DISABLE_HDR_DESC": "DXVKがゲームにHDRを公開しないようにします。ゲームの再起動が必要です。",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化",
"CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。",
"CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化",
@@ -109,7 +111,9 @@
"CONFIG_ENABLE_WSI": "WSI 활성화",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화",
- "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.",
+ "CONFIG_DISABLE_HDR": "HDR 비활성화",
+ "CONFIG_DISABLE_HDR_DESC": "DXVK가 게임에 HDR을 노출하지 않도록 합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화",
"CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화",
@@ -224,7 +228,9 @@
"CONFIG_ENABLE_WSI": "Enable WSI",
"CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.",
"CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI",
- "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.",
+ "CONFIG_DISABLE_HDR": "Disable HDR",
+ "CONFIG_DISABLE_HDR_DESC": "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode",
"CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
"CONFIG_DISABLE_VKBASALT": "Disable vkBasalt",
diff --git a/src/types.d.ts b/src/types.d.ts
index 4b88d3d..4adad61 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -16,7 +16,6 @@ declare module "*.jpg" {
interface SteamAppDetails {
strLaunchOptions?: string;
strShortcutLaunchOptions?: string;
- strShortcutExe?: string;
}
interface SteamAppDetailsRegistration {
@@ -30,6 +29,7 @@ interface SteamApps {
): SteamAppDetailsRegistration;
SetAppLaunchOptions(appId: number, options: string): void | Promise<void>;
SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>;
+ TerminateApp(appId: string, param1: boolean): void;
GetAllShortcuts?(): Promise<unknown[]>;
}
diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts
deleted file mode 100644
index 5b8156c..0000000
--- a/src/utils/steamLaunchOptionParser.ts
+++ /dev/null
@@ -1,501 +0,0 @@
-export interface WorkaroundState {
- dxvkFrameRate: number;
- disableGamescopeWsi: boolean;
- disableSteamdeckMode: boolean;
- disableVkbasalt: boolean;
- enableZink: boolean;
-}
-
-export type WorkaroundField = keyof WorkaroundState;
-
-export interface ParsedWorkaroundOptions {
- state: WorkaroundState;
- issues: string[];
-}
-
-interface LaunchToken {
- raw: string;
- value: string;
-}
-
-interface EnvironmentEntry {
- value: string;
- count: number;
-}
-
-type BooleanWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">;
-type EnvironmentSpec = readonly [key: string, value: string];
-type DxvkFrameRateKey = "dxvk.maxFrameRate" | "dxgi.maxFrameRate" | "d3d9.maxFrameRate";
-
-interface WorkaroundDefinition {
- spec: EnvironmentSpec;
- clear: readonly string[];
- label?: string;
-}
-
-const COMMAND_TOKEN = "%command%";
-const LEGACY_WRAPPER_TOKENS = new Set([
- "~/lsfg",
- "/home/deck/lsfg",
- "~/.local/bin/lsfg-vk-experimental",
- "/home/deck/.local/bin/lsfg-vk-experimental",
- "~/.local/bin/mako-run",
- "/home/deck/.local/bin/mako-run",
- "~/.local/bin/mako-launch",
- "/home/deck/.local/bin/mako-launch",
-]);
-const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [
- "dxvk.maxFrameRate",
- "dxgi.maxFrameRate",
- "d3d9.maxFrameRate",
-];
-const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]);
-const WORKAROUND_DEFINITIONS = {
- disableGamescopeWsi: {
- spec: ["ENABLE_GAMESCOPE_WSI", "0"],
- clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"],
- },
- disableSteamdeckMode: {
- spec: ["SteamDeck", "0"],
- clear: ["SteamDeck"],
- label: "Steam Deck mode",
- },
- disableVkbasalt: {
- spec: ["DISABLE_VKBASALT", "1"],
- clear: ["DISABLE_VKBASALT"],
- label: "Disable vkBasalt",
- },
- enableZink: {
- spec: ["MESA_LOADER_DRIVER_OVERRIDE", "zink"],
- clear: ["__GLX_VENDOR_LIBRARY_NAME", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER"],
- },
-} as const satisfies Record<BooleanWorkaroundField, WorkaroundDefinition>;
-const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [
- "disableGamescopeWsi",
- "disableSteamdeckMode",
- "disableVkbasalt",
- "enableZink",
-];
-const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI";
-const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI";
-const MANAGED_ENV_KEYS = new Set([
- ...DXVK_MANAGED_KEYS,
- ...BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
-]);
-
-const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
- dxvkFrameRate: 0,
- disableGamescopeWsi: false,
- disableSteamdeckMode: false,
- disableVkbasalt: false,
- enableZink: false,
-};
-
-export function getDefaultWorkaroundState(): WorkaroundState {
- return { ...DEFAULT_WORKAROUND_STATE };
-}
-
-function decodeToken(raw: string): string {
- let value = "";
- let quote: "'" | '"' | null = null;
-
- for (let index = 0; index < raw.length; index += 1) {
- const character = raw[index];
- if (character === "\\" && quote !== "'" && index + 1 < raw.length) {
- value += raw[index + 1];
- index += 1;
- } else if (quote !== null) {
- if (character === quote) quote = null;
- else value += character;
- } else if (character === "'" || character === '"') {
- quote = character;
- } else {
- value += character;
- }
- }
-
- return value;
-}
-
-// Steam stores one shell-like line. Keep each token's raw spelling beside its
-// decoded value so managed edits leave unrelated quoting and arguments alone.
-function tokenize(options: string): LaunchToken[] {
- const tokens: LaunchToken[] = [];
- let start = -1;
- let quote: "'" | '"' | null = null;
- let escaped = false;
-
- for (let index = 0; index < options.length; index += 1) {
- const character = options[index];
- if (start < 0) {
- if (/\s/.test(character)) continue;
- start = index;
- }
-
- if (escaped) {
- escaped = false;
- } else if (character === "\\" && quote !== "'") {
- escaped = true;
- } else if (quote !== null) {
- if (character === quote) quote = null;
- } else if (character === "'" || character === '"') {
- quote = character;
- } else if (/\s/.test(character)) {
- const raw = options.slice(start, index);
- tokens.push({ raw, value: decodeToken(raw) });
- start = -1;
- }
- }
-
- if (start >= 0) {
- const raw = options.slice(start);
- tokens.push({ raw, value: decodeToken(raw) });
- }
- return tokens;
-}
-
-function serialize(tokens: readonly LaunchToken[]): string {
- if (tokens.length === 1 && tokens[0].raw.toLowerCase() === COMMAND_TOKEN) return "";
- return tokens.map((token) => token.raw).join(" ");
-}
-
-export function normalizeLaunchOptions(options: string): string {
- return serialize(tokenize(options));
-}
-
-function parseEnvironmentToken(token: LaunchToken): [string, string] | null {
- const separator = token.value.indexOf("=");
- if (separator < 1) return null;
- const key = token.value.slice(0, separator);
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
- return [key, token.value.slice(separator + 1)];
-}
-
-function findCommandIndex(tokens: readonly LaunchToken[]): number {
- return tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN);
-}
-
-function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number {
- let count = 0;
- while (count < tokens.length && parseEnvironmentToken(tokens[count]) !== null) count += 1;
- return count;
-}
-
-function effectivePrefixLimit(tokens: readonly LaunchToken[]): number {
- return leadingEnvironmentCount(tokens);
-}
-
-function effectiveEnvironmentEntries(tokens: readonly LaunchToken[]): Map<string, EnvironmentEntry> {
- const entries = new Map<string, EnvironmentEntry>();
- for (let index = 0; index < effectivePrefixLimit(tokens); index += 1) {
- const parsed = parseEnvironmentToken(tokens[index]);
- if (!parsed) continue;
- const [key, value] = parsed;
- const previous = entries.get(key);
- entries.set(key, { value, count: (previous?.count || 0) + 1 });
- }
- return entries;
-}
-
-function removePrefixAssignments(tokens: LaunchToken[], predicate: (token: LaunchToken) => boolean): boolean {
- const limit = effectivePrefixLimit(tokens);
- const retained = tokens.filter((token, index) => index >= limit || !predicate(token));
- if (retained.length === tokens.length) return false;
- tokens.splice(0, tokens.length, ...retained);
- return true;
-}
-
-function removeAllAssignments(tokens: LaunchToken[], keys: ReadonlySet<string>): boolean {
- return removePrefixAssignments(tokens, (token) => {
- const parsed = parseEnvironmentToken(token);
- return parsed !== null && keys.has(parsed[0]);
- });
-}
-
-function encodeEnvironmentValue(value: string): string {
- if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value;
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
-}
-
-function insertEnvironmentSpecs(tokens: LaunchToken[], specs: readonly EnvironmentSpec[]): void {
- tokens.unshift(...specs.map(([key, value]) => ({
- raw: `${key}=${encodeEnvironmentValue(value)}`,
- value: `${key}=${value}`,
- })));
-}
-
-function ensureCommandToken(tokens: LaunchToken[]): void {
- if (findCommandIndex(tokens) >= 0) return;
- tokens.splice(leadingEnvironmentCount(tokens), 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN });
-}
-
-export function isLegacyWrapperToken(value: string): boolean {
- const path = decodeToken(value);
- return LEGACY_WRAPPER_TOKENS.has(path);
-}
-
-function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean {
- const commandIndex = findCommandIndex(tokens);
- const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length;
- const retained = tokens.filter((token, index) => index >= prefixEnd || !isLegacyWrapperToken(token.raw));
- if (retained.length === tokens.length) return false;
- tokens.splice(0, tokens.length, ...retained);
- return true;
-}
-
-interface DxvkConfigAssignment {
- values: string[];
- malformed: number;
-}
-
-interface ParsedDxvkConfig {
- segments: string[];
- assignments: Map<DxvkFrameRateKey, DxvkConfigAssignment>;
-}
-
-function splitDxvkConfig(value: string): string[] {
- const segments: string[] = [];
- let start = 0;
- let quote: "'" | '"' | null = null;
- let escaped = false;
-
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index];
- if (escaped) escaped = false;
- else if (character === "\\" && quote !== "'") escaped = true;
- else if (quote !== null) {
- if (character === quote) quote = null;
- } else if (character === "'" || character === '"') quote = character;
- else if (character === ";") {
- segments.push(value.slice(start, index));
- start = index + 1;
- }
- }
-
- segments.push(value.slice(start));
- return segments;
-}
-
-function knownDxvkKey(value: string): DxvkFrameRateKey | null {
- const key = value.match(/^([A-Za-z][A-Za-z0-9.]*)/)?.[1];
- return key && DXVK_FRAME_RATE_KEYS.includes(key as DxvkFrameRateKey)
- ? key as DxvkFrameRateKey
- : null;
-}
-
-function parseDxvkConfig(value: string): ParsedDxvkConfig {
- const segments = splitDxvkConfig(value);
- const assignments = new Map<DxvkFrameRateKey, DxvkConfigAssignment>();
- for (const segment of segments) {
- const trimmed = segment.trim();
- const key = knownDxvkKey(trimmed);
- if (!key) continue;
- const match = trimmed.match(/^[A-Za-z][A-Za-z0-9.]*\s*=\s*(.*?)\s*$/);
- const entry = assignments.get(key) || { values: [], malformed: 0 };
- if (match) entry.values.push(match[1]);
- else entry.malformed += 1;
- assignments.set(key, entry);
- }
- return { segments, assignments };
-}
-
-function isDxvkFrameRateSegment(segment: string): boolean {
- return knownDxvkKey(segment.trim()) !== null;
-}
-
-function parseSupportedFrameRate(value: string): number | null {
- if (!/^\d+$/.test(value)) return null;
- const numericValue = Number(value);
- return Number.isSafeInteger(numericValue) && numericValue <= 60 ? numericValue : null;
-}
-
-function rewriteDxvkFrameRate(tokens: LaunchToken[], frameRate: number): void {
- const config = effectiveEnvironmentEntries(tokens).get("DXVK_CONFIG");
- const parsed = parseDxvkConfig(config?.value || "");
- const retained = parsed.segments
- .filter((segment) => !isDxvkFrameRateSegment(segment))
- .filter((segment) => segment.trim().length > 0)
- .join(";");
- const nextConfig = frameRate > 0
- ? [`dxvk.maxFrameRate = ${frameRate}`, ...(retained ? [retained] : [])].join(";")
- : retained;
-
- removeAllAssignments(tokens, DXVK_MANAGED_KEYS);
- if (nextConfig) {
- ensureCommandToken(tokens);
- insertEnvironmentSpecs(tokens, [["DXVK_CONFIG", nextConfig]]);
- }
-}
-
-function environmentSpecsForState(state: WorkaroundState): EnvironmentSpec[] {
- return BOOLEAN_WORKAROUND_FIELDS
- .filter((field) => state[field])
- .map((field) => WORKAROUND_DEFINITIONS[field].spec);
-}
-
-function validateFrameRate(frameRate: number): void {
- if (!Number.isInteger(frameRate) || frameRate < 0 || frameRate > 60) {
- throw new Error("Base FPS Cap must be an integer from 0 to 60");
- }
-}
-
-function readBoolean(
- entries: Map<string, EnvironmentEntry>,
- key: string,
- label: string,
- trueValue: string,
- issues: string[],
-): boolean {
- const entry = entries.get(key);
- if (!entry) return false;
- const falseValue = trueValue === "1" ? "0" : "1";
- if (entry.value === trueValue) return true;
- if (entry.value === falseValue) return false;
- issues.push(`${label} has an unsupported value.`);
- return false;
-}
-
-export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions {
- const tokens = tokenize(options);
- const entries = effectiveEnvironmentEntries(tokens);
- const state = getDefaultWorkaroundState();
- const issues: string[] = [];
-
- for (const [key, entry] of entries) {
- if (MANAGED_ENV_KEYS.has(key) && entry.count > 1) {
- issues.push(`${key} appears more than once; Steam uses the last value.`);
- }
- }
-
- const dxvkConfig = parseDxvkConfig(entries.get("DXVK_CONFIG")?.value || "");
- const effectiveDxvkValues = new Map<DxvkFrameRateKey, number | null>();
- for (const key of DXVK_FRAME_RATE_KEYS) {
- const assignment = dxvkConfig.assignments.get(key);
- if (!assignment) continue;
- if (assignment.malformed > 0) issues.push(`${key} in DXVK_CONFIG is malformed.`);
- if (assignment.values.length > 1) {
- issues.push(`${key} appears more than once in DXVK_CONFIG; DXVK uses the last value.`);
- }
- if (assignment.values.length === 0) continue;
- const value = parseSupportedFrameRate(assignment.values[assignment.values.length - 1]);
- effectiveDxvkValues.set(key, value);
- if (value === null) issues.push(`${key} in DXVK_CONFIG is outside the supported 0-60 range.`);
- }
-
- const unifiedFrameRate = effectiveDxvkValues.get("dxvk.maxFrameRate");
- const dxgiFrameRate = effectiveDxvkValues.get("dxgi.maxFrameRate");
- const d3d9FrameRate = effectiveDxvkValues.get("d3d9.maxFrameRate");
- if (unifiedFrameRate !== undefined) {
- if (unifiedFrameRate !== null) state.dxvkFrameRate = unifiedFrameRate;
- } else if (dxgiFrameRate !== undefined && d3d9FrameRate !== undefined) {
- if (dxgiFrameRate !== null && dxgiFrameRate === d3d9FrameRate) state.dxvkFrameRate = dxgiFrameRate;
- else issues.push("DXVK_CONFIG has conflicting or invalid DirectX frame caps.");
- } else if (dxgiFrameRate !== undefined || d3d9FrameRate !== undefined) {
- const partial = dxgiFrameRate ?? d3d9FrameRate;
- if (partial !== null && partial !== undefined) state.dxvkFrameRate = partial;
- issues.push("DXVK_CONFIG only caps one DirectX API; adjust the cap to normalize it.");
- }
-
- if (entries.has("DXVK_FRAME_RATE")) {
- issues.push("DXVK_FRAME_RATE is obsolete on current DXVK; adjust the cap to migrate it.");
- }
-
- const wsiSignals: boolean[] = [];
- const wsiDisable = entries.get(WSI_DISABLE_KEY);
- if (wsiDisable) {
- if (wsiDisable.value !== "0" && wsiDisable.value !== "1") issues.push("Disable Gamescope WSI has an unsupported value.");
- else wsiSignals.push(wsiDisable.value === "1");
- }
- const wsiEnable = entries.get(WSI_ENABLE_KEY);
- if (wsiEnable) {
- if (wsiEnable.value !== "0" && wsiEnable.value !== "1") issues.push("Enable Gamescope WSI has an unsupported value.");
- else wsiSignals.push(wsiEnable.value === "0");
- }
- if (wsiSignals.length > 0) {
- if (wsiSignals.length === 2 && wsiSignals[0] !== wsiSignals[1]) {
- issues.push("Gamescope WSI has conflicting enable and disable assignments.");
- }
- state.disableGamescopeWsi = wsiSignals.some(Boolean);
- }
-
- for (const field of ["disableSteamdeckMode", "disableVkbasalt"] as const) {
- const { spec, label } = WORKAROUND_DEFINITIONS[field];
- state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues);
- }
-
- const vkBasaltEnable = entries.get("ENABLE_VKBASALT");
- const vkBasaltDisable = entries.get("DISABLE_VKBASALT");
- if (vkBasaltEnable?.value === "1" && vkBasaltDisable?.value === "1") {
- issues.push("vkBasalt has conflicting enable and disable assignments.");
- }
-
- const zink = entries.get(WORKAROUND_DEFINITIONS.enableZink.spec[0]);
- const glxVendor = entries.get("__GLX_VENDOR_LIBRARY_NAME");
- const galliumDriver = entries.get("GALLIUM_DRIVER");
- const hasLegacyZink = glxVendor !== undefined || galliumDriver !== undefined;
- if (zink || hasLegacyZink) {
- state.enableZink = zink?.value === WORKAROUND_DEFINITIONS.enableZink.spec[1];
- if (hasLegacyZink && (
- glxVendor?.value !== "mesa" ||
- zink?.value !== WORKAROUND_DEFINITIONS.enableZink.spec[1] ||
- galliumDriver?.value !== "zink"
- )) {
- issues.push("Zink workaround is only partially configured.");
- } else if (!state.enableZink) {
- issues.push("Zink workaround has an unsupported driver value.");
- }
- }
-
- return { state, issues };
-}
-
-export function applyWorkaroundState(options: string, state: WorkaroundState): string {
- validateFrameRate(state.dxvkFrameRate);
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
- rewriteDxvkFrameRate(tokens, state.dxvkFrameRate);
- const keysToClear = new Set<string>(
- BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
- );
- if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT");
- removeAllAssignments(tokens, keysToClear);
- const specs = environmentSpecsForState(state);
- if (specs.length > 0) {
- ensureCommandToken(tokens);
- insertEnvironmentSpecs(tokens, specs);
- }
- return serialize(tokens);
-}
-
-export function applyWorkaroundChange(options: string, field: WorkaroundField, value: boolean | number): string {
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
-
- if (field === "dxvkFrameRate") {
- if (typeof value !== "number") throw new Error("Base FPS Cap must be an integer from 0 to 60");
- validateFrameRate(value);
- rewriteDxvkFrameRate(tokens, value);
- return serialize(tokens);
- }
-
- if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`);
- const definition = WORKAROUND_DEFINITIONS[field];
- const keysToClear = new Set<string>(definition.clear);
- if (value && field === "disableVkbasalt") keysToClear.add("ENABLE_VKBASALT");
- removeAllAssignments(tokens, keysToClear);
- if (value) {
- ensureCommandToken(tokens);
- insertEnvironmentSpecs(tokens, [definition.spec]);
- }
- return serialize(tokens);
-}
-
-export function cleanupLegacyLaunchOptions(options: string): string {
- const tokens = tokenize(options);
- removeLegacyWrapperFromTokens(tokens);
- return serialize(tokens);
-}
-
-export function cleanupLegacyWrapper(options: string): string {
- return cleanupLegacyLaunchOptions(options);
-}
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
index 39125bd..f03eeab 100644
--- a/src/utils/steamLaunchOptions.ts
+++ b/src/utils/steamLaunchOptions.ts
@@ -1,30 +1,58 @@
-// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests.
-import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts";
+const DEFAULT_WRAPPER_PATH = "~/.lsfg";
+const COMMAND_TOKEN = "%command%";
-// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests.
-export * from "./steamLaunchOptionParser.ts";
+export const LEGACY_WRAPPER_TOKENS = new Set([
+ "~/lsfg",
+ "~/.local/bin/lsfg",
+ "~/.local/bin/lsfg-vk-experimental",
+ "~/.local/bin/mako-run",
+ "mako-run",
+ "~/.local/bin/mako-launch",
+ "mako-launch",
+]);
+const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/;
+const MANAGED_ENV_KEYS = new Set([
+ "ENABLE_GAMESCOPE_WSI", "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck",
+ "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE",
+ "__GLX_VENDOR_LIBRARY_NAME", "GALLIUM_DRIVER", "DXVK_FRAME_RATE",
+]);
+const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i;
+
+interface LaunchToken { raw: string; value: string; }
export interface SteamLaunchOptionsSnapshot {
appId: number;
nonSteam: boolean;
options: string;
details: SteamAppDetails;
}
+export interface WrapperIntegrationResult {
+ snapshot: SteamLaunchOptionsSnapshot;
+ commandTokenAdded: boolean;
+ changed: boolean;
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
+
+function apps(): Partial<SteamApps> | undefined {
+ return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial<SteamApps> } }).SteamClient?.Apps;
+}
function validateAppId(appId: number): void {
if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID");
}
-function getSteamApps(): Partial<SteamApps> | undefined {
- return (globalThis as typeof globalThis & {
- SteamClient?: { Apps?: Partial<SteamApps> };
- }).SteamClient?.Apps;
+function timer() {
+ const host = typeof window !== "undefined" ? window : globalThis;
+ return {
+ set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number,
+ clear: (id: number) => host.clearTimeout(id),
+ };
}
-function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
- if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) {
- throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first");
- }
+function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
return {
appId,
nonSteam,
@@ -33,77 +61,39 @@ function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamApp
};
}
-function asError(error: unknown): Error {
- return error instanceof Error ? error : new Error(String(error));
-}
-
-function registerSteamAppDetails(
- appId: number,
- onDetails: (details: SteamAppDetails) => boolean | void,
-): () => void {
+function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void {
validateAppId(appId);
- const apps = getSteamApps();
- const registerForAppDetails = apps?.RegisterForAppDetails;
- if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable");
-
+ const register = apps()?.RegisterForAppDetails;
+ if (!register) throw new Error("Steam app-details API is unavailable");
let active = true;
- let unregisterPending = false;
let registration: SteamAppDetailsRegistration | undefined;
const unsubscribe = () => {
active = false;
- if (!registration) {
- unregisterPending = true;
- return;
- }
- try {
- registration.unregister();
- } catch {
- // Steam may invalidate registrations during a details refresh.
- }
+ try { registration?.unregister(); } catch {}
};
-
- try {
- registration = registerForAppDetails.call(apps, appId, (details) => {
- if (!active) return;
- if (onDetails(details || {}) === false && active) unsubscribe();
- });
- if (unregisterPending) {
- try {
- registration.unregister();
- } catch {
- // The registration can be invalidated before a synchronous callback returns.
- }
- }
- } catch (error) {
- throw asError(error);
- }
+ registration = register.call(apps(), appId, (details) => {
+ if (active && onDetails(details || {}) === false) unsubscribe();
+ });
+ if (!active) unsubscribe();
return unsubscribe;
}
export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> {
return new Promise((resolve, reject) => {
- let settled = false;
- let timeout = 0;
+ let done = false;
let unsubscribe = () => {};
+ const clock = timer();
+ const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000);
const finish = (error?: unknown, details?: SteamAppDetails) => {
- if (settled) return;
- settled = true;
- window.clearTimeout(timeout);
+ if (done) return;
+ done = true;
+ clock.clear(timeout);
unsubscribe();
- if (error) {
- reject(asError(error));
- return;
- }
- try {
- resolve(snapshotFromDetails(appId, nonSteam, details || {}));
- } catch (snapshotError) {
- reject(asError(snapshotError));
- }
+ if (error) reject(asError(error));
+ else resolve(snapshot(appId, nonSteam, details || {}));
};
-
- timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000);
try {
- unsubscribe = registerSteamAppDetails(appId, (details) => {
+ unsubscribe = registerDetails(appId, (details) => {
finish(undefined, details);
return false;
});
@@ -116,71 +106,217 @@ export async function readSteamLaunchOptions(appId: number, nonSteam: boolean):
export function subscribeSteamLaunchOptions(
appId: number,
nonSteam: boolean,
- onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void,
+ onSnapshot: (value: SteamLaunchOptionsSnapshot) => void,
onError: (error: Error) => void,
): () => void {
- return registerSteamAppDetails(appId, (details) => {
- try {
- onSnapshot(snapshotFromDetails(appId, nonSteam, details));
- } catch (error) {
- onError(asError(error));
- }
+ return registerDetails(appId, (details) => {
+ try { onSnapshot(snapshot(appId, nonSteam, details)); }
+ catch (error) { onError(asError(error)); }
});
}
-async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> {
- const apps = getSteamApps();
- const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions;
- if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`);
- await Promise.resolve(setter.call(apps, appId, options));
+function decodeToken(raw: string): string {
+ let value = "";
+ let quote: "'" | '"' | null = null;
+ for (let i = 0; i < raw.length; i++) {
+ const c = raw[i];
+ if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i];
+ else if (quote) { if (c === quote) quote = null; else value += c; }
+ else if (c === "'" || c === '"') quote = c;
+ else value += c;
+ }
+ return value;
}
-function delay(milliseconds: number): Promise<void> {
- return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
+function tokenize(options: string): LaunchToken[] {
+ const tokens: LaunchToken[] = [];
+ let start = -1;
+ let quote: "'" | '"' | null = null;
+ let escaped = false;
+ const push = (end: number) => {
+ if (start < 0) return;
+ const raw = options.slice(start, end);
+ tokens.push({ raw, value: decodeToken(raw) });
+ start = -1;
+ };
+ for (let i = 0; i < options.length; i++) {
+ const c = options[i];
+ if (start < 0) { if (/\s/.test(c)) continue; start = i; }
+ if (escaped) escaped = false;
+ else if (c === "\\" && quote !== "'") escaped = true;
+ else if (quote) { if (c === quote) quote = null; }
+ else if (c === "'" || c === '"') quote = c;
+ else if (/\s/.test(c)) push(i);
+ }
+ push(options.length);
+ return tokens;
+}
+
+const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" ");
+const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN);
+const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value);
+const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value);
+const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value);
+
+export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options));
+export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value));
+
+function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean {
+ const command = commandIndex(tokens);
+ const prefixEnd = command >= 0 ? command : tokens.length;
+ const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value));
+ if (kept.length === tokens.length) return false;
+ tokens.splice(0, tokens.length, ...kept);
+ return true;
}
-async function waitForLaunchOptions(
+function installLaunchOption(
+ options: string,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+ shortcutLaunchOptions = false,
+) {
+ const tokens = tokenize(options);
+ removeMatchingWrappers(tokens, isLegacyToken);
+ let command = commandIndex(tokens);
+ if (command >= 0) {
+ if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false };
+ removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath);
+ command = commandIndex(tokens);
+ tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath });
+ return { options: serialize(tokens), commandTokenAdded: false };
+ }
+ let insertion = 0;
+ while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++;
+ if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) {
+ throw new Error("Launch options do not contain %command%; refusing to guess a launcher command");
+ }
+ tokens.splice(insertion, 0,
+ { raw: wrapperPath, value: wrapperPath },
+ { raw: COMMAND_TOKEN, value: COMMAND_TOKEN },
+ );
+ return { options: serialize(tokens), commandTokenAdded: true };
+}
+
+export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) {
+ return installLaunchOption(options, wrapperPath);
+}
+
+export function removeWrapperLaunchOption(
+ options: string,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+ commandTokenAdded = false,
+): string {
+ const tokens = tokenize(options);
+ if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) {
+ const command = commandIndex(tokens);
+ if (command >= 0) tokens.splice(command, 1);
+ }
+ return serialize(tokens);
+}
+
+function encodeAssignmentValue(value: string): string {
+ return /^[A-Za-z0-9_./:+,%=-]+$/.test(value)
+ ? value
+ : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+}
+
+export function cleanupPluginAssignments(options: string): string {
+ const tokens = tokenize(options);
+ const command = commandIndex(tokens);
+ const prefixEnd = command >= 0 ? command : tokens.length;
+ return serialize(tokens.flatMap((token, i) => {
+ if (i >= prefixEnd || !isAssignment(token)) return [token];
+ const split = token.value.indexOf("=");
+ const key = token.value.slice(0, split);
+ if (key === "DXVK_CONFIG") {
+ const value = token.value.slice(split + 1).split(";").map((part) => part.trim())
+ .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; ");
+ return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : [];
+ }
+ return MANAGED_ENV_KEYS.has(key) ? [] : [token];
+ }));
+}
+
+export function cleanupLegacyLaunchOptions(options: string): string {
+ const tokens = tokenize(options);
+ removeMatchingWrappers(tokens, isLegacyToken);
+ return serialize(tokens);
+}
+export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) =>
+ cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath));
+export const cleanupLegacyWrapper = cleanupPluginLaunchOptions;
+export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean {
+ const tokens = tokenize(options);
+ const command = commandIndex(tokens);
+ return command > 0 && tokens[command - 1].value === wrapperPath;
+}
+
+export function isWrapperIntegrationInstalled(
+ steam: SteamLaunchOptionsSnapshot,
+ _nonSteam: boolean,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+): boolean {
+ return hasWrapperLaunchIntegration(steam.options, wrapperPath);
+}
+
+const queues = new Map<string, Promise<unknown>>();
+function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
+ const key = `${nonSteam ? "shortcut" : "app"}:${appId}`;
+ const previous = queues.get(key) || Promise.resolve();
+ const current = previous.catch(() => undefined).then(operation);
+ const cleanup = current.then(
+ () => { if (queues.get(key) === cleanup) queues.delete(key); },
+ () => { if (queues.get(key) === cleanup) queues.delete(key); },
+ );
+ queues.set(key, cleanup);
+ return current;
+}
+
+async function waitFor(
appId: number,
nonSteam: boolean,
- expected: string,
+ matches: (value: SteamLaunchOptionsSnapshot) => boolean,
+ message: string,
): Promise<SteamLaunchOptionsSnapshot> {
const deadline = Date.now() + 5000;
let lastError: Error | null = null;
while (Date.now() <= deadline) {
try {
- const snapshot = await readSteamLaunchOptions(appId, nonSteam);
- if (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot;
- } catch (error) {
- lastError = asError(error);
- }
- if (Date.now() >= deadline) break;
- await delay(100);
+ const value = await readSteamLaunchOptions(appId, nonSteam);
+ if (matches(value)) return value;
+ } catch (error) { lastError = asError(error); }
+ if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100));
}
- if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`);
- throw new Error("Steam did not accept the launch options before the readback timeout");
+ throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`);
}
-const operationQueues = new Map<string, Promise<void>>();
-
-function queueKey(appId: number, nonSteam: boolean): string {
- return `${nonSteam ? "shortcut" : "app"}:${appId}`;
+async function writeVerified(
+ appId: number,
+ nonSteam: boolean,
+ previous: string,
+ next: string,
+ write: (value: string) => Promise<void>,
+ message: string,
+): Promise<SteamLaunchOptionsSnapshot> {
+ try {
+ await write(next);
+ return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message);
+ } catch (error) {
+ const failure = asError(error);
+ try {
+ await write(previous);
+ await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options");
+ } catch (rollback) {
+ throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`);
+ }
+ throw failure;
+ }
}
-function queueSteamAppOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
- const key = queueKey(appId, nonSteam);
- 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);
- },
- );
- operationQueues.set(key, cleanup);
- return queued;
+function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> {
+ const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions;
+ if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`));
+ return Promise.resolve(setter.call(apps(), appId, value));
}
export function updateSteamLaunchOptions(
@@ -188,24 +324,59 @@ export function updateSteamLaunchOptions(
nonSteam: boolean,
transform: (options: string) => string,
): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamAppOperation(appId, nonSteam, async () => {
+ return queued(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
const next = transform(current.options);
- if (next === current.options) return current;
- await setSteamLaunchOptions(appId, nonSteam, next);
- return waitForLaunchOptions(appId, nonSteam, next);
+ return next === current.options ? current : writeVerified(
+ appId, nonSteam, current.options, next,
+ (value) => writeOptions(appId, nonSteam, value),
+ "Steam did not accept the launch options",
+ );
});
}
-export function cleanupSteamLaunchOptions(
+export function installWrapperIntegration(
appId: number,
nonSteam: boolean,
+ wrapperPath: string,
+ commandTokenAdded = false,
+): Promise<WrapperIntegrationResult> {
+ return queued(appId, nonSteam, async () => {
+ const current = await readSteamLaunchOptions(appId, nonSteam);
+ const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options));
+ const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath);
+ const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam);
+ if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false };
+ const value = await writeVerified(
+ appId, nonSteam, current.options, rewrite.options,
+ (options) => writeOptions(appId, nonSteam, options),
+ "Steam did not accept the launch options",
+ );
+ return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true };
+ });
+}
+
+export function removeWrapperIntegration(
+ appId: number,
+ nonSteam: boolean,
+ wrapperPath: string,
+ commandTokenAdded = false,
): Promise<SteamLaunchOptionsSnapshot> {
- return queueSteamAppOperation(appId, nonSteam, async () => {
+ return queued(appId, nonSteam, async () => {
const current = await readSteamLaunchOptions(appId, nonSteam);
- const next = cleanupLegacyLaunchOptions(current.options);
- if (next === current.options) return current;
- await setSteamLaunchOptions(appId, nonSteam, next);
- return waitForLaunchOptions(appId, nonSteam, next);
+ const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded));
+ return next === current.options ? current : writeVerified(
+ appId, nonSteam, current.options, next,
+ (options) => writeOptions(appId, nonSteam, options),
+ "Steam did not clean the launch options",
+ );
});
}
+
+export const cleanupLegacySteamLaunchOptions = (
+ appId: number,
+ nonSteam: boolean,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath));
+
+export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH;
diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts
index cbbbc55..c41f4c0 100644
--- a/src/utils/toastUtils.ts
+++ b/src/utils/toastUtils.ts
@@ -1,8 +1,3 @@
-/**
- * Centralized toast notification utilities
- * Provides consistent success/error messaging patterns
- */
-
import { toaster } from "@decky/api";
export interface ToastOptions {
@@ -10,84 +5,46 @@ export interface ToastOptions {
body: string;
}
-/**
- * Show a success toast notification
- */
-export function showSuccessToast(title: string, body: string): void {
- toaster.toast({
- title,
- body
- });
-}
+const showToast = (title: string, body: string): void => {
+ toaster.toast({ title, body });
+};
+export const showSuccessToast = showToast;
+export const showErrorToast = showToast;
-/**
- * Show an error toast notification
- */
-export function showErrorToast(title: string, body: string): void {
- toaster.toast({
- title,
- body
- });
-}
-
-/**
- * Standard success messages for common operations
- */
export const ToastMessages = {
INSTALL_SUCCESS: {
title: "Installation Complete",
- body: "lsfg-vk has been installed successfully"
+ body: "lsfg-vk has been installed successfully",
},
INSTALL_ERROR: {
title: "Installation Failed",
- body: "Unknown error occurred"
+ body: "Unknown error occurred",
},
UNINSTALL_SUCCESS: {
- title: "Uninstallation Complete",
- body: "lsfg-vk has been uninstalled successfully"
+ title: "Uninstallation Complete",
+ body: "lsfg-vk has been uninstalled successfully",
},
UNINSTALL_ERROR: {
title: "Uninstallation Failed",
- body: "Unknown error occurred"
+ body: "Unknown error occurred",
},
CONFIG_UPDATE_ERROR: {
title: "Update Failed",
- body: "Failed to update configuration"
- }
+ body: "Failed to update configuration",
+ },
} as const;
-/**
- * Show a toast with dynamic error message
- */
-export function showErrorToastWithMessage(title: string, error: unknown): void {
- const errorMessage = error instanceof Error ? error.message : String(error);
- showErrorToast(title, errorMessage);
-}
+export const showErrorToastWithMessage = (title: string, error: unknown): void =>
+ showErrorToast(title, error instanceof Error ? error.message : String(error));
-/**
- * Show installation success toast
- */
-export function showInstallSuccessToast(): void {
+export const showInstallSuccessToast = (): void =>
showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body);
-}
-/**
- * Show installation error toast
- */
-export function showInstallErrorToast(error?: string): void {
+export const showInstallErrorToast = (error?: string): void =>
showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body);
-}
-/**
- * Show uninstallation success toast
- */
-export function showUninstallSuccessToast(): void {
+export const showUninstallSuccessToast = (): void =>
showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body);
-}
-/**
- * Show uninstallation error toast
- */
-export function showUninstallErrorToast(error?: string): void {
+export const showUninstallErrorToast = (error?: string): void =>
showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body);
-}
diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts
index 0662fbb..40aeb3c 100644
--- a/tests/steamLaunchOptions.test.ts
+++ b/tests/steamLaunchOptions.test.ts
@@ -1,261 +1,94 @@
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,
- disableSteamdeckMode: true,
- disableVkbasalt: true,
- enableZink: true,
- });
+const wrapper = "~/.lsfg";
- assert.equal(
- options,
- 'ENABLE_GAMESCOPE_WSI=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,
- 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("keeps WSI disable opt-in and does not add HDR assignments", () => {
- const defaults = getDefaultWorkaroundState();
- assert.equal(applyWorkaroundState("%command%", defaults), "");
- assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false);
- assert.equal(
- applyWorkaroundChange("%command%", "disableGamescopeWsi", true),
- "ENABLE_GAMESCOPE_WSI=0 %command%",
- );
- assert.equal(
- applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 %command%", "disableGamescopeWsi", false),
- "",
- );
-
- const legacy = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%");
- assert.equal(legacy.state.disableGamescopeWsi, true);
- assert.deepEqual(legacy.issues, []);
- assert.equal(
- applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=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("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("normalizes blank and argument-only shortcut fields", () => {
+ 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("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%");
+test("preserves assignments quoting suffixes and released wrapper cleanup", () => {
+ const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"';
assert.equal(
- applyWorkaroundChange(disabled, "disableVkbasalt", false),
- "",
+ installWrapperLaunchOption(options, wrapper).options,
+ 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun ~/.lsfg %command% --flag "two words"',
);
- const conflict = parseWorkaroundOptions("ENABLE_VKBASALT=1 DISABLE_VKBASALT=1 %command%");
- assert.equal(conflict.state.disableVkbasalt, true);
- assert.match(conflict.issues.join(" "), /conflicting/);
+ assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`);
+ assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag");
+ 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(isLegacyWrapperToken("/home/kurt/lsfg"), true);
+ assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false);
});
-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("removes only managed assignments and preserves unrelated values", () => {
assert.equal(
- applyWorkaroundChange(
- "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%",
- "enableZink",
- false,
+ 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%',
),
- "",
- );
-});
-
-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("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"),
- "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%",
+ 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%',
);
+ assert.equal(cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), "%command%");
assert.equal(
- cleanupLegacyWrapper("LSFG_PROCESS=decky-lsfg-vk %command%"),
- "LSFG_PROCESS=decky-lsfg-vk %command%",
+ cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"),
+ "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%",
);
- assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false);
-});
-
-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("uses launch options for Steam and non-Steam shortcuts without a Target API", 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[] = [];
+ const appWrites: string[] = [];
const shortcutWrites: 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" }
+ : { 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);
@@ -263,29 +96,25 @@ test("reads and writes the matching Steam app-details launch-option field", asyn
shortcutOptions = options;
},
};
-
- (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);
- 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 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);
+
+ const shortcut = await installWrapperIntegration(43, true, wrapper);
+ assert.equal(shortcut.snapshot.options, `~/.lsfg %command% --windowed`);
+ assert.deepEqual(shortcutWrites, [`~/.lsfg %command% --windowed`]);
+
+ const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.commandTokenAdded);
+ assert.equal(restored.options, "--windowed");
+
+ const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded);
+ assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " "));
assert.ok(unregisters.includes(42));
assert.ok(unregisters.includes(43));
} finally {
@@ -295,3 +124,82 @@ test("reads and writes the matching Steam app-details launch-option field", asyn
else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient;
}
});
+
+test("AppImage EmuDeck and direct Flatpak shortcuts all stay launch-option based", async () => {
+ const previousWindow = (globalThis as Record<string, unknown>).window;
+ const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
+ const cases = [
+ {
+ options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"',
+ expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"',
+ },
+ {
+ options: "",
+ expected: "~/.lsfg %command%",
+ },
+ {
+ options: "run org.example.Game",
+ expected: "~/.lsfg %command% run org.example.Game",
+ },
+ ];
+ (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
+ try {
+ for (const [index, item] of cases.entries()) {
+ let shortcutOptions = item.options;
+ const shortcutWrites: string[] = [];
+ (globalThis as Record<string, unknown>).SteamClient = {
+ Apps: {
+ RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) {
+ callback({ strShortcutLaunchOptions: shortcutOptions });
+ return { unregister() {} };
+ },
+ SetShortcutLaunchOptions(_appId: number, options: string) {
+ shortcutWrites.push(options);
+ shortcutOptions = options;
+ },
+ },
+ };
+ const installed = await installWrapperIntegration(100 + index, true, wrapper);
+ assert.equal(installed.snapshot.options, item.expected);
+ assert.deepEqual(shortcutWrites, [item.expected]);
+ const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded);
+ assert.equal(restored.options, item.options);
+ }
+ } 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("launch option write failure rolls back the original value", async () => {
+ const previousWindow = (globalThis as Record<string, unknown>).window;
+ const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
+ let appOptions = "FOO=bar %command%";
+ const writes: string[] = [];
+ (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
+ (globalThis as Record<string, unknown>).SteamClient = {
+ Apps: {
+ RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) {
+ callback({ strLaunchOptions: appOptions });
+ return { unregister() {} };
+ },
+ SetAppLaunchOptions(_appId: number, options: string) {
+ writes.push(options);
+ appOptions = options;
+ if (options.includes(wrapper)) throw new Error("simulated launch option failure");
+ },
+ },
+ };
+ try {
+ await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch option failure/);
+ assert.equal(appOptions, "FOO=bar %command%");
+ assert.deepEqual(writes, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]);
+ } 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_configuration_profiles.py b/tests/test_configuration_profiles.py
new file mode 100644
index 0000000..789842e
--- /dev/null
+++ b/tests/test_configuration_profiles.py
@@ -0,0 +1,80 @@
+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.config_schema import ConfigurationManager
+from lsfg_vk.configuration import ConfigurationService
+
+
+class ConfigurationProfileTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.runtime = Mock()
+ self.service = ConfigurationService(runtime_service=self.runtime)
+ self.service.user_home = self.home
+ self.service.config_dir = self.home / ".config/lsfg-vk"
+ self.service.config_file_path = self.service.config_dir / "conf.toml"
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ def test_selector_only_profile_survives_round_trip(self):
+ content = """version = 2
+
+[global]
+allow_fp16 = true
+
+[[profile]]
+name = "flatpak:org.example.Game"
+pacing_mode = "vsync"
+multiplier = 3
+flow_scale = 0.8
+performance_mode = false
+override_present_mode = true
+preserve_swapchain_image_count = false
+"""
+ parsed = ConfigurationManager.parse_toml_content_multi_profile(content)
+ self.assertIn("flatpak:org.example.Game", parsed["profiles"])
+ self.assertEqual(parsed["profiles"]["flatpak:org.example.Game"]["active_in"], [])
+ rendered = ConfigurationManager.generate_toml_content_multi_profile(parsed)
+ reparsed = ConfigurationManager.parse_toml_content_multi_profile(rendered)
+ self.assertEqual(reparsed["profiles"]["flatpak:org.example.Game"]["multiplier"], 3)
+
+ def test_game_reset_all_preserves_flatpak_profiles(self):
+ self.service.update_game_config("123", "Steam Game", {"multiplier": 2})
+ self.service.update_flatpak_config("org.example.Game", {"multiplier": 3})
+
+ result = self.service.reset_all_game_configs()
+ data = self.service._get_profile_data()
+
+ self.assertTrue(result["success"])
+ self.assertNotIn("Steam Game", data["profiles"])
+ self.assertIn("flatpak:org.example.Game", data["profiles"])
+ self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3)
+
+ def test_flatpak_reset_all_preserves_steam_profiles(self):
+ self.service.update_game_config("123", "Steam Game", {"multiplier": 2})
+ self.service.update_flatpak_config("org.example.Game", {"multiplier": 3})
+
+ result = self.service.reset_all_flatpak_configs()
+ data = self.service._get_profile_data()
+
+ self.assertTrue(result["success"])
+ self.assertIn("Steam Game", data["profiles"])
+ self.assertNotIn("flatpak:org.example.Game", data["profiles"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py
new file mode 100644
index 0000000..8d58413
--- /dev/null
+++ b/tests/test_flatpak_profile_service.py
@@ -0,0 +1,210 @@
+import hashlib
+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.configuration import ConfigurationService
+from lsfg_vk.flatpak_profile_service import FlatpakProfileService
+
+
+class FakeFlatpakService:
+ def __init__(self, home: Path):
+ self.user_home = home
+ self.config_dir = home / ".config/lsfg-vk"
+ self.config_file_path = self.config_dir / "conf.toml"
+ self.backup_dir = self.config_dir / "flatpak-overrides"
+ self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}}
+ self.commands = []
+ self.running = ""
+
+ def _read_state(self):
+ return self.state
+
+ def _write_state(self, state):
+ self.state = state
+
+ def _override_path(self, app_id):
+ return self.user_home / ".local/share/flatpak/overrides" / app_id
+
+ def _backup_path(self, app_id):
+ return self.backup_dir / f"{app_id}.ini"
+
+ @staticmethod
+ def _sha256(content):
+ return hashlib.sha256(content).hexdigest()
+
+ def _snapshot_override(self, app_id):
+ path = self._override_path(app_id)
+ if not path.exists():
+ return False, b""
+ return True, path.read_bytes()
+
+ @staticmethod
+ def _write_file(path, content, mode=0o644):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ path.chmod(mode)
+
+ def prepare_app(self, app_id):
+ apps = self.state["prepared_apps"]
+ if app_id not in apps:
+ existed, original = self._snapshot_override(app_id)
+ if existed:
+ self._write_file(self._backup_path(app_id), original.decode("utf-8"))
+ apps[app_id] = {"override_existed": existed, "managed_sha256": ""}
+ entry = apps[app_id]
+ baseline = ""
+ if entry["override_existed"]:
+ baseline = self._backup_path(app_id).read_text(encoding="utf-8")
+ managed = baseline + "\n[Context]\nfilesystems=/config:ro;/dll:ro;\n[Environment]\nLSFGVK_CONFIG=/config/conf.toml\nLSFGVK_FLATPAK=1\n"
+ self._write_file(self._override_path(app_id), managed)
+ entry["managed_sha256"] = self._sha256(managed.encode())
+ return {"success": True, "owned": True, "prepared": True, "runtime": "org.freedesktop.Platform/x86_64/24.08", "runtime_branch": "24.08"}
+
+ def remove_app_override(self, app_id):
+ entry = self.state["prepared_apps"].get(app_id)
+ if entry is None:
+ return {"success": True, "prepared": False, "owned": False}
+ existed, current = self._snapshot_override(app_id)
+ current_hash = self._sha256(current) if existed else self._sha256(b"")
+ if current_hash != entry["managed_sha256"]:
+ return {"success": False, "error": "Flatpak override changed after preparation"}
+ path = self._override_path(app_id)
+ backup = self._backup_path(app_id)
+ if entry["override_existed"]:
+ self._write_file(path, backup.read_text(encoding="utf-8"))
+ else:
+ path.unlink(missing_ok=True)
+ backup.unlink(missing_ok=True)
+ self.state["prepared_apps"].pop(app_id)
+ return {"success": True, "prepared": False, "owned": False}
+
+ def get_flatpak_apps(self):
+ app_id = "org.example.Game"
+ return {
+ "success": True,
+ "apps": [{
+ "app_id": app_id,
+ "app_name": "Example Game",
+ "runtime": "org.freedesktop.Platform/x86_64/24.08",
+ "runtime_branch": "24.08",
+ "runtime_ready": True,
+ "prepared": app_id in self.state["prepared_apps"],
+ "owned": app_id in self.state["prepared_apps"],
+ "error": None,
+ }],
+ }
+
+ def _run_flatpak_command(self, args, **_kwargs):
+ self.commands.append(args)
+ if args[:3] == ["override", "--user", "--show"]:
+ path = self._override_path(args[3])
+ return types.SimpleNamespace(returncode=0, stdout=path.read_text(encoding="utf-8") if path.exists() else "", stderr="")
+ if args[0] == "override":
+ app_id = args[-1]
+ path = self._override_path(app_id)
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
+ env = [item.removeprefix("--env=") for item in args if item.startswith("--env=")]
+ unset = [item.removeprefix("--unset-env=") for item in args if item.startswith("--unset-env=")]
+ if unset:
+ content += "\n[Context]\nunset-environment=" + ";".join(unset) + ";\n"
+ if env:
+ content += "\n[Environment]\n" + "\n".join(env) + "\n"
+ self._write_file(path, content)
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
+ if args[0] == "ps":
+ return types.SimpleNamespace(returncode=0, stdout=self.running, stderr="")
+ raise AssertionError(args)
+
+
+class FlatpakProfileServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.flatpak = FakeFlatpakService(self.home)
+ self.runtime = Mock()
+ self.configuration = ConfigurationService(runtime_service=self.runtime)
+ self.configuration.user_home = self.home
+ self.configuration.config_dir = self.flatpak.config_dir
+ self.configuration.config_file_path = self.flatpak.config_file_path
+ self.service = FlatpakProfileService(self.flatpak, self.configuration)
+ self.app_id = "org.example.Game"
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ def test_enable_creates_selector_profile_and_default_workarounds(self):
+ result = self.service.enable_app(self.app_id)
+ config = self.configuration.get_flatpak_config(self.app_id)
+ content = self.flatpak._override_path(self.app_id).read_text(encoding="utf-8")
+
+ self.assertTrue(result["success"])
+ self.assertTrue(config["exists"])
+ self.assertEqual(config["profile"], "flatpak:org.example.Game")
+ self.assertEqual(config["config"]["active_in"], [])
+ self.assertIn("LSFGVK_PROFILE=flatpak:org.example.Game", content)
+ self.assertIn("ENABLE_GAMESCOPE_WSI=0", content)
+ self.assertIn("DXVK_HDR=0", content)
+
+ def test_workaround_update_rebuilds_from_original_override(self):
+ baseline = "[Environment]\nDXVK_CONFIG=dxgi.syncInterval = 0\nKEEP=yes\n"
+ self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline)
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+
+ state = self.service.default_state()
+ state.update({"dxvkFrameRate": 30, "disableHdr": False, "enableZink": True})
+ result = self.service.set_workaround_state(self.app_id, state)
+ command = next(
+ args for args in reversed(self.flatpak.commands)
+ if args[0] == "override" and any(item.startswith("--env=LSFGVK_PROFILE=") for item in args)
+ )
+
+ self.assertTrue(result["success"])
+ self.assertIn("--env=DXVK_CONFIG=dxgi.syncInterval = 0; dxvk.maxFrameRate = 30", command)
+ self.assertNotIn("--env=DXVK_HDR=0", command)
+ self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command)
+
+ def test_remove_restores_exact_original_override_and_profile(self):
+ baseline = "[Environment]\nKEEP=yes\n"
+ self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline)
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+
+ removed = self.service.remove_app(self.app_id)
+
+ self.assertTrue(removed["success"])
+ self.assertEqual(self.flatpak._override_path(self.app_id).read_text(encoding="utf-8"), baseline)
+ self.assertFalse(self.configuration.get_flatpak_config(self.app_id)["exists"])
+
+ def test_external_override_change_fails_closed(self):
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+ path = self.flatpak._override_path(self.app_id)
+ path.write_text(path.read_text(encoding="utf-8") + "EXTERNAL=yes\n", encoding="utf-8")
+
+ result = self.service.set_workaround_state(self.app_id, self.service.default_state())
+
+ self.assertFalse(result["success"])
+ self.assertIn("changed after preparation", result["error"])
+
+ def test_running_detection_uses_owned_selector_state(self):
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+ self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n"
+
+ result = self.service.get_running_apps()
+
+ self.assertTrue(result["success"])
+ self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234"}])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py
new file mode 100644
index 0000000..4d804c7
--- /dev/null
+++ b/tests/test_flatpak_service.py
@@ -0,0 +1,314 @@
+import json
+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.flatpak_service import FlatpakService
+
+
+class FlatpakServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.service = FlatpakService()
+ self.service.user_home = self.home
+ self.service.config_dir = self.home / ".config/lsfg-vk"
+ self.service.config_file_path = self.service.config_dir / "conf.toml"
+ self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True)
+ self.service.check_flatpak_available = Mock(return_value=True)
+ self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command)
+ self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08"
+ self.runtime_metadata = ""
+ self.user_branches = set()
+ self.system_branches = set()
+ self.user_extension_origin = "flathub"
+ self.apps = {"com.example.Game": "Example Game"}
+ self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling"
+ self.service._dll_directory = Mock(return_value=self.dll_dir)
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ @staticmethod
+ def _result(stdout="", returncode=0, stderr=""):
+ return types.SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr)
+
+ @staticmethod
+ def _extension_line(branch):
+ return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n"
+
+ @staticmethod
+ def _parse_override(content):
+ section = None
+ filesystems = []
+ unset_environment = []
+ environment = {}
+ other = []
+ for raw in content.splitlines():
+ line = raw.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1]
+ continue
+ key, separator, value = line.partition("=")
+ if not separator:
+ continue
+ if section == "Context" and key == "filesystems":
+ filesystems.extend(item for item in value.split(";") if item)
+ elif section == "Context" and key == "unset-environment":
+ unset_environment.extend(item for item in value.split(";") if item)
+ elif section == "Environment":
+ environment[key] = value
+ else:
+ other.append((section, key, value))
+ return filesystems, unset_environment, environment, other
+
+ @staticmethod
+ def _serialize_override(filesystems, unset_environment, environment):
+ lines = ["[Context]"]
+ if filesystems:
+ lines.append("filesystems=" + ";".join(filesystems) + ";")
+ if unset_environment:
+ lines.append("unset-environment=" + ";".join(unset_environment) + ";")
+ if environment:
+ lines.append("")
+ lines.append("[Environment]")
+ lines.extend(f"{key}={value}" for key, value in environment.items())
+ return "\n".join(lines) + "\n"
+
+ def _apply_override(self, args):
+ app_id = args[-1]
+ path = self.service._override_path(app_id)
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
+ filesystems, unset_environment, environment, _ = self._parse_override(content)
+ for arg in args[2:-1]:
+ if arg.startswith("--filesystem="):
+ value = arg.split("=", 1)[1]
+ if value not in filesystems:
+ filesystems.append(value)
+ elif arg.startswith("--env="):
+ key, value = arg.split("=", 1)[1].split("=", 1)
+ environment[key] = value
+ if key in unset_environment:
+ unset_environment.remove(key)
+ elif arg.startswith("--unset-env="):
+ key = arg.split("=", 1)[1]
+ environment.pop(key, None)
+ if key not in unset_environment:
+ unset_environment.append(key)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ self._serialize_override(filesystems, unset_environment, environment),
+ encoding="utf-8",
+ )
+ return self._result()
+
+ def _run_flatpak_command(self, args, **_kwargs):
+ if args[:2] == ["info", "--show-runtime"]:
+ return self._result(self.runtime_ref + "\n")
+ if args[:2] == ["info", "--show-metadata"]:
+ return self._result(self.runtime_metadata)
+ if args[:3] == ["info", "--user", "--show-origin"]:
+ return self._result(self.user_extension_origin)
+ if args[:2] == ["list", "--app"]:
+ return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items()))
+ if args[0] == "list":
+ branches = self.user_branches if "--user" in args else self.system_branches
+ return self._result("".join(self._extension_line(branch) for branch in sorted(branches)))
+ if args[0] == "install":
+ self.user_branches.add(self.runtime_ref.rsplit("/", 1)[-1])
+ return self._result()
+ if args[0] == "uninstall":
+ self.user_branches.discard(args[-1].rsplit("/", 1)[-1])
+ return self._result()
+ if args[:3] == ["override", "--user", "--show"]:
+ path = self.service._override_path(args[-1])
+ return self._result(path.read_text(encoding="utf-8") if path.exists() else "")
+ if args[:2] == ["override", "--user"]:
+ return self._apply_override(args)
+ raise AssertionError(f"Unexpected Flatpak command: {args}")
+
+ def test_resolves_freedesktop_and_derived_runtimes(self):
+ runtime, branch = self.service._resolve_runtime("com.example.Game")
+ self.assertEqual(runtime, self.runtime_ref)
+ self.assertEqual(branch, "24.08")
+
+ self.runtime_ref = "org.kde.Platform/x86_64/6.10"
+ self.runtime_metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n"
+ runtime, branch = self.service._resolve_runtime("com.example.Game")
+ self.assertEqual(runtime, self.runtime_ref)
+ self.assertEqual(branch, "25.08")
+
+ def test_prepare_app_installs_runtime_and_persists_narrow_override(self):
+ response = self.service.prepare_app("com.example.Game")
+
+ self.assertTrue(response["success"])
+ self.assertTrue(response["prepared"])
+ self.assertTrue(response["owned"])
+ self.assertEqual(response["runtime_branch"], "24.08")
+ self.assertEqual(self.user_branches, {"24.08"})
+ install_calls = [
+ call.args[0]
+ for call in self.service._run_flatpak_command.call_args_list
+ if call.args[0][0] == "install"
+ ]
+ self.assertEqual(
+ install_calls,
+ [[
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ "flathub",
+ "org.freedesktop.Platform.VulkanLayer.lsfgvk//24.08",
+ ]],
+ )
+ status = self.service._app_override_status("com.example.Game")
+ self.assertTrue(status["prepared"])
+ content = self.service._override_path("com.example.Game").read_text(encoding="utf-8")
+ self.assertIn(str(self.service.config_dir) + ":ro", content)
+ self.assertIn(str(self.dll_dir) + ":ro", content)
+ self.assertIn("LSFGVK_CONFIG=" + str(self.service.config_file_path), content)
+ self.assertIn("LSFGVK_FLATPAK=1", content)
+ self.assertNotIn("ENABLE_GAMESCOPE_WSI", content)
+ state = json.loads(self.service.ownership_path.read_text(encoding="utf-8"))
+ self.assertEqual(state["plugin_owned_branches"], ["24.08"])
+ self.assertIn("com.example.Game", state["prepared_apps"])
+
+ def test_prepare_is_idempotent(self):
+ first = self.service.prepare_app("com.example.Game")
+ first_content = self.service._override_path("com.example.Game").read_bytes()
+ second = self.service.prepare_app("com.example.Game")
+
+ self.assertTrue(first["success"])
+ self.assertTrue(second["success"])
+ self.assertEqual(first_content, self.service._override_path("com.example.Game").read_bytes())
+ install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"]
+ self.assertEqual(len(install_calls), 1)
+
+ def test_replaces_extension_from_another_remote(self):
+ self.user_branches = {"24.08"}
+ self.user_extension_origin = "lsfgvk-origin"
+
+ response = self.service.install_extension("24.08")
+
+ self.assertTrue(response["success"])
+ commands = [call.args[0] for call in self.service._run_flatpak_command.call_args_list]
+ self.assertIn(
+ [
+ "uninstall",
+ "--user",
+ "--noninteractive",
+ "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08",
+ ],
+ commands,
+ )
+ self.assertEqual(self.user_branches, {"24.08"})
+
+ def test_preinstalled_runtime_is_not_owned(self):
+ self.system_branches = {"24.08"}
+ response = self.service.prepare_app("com.example.Game")
+
+ self.assertTrue(response["success"])
+ state = json.loads(self.service.ownership_path.read_text(encoding="utf-8"))
+ self.assertEqual(state["plugin_owned_branches"], [])
+ self.assertIn("com.example.Game", state["prepared_apps"])
+
+ def test_external_preparation_is_preserved(self):
+ path = self.service._override_path("com.example.Game")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ self._serialize_override(
+ [str(self.service.config_dir) + ":ro", str(self.dll_dir) + ":ro"],
+ ["DISABLE_LSFGVK", "DISABLE_LSFG"],
+ {
+ "LSFGVK_CONFIG": str(self.service.config_file_path),
+ "LSFGVK_FLATPAK": "1",
+ },
+ ),
+ encoding="utf-8",
+ )
+ self.system_branches = {"24.08"}
+
+ response = self.service.prepare_app("com.example.Game")
+
+ self.assertTrue(response["success"])
+ self.assertTrue(response["prepared"])
+ self.assertFalse(response["owned"])
+ self.assertFalse(self.service.ownership_path.exists())
+
+ def test_remove_restores_exact_previous_override(self):
+ self.system_branches = {"24.08"}
+ original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n"
+ path = self.service._override_path("com.example.Game")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(original, encoding="utf-8")
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+
+ response = self.service.remove_app_override("com.example.Game")
+
+ self.assertTrue(response["success"])
+ self.assertEqual(path.read_text(encoding="utf-8"), original)
+ self.assertFalse(self.service.ownership_path.exists())
+
+ def test_remove_deletes_override_created_by_plugin(self):
+ self.system_branches = {"24.08"}
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+ path = self.service._override_path("com.example.Game")
+ self.assertTrue(path.exists())
+
+ response = self.service.remove_app_override("com.example.Game")
+
+ self.assertTrue(response["success"])
+ self.assertFalse(path.exists())
+ self.assertFalse(self.service.ownership_path.exists())
+
+ def test_remove_fails_closed_after_external_change(self):
+ self.system_branches = {"24.08"}
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+ path = self.service._override_path("com.example.Game")
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write("EXTERNAL=1\n")
+
+ response = self.service.remove_app_override("com.example.Game")
+
+ self.assertFalse(response["success"])
+ self.assertIn("changed after preparation", response["error"])
+ self.assertTrue(path.exists())
+ self.assertTrue(self.service.ownership_path.exists())
+
+ def test_full_cleanup_removes_only_owned_state(self):
+ self.system_branches = {"23.08"}
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+ self.assertEqual(self.user_branches, {"24.08"})
+
+ response = self.service.remove_plugin_owned_environment()
+
+ self.assertTrue(response["success"])
+ self.assertEqual(response["removed_apps"], ["com.example.Game"])
+ self.assertEqual(response["removed_branches"], ["24.08"])
+ self.assertEqual(self.user_branches, set())
+ self.assertEqual(self.system_branches, {"23.08"})
+ self.assertFalse(self.service.ownership_path.exists())
+
+ def test_corrupt_ownership_metadata_fails_closed(self):
+ self.service.ownership_path.write_text("{not-json", encoding="utf-8")
+
+ response = self.service.remove_plugin_owned_environment()
+
+ self.assertFalse(response["success"])
+ self.assertEqual(self.service._run_flatpak_command.call_count, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py
index a392f85..7ab4621 100644
--- a/tests/test_plugin_migration.py
+++ b/tests/test_plugin_migration.py
@@ -6,7 +6,7 @@ from unittest.mock import Mock
class PluginMigrationTests(unittest.TestCase):
- def test_migration_only_runs_decky_path_migrations(self):
+ def _load_plugin(self):
decky = types.SimpleNamespace(
DECKY_HOME="/decky",
DECKY_USER_HOME="/home/deck",
@@ -17,12 +17,31 @@ class PluginMigrationTests(unittest.TestCase):
)
previous_decky = sys.modules.get("decky")
previous_tomllib = sys.modules.get("tomllib")
+ previous_plugin = sys.modules.pop("lsfg_vk.plugin", None)
sys.modules["decky"] = decky
sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock())
- try:
- sys.path.insert(0, "py_modules")
- from lsfg_vk.plugin import Plugin
+ sys.path.insert(0, "py_modules")
+ from lsfg_vk.plugin import Plugin
+ return Plugin, decky, previous_decky, previous_tomllib, previous_plugin
+
+ def _restore(self, previous_decky, previous_tomllib, previous_plugin):
+ sys.path.remove("py_modules")
+ if previous_decky is None:
+ sys.modules.pop("decky", None)
+ else:
+ sys.modules["decky"] = previous_decky
+ if previous_tomllib is None:
+ sys.modules.pop("tomllib", None)
+ else:
+ sys.modules["tomllib"] = previous_tomllib
+ if previous_plugin is None:
+ sys.modules.pop("lsfg_vk.plugin", None)
+ else:
+ sys.modules["lsfg_vk.plugin"] = previous_plugin
+ def test_migration_only_runs_decky_path_migrations(self):
+ Plugin, decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin()
+ try:
plugin = Plugin.__new__(Plugin)
plugin.installation_service = Mock()
plugin.flatpak_service = Mock()
@@ -33,17 +52,42 @@ class PluginMigrationTests(unittest.TestCase):
decky.migrate_settings.assert_called_once()
decky.migrate_runtime.assert_called_once()
plugin.installation_service.install.assert_not_called()
- plugin.flatpak_service.migrate_v2.assert_not_called()
+ plugin.flatpak_service.prepare_app.assert_not_called()
+ finally:
+ self._restore(previous_decky, previous_tomllib, previous_plugin)
+
+ def test_uninstall_cleans_owned_flatpak_state_and_profiles(self):
+ Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin()
+ try:
+ plugin = Plugin.__new__(Plugin)
+ plugin.installation_service = Mock()
+ plugin.flatpak_service = Mock()
+ plugin.configuration_service = Mock()
+ plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True}
+
+ asyncio.run(plugin._uninstall())
+
+ plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with()
+ plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with()
+ plugin.installation_service.cleanup_on_uninstall.assert_called_once_with()
+ finally:
+ self._restore(previous_decky, previous_tomllib, previous_plugin)
+
+ def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self):
+ Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin()
+ try:
+ plugin = Plugin.__new__(Plugin)
+ plugin.installation_service = Mock()
+ plugin.flatpak_service = Mock()
+ plugin.configuration_service = Mock()
+ plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"}
+
+ asyncio.run(plugin._uninstall())
+
+ plugin.configuration_service.reset_all_flatpak_configs.assert_not_called()
+ plugin.installation_service.cleanup_on_uninstall.assert_called_once_with()
finally:
- sys.path.remove("py_modules")
- if previous_decky is None:
- sys.modules.pop("decky", None)
- else:
- sys.modules["decky"] = previous_decky
- if previous_tomllib is None:
- sys.modules.pop("tomllib", None)
- else:
- sys.modules["tomllib"] = previous_tomllib
+ self._restore(previous_decky, previous_tomllib, previous_plugin)
if __name__ == "__main__":
diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py
new file mode 100644
index 0000000..004ffb6
--- /dev/null
+++ b/tests/test_steam_service.py
@@ -0,0 +1,58 @@
+import sys
+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.steam_service import SteamService
+
+
+class SteamShortcutTests(unittest.TestCase):
+ def test_direct_flatpak_shortcut_is_ordinary_non_steam_metadata(self):
+ game = SteamService._shortcut_game(
+ {
+ "appid": 123456,
+ "AppName": "PCSX2 shortcut",
+ "Exe": "/usr/bin/flatpak",
+ "LaunchOptions": "run net.pcsx2.PCSX2 --fullscreen",
+ "StartDir": "/home/deck/Games",
+ }
+ )
+
+ self.assertEqual(game, {
+ "appid": "123456",
+ "name": "PCSX2 shortcut",
+ "nonSteam": True,
+ })
+
+ def test_emudeck_launcher_is_ordinary_non_steam_metadata(self):
+ game = SteamService._shortcut_game(
+ {
+ "appid": 987654,
+ "AppName": "1080 Snowboarding",
+ "Exe": '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64',
+ "LaunchOptions": "",
+ }
+ )
+
+ self.assertEqual(game, {
+ "appid": "987654",
+ "name": "1080 Snowboarding",
+ "nonSteam": True,
+ })
+
+ def test_shortcut_rejects_invalid_identity(self):
+ self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"}))
+ self.assertIsNone(SteamService._shortcut_game({"appid": 1, "AppName": ""}))
+ self.assertIsNone(SteamService._shortcut_game("bad"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py
new file mode 100644
index 0000000..5c291c7
--- /dev/null
+++ b/tests/test_wrapper_service.py
@@ -0,0 +1,163 @@
+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.config_dir = self.home / ".config/lsfg-vk"
+ self.service.config_file_path = self.service.config_dir / "conf.toml"
+ self.service.sidecar_path = self.service.config_dir / "workarounds.json"
+ self.service.wrapper_path = self.home / ".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_exports_appid_config_and_workarounds(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_LSFGVK": "1",
+ "DISABLE_LSFG": "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["SteamAppId"], "123")
+ self.assertEqual(values["LSFGVK_CONFIG"], str(self.service.config_file_path))
+ 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)
+ self.assertNotIn("DISABLE_LSFGVK", values)
+ self.assertNotIn("DISABLE_LSFG", values)
+
+ def test_wrapper_is_transport_agnostic(self):
+ self.service.set("123", self._state())
+ fake = self.home / "target"
+ fake.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n", encoding="utf-8")
+ fake.chmod(0o755)
+ result = self._run(123, str(fake), "run", "org.example.Game")
+ self.assertEqual(result.stdout.splitlines(), ["run", "org.example.Game"])
+ content = self.service.wrapper_path.read_text(encoding="utf-8")
+ self.assertNotIn("flatpakAppId", content)
+ self.assertNotIn("shortcut_exe", content)
+ self.assertNotIn("--filesystem", content)
+
+ 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["SteamAppId"], "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_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.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_safe_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()