summaryrefslogtreecommitdiff
path: root/py_modules/lsfg_vk
diff options
context:
space:
mode:
Diffstat (limited to 'py_modules/lsfg_vk')
-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
10 files changed, 1650 insertions, 679 deletions
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,
+ }