summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--assets/flatpak-target.pngbin78195 -> 0 bytes
-rw-r--r--justfile10
-rw-r--r--package.json15
-rw-r--r--py_modules/lsfg_vk/config_schema.py2
-rw-r--r--py_modules/lsfg_vk/configuration.py76
-rw-r--r--py_modules/lsfg_vk/constants.py3
-rw-r--r--py_modules/lsfg_vk/flatpak_profile_service.py381
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py589
-rw-r--r--py_modules/lsfg_vk/plugin.py112
-rw-r--r--py_modules/lsfg_vk/steam_service.py59
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py225
-rwxr-xr-xscripts/deploy-to-deck.sh58
-rw-r--r--src/api/lsfgApi.ts85
-rw-r--r--src/components/ConfigFileTab.tsx119
-rw-r--r--src/components/ConfigurationTab.tsx102
-rw-r--r--src/components/Content.tsx123
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx39
-rw-r--r--src/components/FlatpakTab.tsx178
-rw-r--r--src/components/FlatpakWorkaroundsSection.tsx144
-rw-r--r--src/components/GameConfigurationControls.tsx3
-rw-r--r--src/components/GameConfigurationSelector.tsx7
-rw-r--r--src/components/NowPlayingTab.tsx36
-rw-r--r--src/components/WorkaroundsSection.tsx10
-rw-r--r--src/hooks/useFlatpakConfiguration.ts108
-rw-r--r--src/hooks/useGameConfiguration.ts91
-rw-r--r--src/hooks/usePerAppWorkarounds.ts47
-rw-r--r--src/types.d.ts3
-rw-r--r--src/utils/steamLaunchOptions.ts122
-rw-r--r--tests/steamLaunchOptions.test.ts168
-rw-r--r--tests/test_configuration_profiles.py80
-rw-r--r--tests/test_flatpak_profile_service.py210
-rw-r--r--tests/test_flatpak_service.py419
-rw-r--r--tests/test_plugin_migration.py72
-rw-r--r--tests/test_steam_service.py99
-rw-r--r--tests/test_wrapper_service.py149
35 files changed, 2503 insertions, 1441 deletions
diff --git a/assets/flatpak-target.png b/assets/flatpak-target.png
deleted file mode 100644
index 773e567..0000000
--- a/assets/flatpak-target.png
+++ /dev/null
Binary files differ
diff --git a/justfile b/justfile
index b190631..b608987 100644
--- a/justfile
+++ b/justfile
@@ -1,11 +1,15 @@
default:
- echo "Available recipes: build, test, clean"
+ echo "Available recipes: build, deploy, test, clean"
build:
- .vscode/build.sh
+ pnpm build
+
+deploy:
+ ./scripts/deploy-to-deck.sh
test:
- scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop
+ node --experimental-strip-types --test tests/steamLaunchOptions.test.ts
+ python3.12 -m unittest discover -s tests -p 'test_*.py'
watch:
ssh deck@192.168.0.6 "journalctl -f"
diff --git a/package.json b/package.json
index b5e716c..4a1780a 100644
--- a/package.json
+++ b/package.json
@@ -52,21 +52,6 @@
"name": "lsfg-vk-2.0.0.tar.xz",
"url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz",
"sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74"
- },
- {
- "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak",
- "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak",
- "sha256hash": "7eff81f96b278fde6fe395c88461c55e611547bf5991d179b9145ec6f5a778b1"
- },
- {
- "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak",
- "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak",
- "sha256hash": "cbd663b9021c355dddec61ec51fd4388c12705f16637cadd4b45aead0e5dc427"
- },
- {
- "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak",
- "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak",
- "sha256hash": "615e87c03b18368ed1cca97e036f1bf54fff95fc6126aac697bb368f33281cbf"
}
],
"pnpm": {
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index 3816d88..bf3e174 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -113,6 +113,6 @@ class ConfigurationManager:
**profile,
**global_config,
})
- if config["active_in"]:
+ if name or config["active_in"]:
profiles[name] = config
return {"profiles": profiles, "global_config": global_config}
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 960d230..45ac4c5 100644
--- a/py_modules/lsfg_vk/constants.py
+++ b/py_modules/lsfg_vk/constants.py
@@ -16,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 f486b74..4f04382 100644
--- a/py_modules/lsfg_vk/flatpak_service.py
+++ b/py_modules/lsfg_vk/flatpak_service.py
@@ -1,7 +1,6 @@
-"""Flatpak runtime support for classified Steam targets."""
-
from __future__ import annotations
+import hashlib
import json
import os
import pwd
@@ -13,21 +12,16 @@ from pathlib import Path
from typing import Dict, Optional, Set
from .base_service import BaseService
-from .constants import (
- BIN_DIR,
- FLATPAK_23_08_FILENAME,
- FLATPAK_24_08_FILENAME,
- FLATPAK_25_08_FILENAME,
-)
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_extensions.json"
- OWNERSHIP_VERSION = 1
+ 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-]*)+$"
)
@@ -35,12 +29,17 @@ class FlatpakService(BaseService):
def __init__(self, logger=None):
super().__init__(logger)
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 _clean_env(self) -> Dict[str, str]:
env = os.environ.copy()
env.pop("LD_LIBRARY_PATH", None)
@@ -89,13 +88,6 @@ class FlatpakService(BaseService):
return branch
@classmethod
- def runtime_branch_from_ref(cls, runtime_ref: str) -> str:
- parts = runtime_ref.strip().split("/") if isinstance(runtime_ref, str) else []
- if len(parts) != 3 or parts[0] != "org.freedesktop.Platform":
- raise ValueError(f"Unsupported Flatpak runtime reference: {runtime_ref}")
- return cls._validate_runtime(parts[2])
-
- @classmethod
def runtime_branch_from_metadata(cls, metadata: str) -> str:
section = None
versions = []
@@ -119,18 +111,8 @@ class FlatpakService(BaseService):
def _extension_ref(cls, branch: str) -> str:
return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}"
- def _bundled_extension_path(self, branch: str) -> Path:
- filename = {
- "23.08": FLATPAK_23_08_FILENAME,
- "24.08": FLATPAK_24_08_FILENAME,
- "25.08": FLATPAK_25_08_FILENAME,
- }[self._validate_runtime(branch)]
- return Path(__file__).resolve().parent.parent.parent / BIN_DIR / filename
-
def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]:
scopes = ("user", "system") if scope is None else (scope,)
- if any(item not in ("user", "system") for item in scopes):
- raise ValueError("Flatpak installation scope must be user or system")
installed = set()
for item in scopes:
result = self._run_flatpak_command(
@@ -145,64 +127,86 @@ class FlatpakService(BaseService):
installed.add(fields[2])
return installed
- def _owned_branches(self) -> Set[str]:
- path = self.ownership_path
- if not path.exists() and not path.is_symlink():
- return set()
- if path.is_symlink() or not path.is_file():
+ 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(path.read_text(encoding="utf-8"))
- branches = data.get("plugin_owned_branches")
- if data.get("version") != self.OWNERSHIP_VERSION or not isinstance(branches, list):
- raise ValueError("invalid ownership metadata")
- owned = {self._validate_runtime(branch) for branch in branches}
- if len(owned) != len(branches):
- raise ValueError("invalid ownership metadata")
- return owned
- except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
- raise RuntimeError(f"Could not trust Flatpak ownership metadata: {error}") from error
-
- def _write_owned_branches(self, branches: Set[str]) -> None:
- if not branches:
+ 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(
- {
- "version": self.OWNERSHIP_VERSION,
- "plugin_owned_branches": sorted(branches),
- },
- indent=2,
- ) + "\n",
+ json.dumps(state, indent=2, sort_keys=True) + "\n",
)
- def get_extension_status(self):
- try:
- available = self.check_flatpak_available()
- installed = self._installed_extension_branches() if available else set()
- return self._success_response(
- 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(
- dict,
- str(error),
- available=False,
- extension_id=self.EXTENSION_ID,
- supported_branches=list(self.SUPPORTED_RUNTIMES),
- installed_branches=[],
- )
+ 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"]}
- get_flatpak_support_status = get_extension_status
+ def _override_path(self, app_id: str) -> Path:
+ return self.user_home / ".local/share/flatpak/overrides" / self._validate_app_id(app_id)
- def _resolve_runtime(self, app_id: str):
+ 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")
@@ -218,77 +222,146 @@ class FlatpakService(BaseService):
if len(parts) != 3:
raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}")
if parts[0] == "org.freedesktop.Platform":
- branch = self._validate_runtime(parts[2])
- elif parts[0] in self.DERIVED_RUNTIME_IDS:
- 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}"
- )
- branch = self.runtime_branch_from_metadata(metadata_result.stdout)
- else:
+ return runtime, self._validate_runtime(parts[2])
+ if parts[0] not in self.DERIVED_RUNTIME_IDS:
raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}")
- return runtime, branch
+ 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 resolve_app_support(self, app_id: str):
+ def _filesystem_present(self, entries: str, host_path: Path) -> bool:
+ accepted = {str(host_path)}
try:
- app_id = self._validate_app_id(app_id)
- runtime, branch = self._resolve_runtime(app_id)
- installed = self._installed_extension_branches()
- ready = branch in installed
+ 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(
dict,
- f"lsfg-vk support is ready for {app_id}" if ready
- else f"lsfg-vk runtime extension {branch} is required for {app_id}",
- flatpak_app_id=app_id,
- runtime=runtime,
- runtime_branch=branch,
- support_status="ready" if ready else "needs-runtime",
- extension_installed=ready,
+ "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 ValueError as error:
- return self._success_response(
- dict,
- str(error),
- flatpak_app_id=app_id,
- runtime=None,
- runtime_branch=None,
- support_status="unsupported",
- extension_installed=False,
- installed_branches=[],
- error=str(error),
- )
except Exception as error:
return self._error_response(
dict,
str(error),
- flatpak_app_id=app_id,
- runtime=None,
- runtime_branch=None,
- support_status="error",
- extension_installed=False,
+ available=False,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
installed_branches=[],
)
+ get_flatpak_support_status = get_extension_status
+
def install_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:
- if branch in self._installed_extension_branches():
- return self._extension_result(branch, True, False, "already installed")
- bundle = self._bundled_extension_path(branch)
- if not bundle.is_file():
- raise FileNotFoundError(f"Bundled Flatpak extension not found at {bundle}; reinstall the plugin")
+ 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", str(bundle)],
+ [
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ self.FLATHUB_REMOTE,
+ f"{self.EXTENSION_ID}//{branch}",
+ ],
capture_output=True,
text=True,
)
@@ -296,9 +369,12 @@ class FlatpakService(BaseService):
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")
- owned = self._owned_branches()
+ state = self._read_state()
+ owned = self._owned_branches(state)
owned.add(branch)
- self._write_owned_branches(owned)
+ 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(dict, str(error), runtime_branch=branch, installed=False, enabled=False)
@@ -313,8 +389,6 @@ class FlatpakService(BaseService):
)
if result.returncode != 0:
raise OSError(result.stderr.strip() or "Flatpak uninstall failed")
- if branch in self._installed_extension_branches("user"):
- raise RuntimeError(f"Flatpak uninstall completed but {self._extension_ref(branch)} is still installed")
return True
def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str):
@@ -333,99 +407,226 @@ class FlatpakService(BaseService):
if not self.check_flatpak_available():
raise FileNotFoundError("Flatpak is not available on this system")
with self._lock:
- owned = self._owned_branches()
+ 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)
- self._write_owned_branches(owned)
+ 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,
- )
+ return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False)
def ensure_extension(self, branch: str):
- try:
- branch = self._validate_runtime(branch)
- if branch in self._installed_extension_branches():
- return self._extension_result(branch, True, False, "is ready")
- except Exception as error:
- return self._error_response(dict, str(error), runtime_branch=branch, support_status="error")
return self.install_extension(branch)
- def ensure_app_support(self, app_id: str):
- resolved = self.resolve_app_support(app_id)
- if not resolved.get("success") or resolved.get("support_status") != "needs-runtime":
- return resolved
- result = self.ensure_extension(resolved["runtime_branch"])
- if not result.get("success"):
- return self._error_response(
- dict,
- result.get("error") or "Could not install the required Flatpak runtime extension",
- flatpak_app_id=app_id,
- runtime=resolved.get("runtime"),
- runtime_branch=resolved.get("runtime_branch"),
- support_status="error",
- extension_installed=False,
- )
- return self.resolve_app_support(app_id)
-
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 remove_plugin_owned_extensions(self):
+ 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,
+ text=True,
+ check=True,
+ )
+ apps = []
+ for line in result.stdout.splitlines():
+ fields = line.split("\t")
+ if len(fields) < 2:
+ continue
+ name, app_id = fields[0].strip(), fields[1].strip()
+ if not app_id:
+ continue
+ 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,
+ )
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=False)
+
+ def remove_app_override(self, app_id: str):
+ try:
+ app_id = self._validate_app_id(app_id)
with self._lock:
- owned = self._owned_branches()
- if not owned:
+ state = self._read_state()
+ apps = state["prepared_apps"]
+ entry = apps.get(app_id)
+ if entry is None:
return self._success_response(
dict,
- "No plugin-owned Flatpak extensions to remove",
+ "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(dict, str(error), app_id=app_id, prepared=False, owned=True)
+
+ def remove_plugin_owned_environment(self):
+ try:
+ 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=[],
- preserved_branches=[],
- ownership_uncertain=False,
)
- if not self.check_flatpak_available():
- raise RuntimeError("Flatpak is not available; plugin-owned extension metadata was preserved")
- removed, failures = [], []
- for branch in sorted(owned):
- try:
- self._remove_extension(branch)
- removed.append(branch)
- except Exception as error:
- failures.append(f"{branch}: {error}")
- remaining = owned - set(removed)
- self._write_owned_branches(remaining)
+ 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_branches=removed,
- preserved_branches=sorted(remaining),
- ownership_uncertain=False,
+ removed_apps=removed_apps,
+ removed_branches=removed_branches,
)
return self._success_response(
dict,
- "Plugin-owned Flatpak extensions removed",
- removed_branches=removed,
- preserved_branches=[],
- ownership_uncertain=False,
+ "Plugin-owned Flatpak state removed",
+ removed_apps=removed_apps,
+ removed_branches=removed_branches,
)
except Exception as error:
- return self._error_response(
- dict,
- str(error),
- removed_branches=[],
- preserved_branches=[],
- ownership_uncertain=True,
- )
+ return self._error_response(dict, str(error), removed_apps=[], removed_branches=[])
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index 071b19b..d20fabf 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -1,9 +1,10 @@
import os
-from typing import Any, Dict, Optional
+from typing import Any, Dict
import decky
from .configuration import ConfigurationService
+from .flatpak_profile_service import FlatpakProfileService
from .flatpak_service import FlatpakService
from .installation import InstallationService
from .runtime_service import RuntimeService
@@ -21,6 +22,10 @@ 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):
@@ -30,27 +35,22 @@ class Plugin:
return self.installation_service.check_installation()
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):
return self.configuration_service.get_game_configs()
async def get_installed_games(self):
- result = self.steam_service.get_installed_games()
- if not result.get("success"):
- return result
- cache: Dict[str, Dict[str, Any]] = {}
- for game in result.get("games", []):
- transport = game.get("transport", {})
- if transport.get("kind") != "flatpak":
- continue
- app_id = transport.get("flatpakAppId")
- if not app_id:
- continue
- if app_id not in cache:
- cache[app_id] = self.flatpak_service.resolve_app_support(app_id)
- game["flatpakSupport"] = cache[app_id]
- return result
+ return self.steam_service.get_installed_games()
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)
@@ -68,17 +68,9 @@ class Plugin:
self,
appid: str,
state: Dict[str, Any],
- shortcut_exe: Optional[str] = None,
command_token_added: bool = False,
- transport: Optional[Dict[str, Any]] = None,
):
- return self.wrapper_service.set(
- appid,
- state,
- shortcut_exe,
- command_token_added,
- transport,
- )
+ return self.wrapper_service.set(appid, state, command_token_added)
async def remove_workaround_state(self, appid: str):
return self.wrapper_service.remove(appid)
@@ -107,20 +99,66 @@ class Plugin:
"error": f"Error reading config file: {error}",
}
+ 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 get_flatpak_support_status(self):
- return self.flatpak_service.get_flatpak_support_status()
+ 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 ensure_flatpak_support(self, flatpak_app_id: str):
- return self.flatpak_service.ensure_app_support(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 repair_flatpak_support(self, flatpak_app_id: str):
- return self.flatpak_service.ensure_app_support(flatpak_app_id)
+ 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_extension_enabled(self, version: str, enabled: bool):
- return self.flatpak_service.set_extension_enabled(version, enabled)
+ 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):
repair = self.wrapper_service.repair()
@@ -133,13 +171,15 @@ class Plugin:
async def _uninstall(self):
decky.logger.info("decky-lsfg-vk plugin being uninstalled")
- self.installation_service.cleanup_on_uninstall()
try:
- result = self.flatpak_service.remove_plugin_owned_extensions()
- if not result.get("success"):
+ 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):
diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py
index a952fcb..2108071 100644
--- a/py_modules/lsfg_vk/steam_service.py
+++ b/py_modules/lsfg_vk/steam_service.py
@@ -1,5 +1,4 @@
import re
-import shlex
from pathlib import Path
from typing import Dict, Optional, Tuple
@@ -7,55 +6,8 @@ from .base_service import BaseService
from .constants import (
STEAM_LOSSLESS_SCALING_APP_ID,
STEAM_LOSSLESS_SCALING_BRANCH,
- WRAPPER_FILENAME,
)
-_FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$")
-_WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}"
-
-
-def _split_command(value: Optional[str]) -> Optional[list[str]]:
- if not isinstance(value, str) or not value.strip():
- return []
- try:
- return shlex.split(value, posix=True)
- except ValueError:
- return None
-
-
-def _is_managed_wrapper(value: str) -> bool:
- if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}:
- return True
- path = Path(value)
- return path.is_absolute() and path.name == WRAPPER_FILENAME
-
-
-def classify_shortcut_transport(executable: Optional[str], launch_options: Optional[str] = None) -> Dict[str, object]:
- executable_tokens = _split_command(executable)
- option_tokens = _split_command(launch_options)
- if executable_tokens is None or option_tokens is None or not executable_tokens:
- return {"kind": "host"}
- direct_flatpak = executable_tokens[0] in {"flatpak", "/usr/bin/flatpak"}
- managed_wrapper = len(executable_tokens) == 1 and _is_managed_wrapper(executable_tokens[0])
- if not direct_flatpak and not managed_wrapper:
- return {"kind": "host"}
- arguments = [*executable_tokens[1:], *option_tokens]
- if not arguments or arguments[0] != "run":
- return {"kind": "host"}
- for argument in arguments[1:]:
- if argument == "--" or argument.startswith("-"):
- continue
- return (
- {"kind": "flatpak", "flatpakAppId": argument}
- if _FLATPAK_APP_ID.fullmatch(argument)
- else {"kind": "host"}
- )
- return {"kind": "host"}
-
-
-def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]:
- return next((values[key] for key in keys if isinstance(values.get(key), str)), None)
-
class SteamService(BaseService):
DEFAULT_BRANCH = "public"
@@ -147,19 +99,11 @@ 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
- executable = _first_string(shortcut, "Exe", "exe", "executable")
- arguments = _first_string(shortcut, "LaunchOptions", "launchoptions", "launch_options", "arguments")
- start_dir = _first_string(shortcut, "StartDir", "startdir", "start_dir")
- game: Dict[str, object] = {
+ return {
"appid": str(appid & 0xFFFFFFFF),
"name": name,
"nonSteam": True,
- "transport": classify_shortcut_transport(executable, arguments),
}
- for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)):
- if value is not None:
- game[key] = value
- return game
def _shortcut_games(self):
games = {}
@@ -309,7 +253,6 @@ class SteamService(BaseService):
"appid": appid,
"name": self._section_value(content, "AppState", "name") or f"App {appid}",
"nonSteam": False,
- "transport": {"kind": "host"},
}
for game in self._shortcut_games():
games.setdefault(str(game["appid"]), game)
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
index 1ed39bc..ebe9526 100644
--- a/py_modules/lsfg_vk/wrapper_service.py
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -1,12 +1,9 @@
-"""Own the small per-AppID workaround dispatcher used by Steam launches."""
-
from __future__ import annotations
import json
import re
import shlex
import threading
-from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from .base_service import BaseService
@@ -14,8 +11,6 @@ from .constants import WRAPPER_FILENAME
class WrapperService(BaseService):
- """Persist workaround state and compile it into a safe POSIX wrapper."""
-
LEGACY_FORMAT_VERSION = 1
FORMAT_VERSION = 2
LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1"
@@ -35,6 +30,8 @@ class WrapperService(BaseService):
"DISABLE_GAMESCOPE_WSI",
"DXVK_HDR",
"SteamDeck",
+ "DISABLE_LSFGVK",
+ "DISABLE_LSFG",
"DISABLE_VKBASALT",
"ENABLE_VKBASALT",
"MESA_LOADER_DRIVER_OVERRIDE",
@@ -84,51 +81,15 @@ class WrapperService(BaseService):
return state
@classmethod
- def _validate_transport(cls, raw: Any) -> Dict[str, Any]:
- if raw is None:
- return {"kind": "host"}
- if not isinstance(raw, dict):
- raise ValueError("Workaround transport must be an object")
- kind = raw.get("kind")
- if kind == "host":
- return {"kind": "host"}
- if kind == "flatpak":
- app_id = raw.get("flatpakAppId")
- if (
- not isinstance(app_id, str)
- or not re.fullmatch(
- r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$",
- app_id,
- )
- ):
- raise ValueError("Flatpak transport requires a valid application ID")
- return {"kind": "flatpak", "flatpakAppId": app_id}
- raise ValueError("Workaround transport must be host or flatpak")
-
- @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),
- # Version 1 entries had no transport field. They are preserved as
- # host entries until the shortcut is explicitly repaired with the
- # backend's classified transport.
- "transport": cls._validate_transport(raw.get("transport")),
}
if type(entry["command_token_added"]) is not bool:
raise ValueError("command_token_added must be a boolean")
- if entry["transport"]["kind"] == "flatpak" and "shortcut_exe" in raw and raw["shortcut_exe"] is not None:
- shortcut_exe = raw["shortcut_exe"]
- if (
- not isinstance(shortcut_exe, str)
- or not shortcut_exe.startswith("/")
- or "\x00" in shortcut_exe
- or not shortcut_exe.strip()
- ):
- raise ValueError("shortcut_exe must be an absolute executable path")
- entry["shortcut_exe"] = shortcut_exe
return entry
@classmethod
@@ -158,11 +119,11 @@ class WrapperService(BaseService):
if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file():
raise RuntimeError("Workaround state path is not a regular file")
try:
- raw = json.loads(self.sidecar_path.read_text(encoding="utf-8"))
+ 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
- document = self._validate_document(raw)
- return document, True, self.sidecar_path.read_text(encoding="utf-8")
+ return self._validate_document(raw), True, content
def _wrapper_marker(self) -> bool:
if self.wrapper_path.is_symlink() or not self.wrapper_path.exists():
@@ -179,29 +140,21 @@ class WrapperService(BaseService):
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}"
- )
+ raise RuntimeError(f"Refusing to replace unowned wrapper at {self.wrapper_path}")
return True
@staticmethod
def _shell(value: str) -> str:
return shlex.quote(value)
- @staticmethod
- def _direct_flatpak_tokens(value: str) -> Optional[list[str]]:
- """Parse the supported full executable form: /usr/bin/flatpak run APP."""
- try:
- tokens = shlex.split(value, posix=True)
- except ValueError:
- return None
- if len(tokens) >= 3 and Path(tokens[0]).name == "flatpak" and tokens[1] == "run":
- return tokens
- return None
-
- @classmethod
- def _state_lines(cls, state: Dict[str, Any], shortcut_exe: Optional[str]) -> list[str]:
- lines = [" unset " + " ".join(cls.MANAGED_ENV_KEYS)]
+ 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"]:
@@ -233,72 +186,12 @@ class WrapperService(BaseService):
" fi",
" export DXVK_CONFIG",
])
- lines.append(f" shortcut_exe={cls._shell(shortcut_exe or '')}")
return lines
- 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 _flatpak_args(self, state: Dict[str, Any]) -> list[str]:
- config_dir = str(self.config_dir)
- config_file = str(self.config_file_path)
- dll_dir = str(self._dll_directory())
- args = [
- self._shell(f"--filesystem={config_dir}:rw"),
- self._shell(f"--filesystem={dll_dir}:ro"),
- self._shell(f"--env=LSFGVK_CONFIG={config_file}"),
- '"--env=LSFGVK_FLATPAK=1"',
- '"--env=SteamAppId=$appid"',
- '"--unset-env=DISABLE_GAMESCOPE_WSI"',
- '"--unset-env=ENABLE_GAMESCOPE_WSI"' if not state["disableGamescopeWsi"] else
- '"--env=ENABLE_GAMESCOPE_WSI=0"',
- '"--unset-env=DXVK_HDR"' if not state["disableHdr"] else
- '"--env=DXVK_HDR=0"',
- '"--unset-env=SteamDeck"' if not state["disableSteamdeckMode"] else
- '"--env=SteamDeck=0"',
- '"--unset-env=DISABLE_VKBASALT" "--unset-env=ENABLE_VKBASALT"',
- ]
- if state["disableVkbasalt"]:
- args.append('"--env=DISABLE_VKBASALT=1"')
- args.extend([
- '"--unset-env=MESA_LOADER_DRIVER_OVERRIDE" "--unset-env=__GLX_VENDOR_LIBRARY_NAME" "--unset-env=GALLIUM_DRIVER"',
- ])
- if state["enableZink"]:
- args.extend([
- '"--env=__GLX_VENDOR_LIBRARY_NAME=mesa"',
- '"--env=MESA_LOADER_DRIVER_OVERRIDE=zink"',
- '"--env=GALLIUM_DRIVER=zink"',
- ])
- args.extend([
- '"--unset-env=DXVK_FRAME_RATE"',
- ])
- static_args = " ".join(args)
- return [
- ' if [ -n "${DXVK_CONFIG+x}" ]; then',
- f' set -- "$flatpak_command" {static_args} "--env=DXVK_CONFIG=$DXVK_CONFIG" "$@"',
- " else",
- f' set -- "$flatpak_command" {static_args} "$@"',
- " fi",
- ]
-
def _render_wrapper(self, document: Dict[str, Any]) -> str:
lines = [
"#!/bin/sh",
self.MARKER,
- "# Generated by Decky LSFG-VK; edits will be rejected on the next update.",
"",
"appid=",
'case "${SteamAppId-}" in',
@@ -317,77 +210,25 @@ class WrapperService(BaseService):
' *) appid="${STEAM_COMPAT_APP_ID}" ;;',
" esac",
"fi",
- "shortcut_exe=",
'case "$appid" in',
]
for appid in sorted(document["apps"], key=lambda value: int(value)):
- entry = document["apps"][appid]
lines.append(f" {appid})")
- lines.extend(self._state_lines(entry["state"], entry.get("shortcut_exe")))
+ lines.extend(self._state_lines(document["apps"][appid]["state"]))
lines.append(" ;;")
lines.extend([
"esac",
- "",
- 'if [ -n "$shortcut_exe" ]; then',
- ])
- # The arguments are emitted per branch below so the values are static and
- # the wrapper never needs a JSON parser or another helper executable.
- lines.append(' case "$appid" in')
- for appid in sorted(document["apps"], key=lambda value: int(value)):
- entry = document["apps"][appid]
- transport = entry.get("transport", {"kind": "host"})
- if transport.get("kind") != "flatpak":
- continue
- shortcut_exe = entry.get("shortcut_exe", "")
- direct_flatpak_tokens = self._direct_flatpak_tokens(shortcut_exe)
- if direct_flatpak_tokens is None and Path(shortcut_exe).name != "flatpak":
- raise ValueError(
- f"Flatpak target {appid} does not use a direct flatpak executable"
- )
- lines.append(f" {appid})")
- lines.extend([
- *(
- [
- f" shortcut_exe={self._shell(direct_flatpak_tokens[0])}",
- " set -- "
- + " ".join(self._shell(token) for token in direct_flatpak_tokens[1:])
- + ' "$@"',
- ]
- if direct_flatpak_tokens
- else []
- ),
- ' if [ "${1-}" != "run" ]; then',
- ' echo "lsfg-vk: Flatpak shortcut must use direct flatpak run transport" >&2',
- " exit 64",
- " fi",
- ' flatpak_command="$1"',
- " shift",
- " flatpak_target=",
- ' for flatpak_arg in "$@"; do',
- ' case "$flatpak_arg" in',
- ' -*) ;;',
- ' *) flatpak_target="$flatpak_arg"; break ;;',
- " esac",
- " done",
- f' if [ "$flatpak_target" != {self._shell(transport["flatpakAppId"])} ]; then',
- ' echo "lsfg-vk: Flatpak shortcut application ID changed externally" >&2',
- " exit 64",
- " fi",
- ])
- lines.extend(self._flatpak_args(entry["state"]))
- lines.append(" ;;")
- lines.extend([
- " esac",
- ' exec "$shortcut_exe" "$@"',
- "fi",
'exec "$@"',
"",
])
return "\n".join(lines)
def _write_document(self, document: Dict[str, Any]) -> None:
- content = json.dumps(document, indent=2, sort_keys=True) + "\n"
- self._write_file(self.sidecar_path, content, 0o644)
+ 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()
@@ -421,9 +262,7 @@ class WrapperService(BaseService):
"state": dict(entry["state"]) if entry else None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": self._wrapper_marker() if document["apps"] else False,
- "shortcut_exe": entry.get("shortcut_exe") if entry else None,
"command_token_added": entry.get("command_token_added", False) if entry else False,
- "transport": dict(entry.get("transport", {"kind": "host"})) if entry else None,
}
def get(self, appid: str) -> Dict[str, Any]:
@@ -448,9 +287,7 @@ class WrapperService(BaseService):
self,
appid: str,
state: Dict[str, Any],
- shortcut_exe: Optional[str] = None,
command_token_added: bool = False,
- transport: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
try:
normalized = self._valid_appid(appid)
@@ -460,27 +297,10 @@ class WrapperService(BaseService):
with self._lock:
self._assert_wrapper_owned_or_absent()
document, _, _ = self._read_document()
- previous_entry = document["apps"].get(normalized)
- selected_transport = self._validate_transport(
- transport
- if transport is not None
- else (
- previous_entry.get("transport")
- if previous_entry
- else None
- )
- )
- entry: Dict[str, Any] = {
+ document["apps"][normalized] = {
"state": validated_state,
- "command_token_added": bool(command_token_added),
- "transport": selected_transport,
+ "command_token_added": command_token_added,
}
- if selected_transport["kind"] == "flatpak":
- if shortcut_exe is not None:
- entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe})
- elif previous_entry and "shortcut_exe" in previous_entry:
- entry["shortcut_exe"] = previous_entry["shortcut_exe"]
- document["apps"][normalized] = entry
self._write_pair(document)
return self._response(document, normalized)
except Exception as error:
@@ -517,7 +337,6 @@ class WrapperService(BaseService):
}
def repair(self) -> Dict[str, Any]:
- """Regenerate a missing owned wrapper without importing old global state."""
try:
with self._lock:
document, _, _ = self._read_document()
diff --git a/scripts/deploy-to-deck.sh b/scripts/deploy-to-deck.sh
new file mode 100755
index 0000000..b5cb4f7
--- /dev/null
+++ b/scripts/deploy-to-deck.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+deck_host="deck@192.168.0.241"
+plugin_root="Decky LSFG-VK"
+install_path="/home/deck/homebrew/plugins/Decky LSFG-VK"
+
+package_dir="$(mktemp -d "${TMPDIR:-/tmp}/decky-plugin-package.XXXXXX")"
+run_id="$(basename "$package_dir")"
+remote_archive="/tmp/decky-lsfg-vk-${run_id}.zip"
+remote_stage="/tmp/decky-lsfg-vk-stage-${run_id}"
+trap 'rm -rf -- "$package_dir"' EXIT INT TERM
+
+pnpm build
+./cli/decky plugin build . \
+ --output-path "$package_dir" \
+ --tmp-output-path "$package_dir/tmp"
+
+package_zip="$(find "$package_dir" -maxdepth 1 -type f -name '*.zip' -print -quit)"
+test -n "$package_zip"
+
+scp "$package_zip" "$deck_host:$remote_archive"
+
+remote_script="$(cat <<'REMOTE'
+set -euo pipefail
+
+archive="$1"
+stage="$2"
+install="$3"
+plugin_root="$4"
+
+cleanup() {
+ rm -rf -- "$archive" "$stage"
+}
+trap cleanup EXIT INT TERM
+
+mkdir -p -- "$stage"
+unzip -q "$archive" -d "$stage"
+test -f "$stage/$plugin_root/plugin.json"
+test -f "$stage/$plugin_root/dist/index.js"
+
+sudo -v
+sudo rm -rf -- "$install"
+sudo mv -- "$stage/$plugin_root" "$install"
+sudo chown -R deck:deck -- "$install"
+sudo systemctl restart plugin_loader.service
+sleep 2
+sudo chown -R deck:deck -- "$install"
+
+test "$(systemctl is-active plugin_loader.service)" = active
+echo "Deck is ready to test"
+REMOTE
+)"
+
+printf -v remote_command 'bash -c %q -- %q %q %q %q' \
+ "$remote_script" "$remote_archive" "$remote_stage" "$install_path" "$plugin_root"
+
+ssh -tt "$deck_host" "$remote_command"
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index b96acec..3d4890e 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -30,10 +30,6 @@ export interface SteamBranchStatus extends ApiResult {
}
export type LsfgConfig = ConfigurationData;
-export type TargetTransport =
- | { kind: "host" }
- | { kind: "flatpak"; flatpakAppId: string };
-export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error";
export interface GameConfigEntry {
appid: string;
@@ -45,11 +41,6 @@ export interface InstalledGame {
appid: string;
name: string;
nonSteam: boolean;
- transport: TargetTransport;
- executable?: string;
- arguments?: string;
- startDir?: string;
- flatpakSupport?: FlatpakTargetSupport;
}
export interface GlobalConfig {
@@ -57,15 +48,6 @@ export interface GlobalConfig {
no_fp16: boolean;
}
-export interface FlatpakTargetSupport extends ApiResult {
- flatpak_app_id?: string;
- runtime?: string | null;
- runtime_branch?: string | null;
- support_status: FlatpakTargetSupportStatus;
- extension_installed: boolean;
- installed_branches: string[];
-}
-
export interface WorkaroundState {
dxvkFrameRate: number;
disableGamescopeWsi: boolean;
@@ -77,12 +59,11 @@ export interface WorkaroundState {
export interface WorkaroundStateResult extends ApiResult {
appid?: string;
+ app_id?: string;
state?: WorkaroundState | null;
wrapper_path?: string;
wrapper_owned?: boolean;
- shortcut_exe?: string | null;
command_token_added?: boolean;
- transport?: TargetTransport | null;
}
export interface GameConfigsResult extends ApiResult {
@@ -105,19 +86,50 @@ export interface FileContentResult extends ApiResult {
path?: string;
}
-export interface FlatpakExtensionStatus extends ApiResult {
- message: string;
- available: boolean;
- extension_id: string;
- supported_branches: string[];
- installed_branches: string[];
+export interface DebugFileContent {
+ id: string;
+ label: string;
+ path: string;
+ exists: boolean;
+ content?: string | null;
+ error?: string | null;
}
-export interface FlatpakExtensionToggleResult extends ApiResult {
- message: string;
- runtime_branch: string;
+export interface DebugFileContentsResult extends ApiResult {
+ files?: DebugFileContent[];
+}
+
+export interface FlatpakApp {
+ app_id: string;
+ app_name: string;
+ runtime?: string | null;
+ runtime_branch?: string | null;
+ runtime_ready: boolean;
+ prepared: boolean;
+ owned: boolean;
enabled: boolean;
- installed: boolean;
+ profile: string;
+ config?: LsfgConfig | null;
+ workarounds: WorkaroundState;
+ error?: string | null;
+}
+
+export interface RunningFlatpakApp {
+ app_id: string;
+ active: boolean;
+ pid?: string;
+}
+
+export interface FlatpakAppsResult extends ApiResult {
+ apps?: FlatpakApp[];
+}
+
+export interface RunningFlatpakAppsResult extends ApiResult {
+ apps?: RunningFlatpakApp[];
+}
+
+export interface FlatpakAppResult extends ApiResult, Partial<FlatpakApp> {
+ app_id: string;
}
export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk");
@@ -125,10 +137,12 @@ export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_
export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed");
export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status");
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
-export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status");
-export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support");
-export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support");
-export const setFlatpakExtensionEnabled = callable<[string, boolean], FlatpakExtensionToggleResult>("set_flatpak_extension_enabled");
+export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps");
+export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app");
+export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config");
+export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state");
+export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app");
+export const getRunningFlatpakApps = callable<[], RunningFlatpakAppsResult>("get_running_flatpak_apps");
export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs");
export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games");
export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config");
@@ -138,8 +152,7 @@ export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get
export const setWorkaroundState = callable<[
string,
WorkaroundState,
- string | null | undefined,
boolean,
- TargetTransport | null | undefined,
], WorkaroundStateResult>("set_workaround_state");
export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state");
+export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents");
diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx
index e3cc09d..07408d7 100644
--- a/src/components/ConfigFileTab.tsx
+++ b/src/components/ConfigFileTab.tsx
@@ -1,13 +1,79 @@
+import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui";
import { useEffect, useState } from "react";
-import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui";
-import { getConfigFileContent, FileContentResult } from "../api/lsfgApi";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi";
import t from "../i18n/i18n";
+function usePersistentCollapsed(key: string) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(key) !== "false";
+ } catch {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(collapsed));
+ } catch {
+ // Persisting the view preference is optional.
+ }
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
+function DebugFileSection({ file }: { file: DebugFileContent }) {
+ const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`);
+ const status = file.exists ? "Present" : "Not present";
+
+ return (
+ <>
+ <PanelSectionRow>
+ <Field
+ label={file.label}
+ description={`${file.path} · ${status}`}
+ bottomSeparator="none"
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div
+ className="LSFG_DebugFileCollapseButton_Container"
+ style={{ marginTop: "-2px", marginBottom: "4px" }}
+ >
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={toggleCollapsed}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+ {!collapsed && (
+ <PanelSectionRow>
+ {file.exists && file.content !== null && file.content !== undefined ? (
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
+ {file.content}
+ </pre>
+ ) : (
+ <Field
+ label="File unavailable"
+ description={file.error || "The file has not been created yet."}
+ />
+ )}
+ </PanelSectionRow>
+ )}
+ </>
+ );
+}
+
export function ConfigFileTab() {
- const [result, setResult] = useState<FileContentResult | null>(null);
+ const [result, setResult] = useState<DebugFileContentsResult | null>(null);
useEffect(() => {
- getConfigFileContent().then(setResult).catch((error) => {
+ getDebugFileContents().then(setResult).catch((error) => {
setResult({ success: false, error: String(error) });
});
}, []);
@@ -23,24 +89,35 @@ export function ConfigFileTab() {
}
return (
- <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
- {result.error && (
- <PanelSectionRow>
- <Field label="Error" description={result.error} />
- </PanelSectionRow>
- )}
- {result.success && result.content && (
- <>
- <PanelSectionRow>
- <Field label="Config file" description={result.path} />
- </PanelSectionRow>
+ <>
+ <style>
+ {`
+ .LSFG_DebugFileCollapseButton_Container > div > div > div > button,
+ .LSFG_DebugFileCollapseButton_Container > div > div > div > div > button {
+ height: 24px !important;
+ min-height: 24px !important;
+ padding: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+
+ .LSFG_DebugFileCollapseButton_Container svg {
+ display: block;
+ margin: 0;
+ }
+ `}
+ </style>
+ <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}>
+ {result.error && (
<PanelSectionRow>
- <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
- {result.content}
- </pre>
+ <Field label="Error" description={result.error} />
</PanelSectionRow>
- </>
- )}
- </PanelSection>
+ )}
+ {result.success && result.files?.map((file) => (
+ <DebugFileSection key={file.id} file={file} />
+ ))}
+ </PanelSection>
+ </>
);
}
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 5ae4a87..a6d4c2a 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,4 +1,4 @@
-import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui";
+import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, ToggleField, gamepadDialogClasses, showModal } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
@@ -11,6 +11,8 @@ interface ConfigurationTabProps {
config: ConfigurationData;
targets: GameTarget[];
runningGame: GameTarget | null;
+ showDebugTab: boolean;
+ onShowDebugTabChange: (value: boolean) => void;
onSelect: (appid: string) => void;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
@@ -24,6 +26,8 @@ export function ConfigurationTab({
config,
targets,
runningGame,
+ showDebugTab,
+ onShowDebugTabChange,
onSelect,
onConfigChange,
onEnable,
@@ -63,31 +67,39 @@ export function ConfigurationTab({
if (detailAppId === null) {
return (
- <PanelSection title="Games">
- <GameConfigurationSelector
- targets={targets}
- runningGame={runningGame}
- onSelect={(appid) => {
- setFocusConfiguredToggle(false);
- setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
- onSelect(appid);
- setDetailAppId(appid);
- }}
- onEnableAll={onEnableAll}
- onResetAll={onResetAll}
- focusConfiguredToggle={focusConfiguredToggle}
- onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
- />
- </PanelSection>
+ <>
+ <PanelSection title="Games">
+ <GameConfigurationSelector
+ targets={targets}
+ runningGame={runningGame}
+ onSelect={(appid) => {
+ setFocusConfiguredToggle(false);
+ setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
+ onSelect(appid);
+ setDetailAppId(appid);
+ }}
+ onEnableAll={onEnableAll}
+ onResetAll={onResetAll}
+ focusConfiguredToggle={focusConfiguredToggle}
+ onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
+ />
+ </PanelSection>
+ <PanelSection title="Settings">
+ <PanelSectionRow>
+ <ToggleField
+ label="Show debug tab"
+ description="Show the raw configuration and generated files tab for troubleshooting."
+ checked={showDebugTab}
+ onChange={onShowDebugTabChange}
+ />
+ </PanelSectionRow>
+ </PanelSection>
+ </>
);
}
const profileLabel = selectedTarget?.name || "Game profile";
- const profileTransport = selectedTarget
- ? selectedTarget.transport.kind === "flatpak"
- ? "Non-Steam · Flatpak"
- : selectedTarget.nonSteam ? "Non-Steam" : "Steam"
- : "Game";
+ const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam";
const profileDescription = selectedTarget
? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
@@ -104,9 +116,7 @@ export function ConfigurationTab({
} else if (detailAppId) {
const isRunningUnconfigured = runningGame?.appid === detailAppId
&& runningGame.nonSteam === false
- && runningGame.transport.kind === "host"
&& selectedTarget?.nonSteam === false
- && selectedTarget?.transport.kind === "host"
&& !runningGame.configured;
if (isRunningUnconfigured) {
showModal(
@@ -131,22 +141,22 @@ export function ConfigurationTab({
<PanelSectionRow>
<div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<Focusable noFocusRing style={{ flex: "none" }}>
- <DialogButton
- aria-label="Back to games"
- onClick={closeDetails}
- style={{
- width: "48px",
- minWidth: "48px",
- height: "24px",
- minHeight: "24px",
- padding: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <FaArrowLeft />
- </DialogButton>
+ <DialogButton
+ aria-label="Back to games"
+ onClick={closeDetails}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
</Focusable>
<div
className={gamepadDialogClasses.FieldLabel}
@@ -166,18 +176,6 @@ export function ConfigurationTab({
</PanelSectionRow>
)}
</PanelSection>
- {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && (
- <PanelSection>
- <PanelSectionRow>
- <ButtonItem
- layout="below"
- onClick={() => void onRepair(selectedTarget.appid)}
- >
- Repair Flatpak support
- </ButtonItem>
- </PanelSectionRow>
- </PanelSection>
- )}
{selectedTarget?.configured && (
<GameConfigurationControls
config={config}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 578f984..ce3c018 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,29 +1,56 @@
import { Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
-import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
+import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
+import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallation } from "../hooks/useLsfgHooks";
import { tabStyles } from "../styles";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
+import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab";
+import { FlatpakTab } from "./FlatpakTab";
import { NowPlayingTab } from "./NowPlayingTab";
import { SetupTab } from "./SetupTab";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
games: <FaList size={18} />,
+ flatpak: <FaCube size={18} />,
configFile: <FaFileAlt size={18} />,
setup: <FaTools size={18} />,
};
+const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1";
+
+function usePersistentBoolean(key: string, defaultValue: boolean) {
+ const [value, setValue] = useState(() => {
+ try {
+ const stored = localStorage.getItem(key);
+ return stored === null ? defaultValue : stored === "true";
+ } catch {
+ return defaultValue;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(value));
+ } catch {}
+ }, [key, value]);
+
+ return [value, setValue] as const;
+}
+
export function Content() {
const {
config,
+ runningConfig,
targets,
runningGame,
setSelectedAppId,
save,
+ saveFor,
enable,
enableAll,
repair,
@@ -42,43 +69,58 @@ export function Content() {
install,
uninstall,
} = useInstallation(reload);
- const [tab, setTab] = useState("Setup");
- const previousRunningAppId = useRef<string | null>(null);
const setupComplete =
isInstalled &&
losslessScalingInstalled &&
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
+ const flatpak = useFlatpakConfiguration(setupComplete);
+ const [tab, setTab] = useState("Setup");
+ const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true);
+ const previousRunningWorkload = useRef<string | null>(null);
+ const runningFlatpak = flatpak.runningApp;
+ const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak);
+ const runningWorkload = runningGame?.configured
+ ? `steam:${runningGame.appid}`
+ : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null;
useEffect(() => {
if (!setupComplete) {
setTab("Setup");
return;
}
- setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Games") : current);
- }, [runningGame?.appid, runningGame?.configured, setupComplete]);
+ setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current);
+ }, [hasNowPlaying, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
- const appid = runningGame?.appid || null;
- const previous = previousRunningAppId.current;
- previousRunningAppId.current = appid;
- if (appid && appid !== previous) setTab(runningGame?.configured ? "NowPlaying" : "Games");
- else if (!appid && previous) {
+ const previous = previousRunningWorkload.current;
+ previousRunningWorkload.current = runningWorkload;
+ if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying");
+ else if (!runningWorkload && previous) {
setTab((current) => current === "NowPlaying" ? "Games" : current);
}
- }, [runningGame?.appid, runningGame?.configured, setupComplete]);
+ }, [runningWorkload, setupComplete]);
+
+ useEffect(() => {
+ if (isInstalled) {
+ void reload();
+ void flatpak.reload();
+ }
+ }, [isInstalled, reload, flatpak.reload]);
useEffect(() => {
- if (isInstalled) void reload();
- }, [isInstalled, reload]);
+ if (!showDebugTab && tab === "ConfigFile") setTab("Games");
+ }, [showDebugTab, tab]);
const handleConfigChange = async (
fieldName: keyof ConfigurationData,
value: boolean | number | string | string[],
cleanupLaunchOptions = false,
- ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions);
+ ) => {
+ await save({ ...config, [fieldName]: value }, cleanupLaunchOptions);
+ };
const setup = (
<SetupTab
@@ -94,20 +136,26 @@ export function Content() {
/>
);
+ const nowPlaying = runningGame?.configured ? (
+ <NowPlayingTab
+ game={runningGame}
+ config={runningConfig}
+ onConfigChange={async (field, value) => {
+ await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true);
+ }}
+ />
+ ) : runningFlatpak ? (
+ <FlatpakNowPlayingTab
+ app={runningFlatpak}
+ busy={flatpak.busyAppId === runningFlatpak.app_id}
+ onConfigChange={flatpak.updateConfig}
+ onWorkaroundChange={flatpak.updateWorkarounds}
+ />
+ ) : null;
+
const tabs = setupComplete
? [
- ...(runningGame?.configured ? [{
- id: "NowPlaying",
- title: tabIcons.nowPlaying,
- content: (
- <NowPlayingTab
- game={runningGame}
- config={config}
- onConfigChange={(field, value) => handleConfigChange(field, value)}
- onRepair={repair}
- />
- ),
- }] : []),
+ ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []),
{
id: "Games",
title: tabIcons.games,
@@ -116,6 +164,8 @@ export function Content() {
config={config}
targets={targets}
runningGame={runningGame}
+ showDebugTab={showDebugTab}
+ onShowDebugTabChange={setShowDebugTab}
onSelect={setSelectedAppId}
onConfigChange={(field, value) => handleConfigChange(field, value, true)}
onEnable={enable}
@@ -126,7 +176,24 @@ export function Content() {
/>
),
},
- { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> },
+ {
+ id: "Flatpak",
+ title: tabIcons.flatpak,
+ content: (
+ <FlatpakTab
+ apps={flatpak.apps}
+ runningApp={runningFlatpak}
+ loading={flatpak.loading}
+ busyAppId={flatpak.busyAppId}
+ onRefresh={flatpak.reload}
+ onEnable={flatpak.enableApp}
+ onRemove={flatpak.removeApp}
+ onConfigChange={flatpak.updateConfig}
+ onWorkaroundChange={flatpak.updateWorkarounds}
+ />
+ ),
+ },
+ ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }] : []),
{ id: "Setup", title: tabIcons.setup, content: setup },
]
: [{ id: "Setup", title: tabIcons.setup, content: setup }];
@@ -137,7 +204,7 @@ export function Content() {
style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }}
>
<style>{tabStyles}</style>
- <Tabs activeTab={tab} onShowTab={setTab} tabs={tabs} />
+ <Tabs activeTab={!showDebugTab && tab === "ConfigFile" ? "Games" : tab} onShowTab={setTab} tabs={tabs} />
</div>
);
}
diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx
new file mode 100644
index 0000000..9e5a0db
--- /dev/null
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -0,0 +1,39 @@
+import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+
+interface Props {
+ app: FlatpakApp;
+ busy: boolean;
+ onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
+ onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
+}
+
+export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) {
+ if (!app.config) return null;
+ const changeConfig = async (
+ field: keyof LsfgConfig,
+ value: boolean | number | string | string[],
+ ) => {
+ await onConfigChange(app.app_id, { ...app.config!, [field]: value });
+ };
+
+ return (
+ <Focusable>
+ <PanelSection title="Now Playing">
+ <PanelSectionRow>
+ <Field label={app.app_name} description={`Flatpak · ${app.app_id}`} />
+ </PanelSectionRow>
+ </PanelSection>
+ <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} />
+ <ConfigurationSection config={app.config} onConfigChange={changeConfig} />
+ <FlatpakWorkaroundsSection
+ state={app.workarounds}
+ disabled={busy}
+ onChange={(state) => onWorkaroundChange(app.app_id, state)}
+ />
+ </Focusable>
+ );
+}
diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx
new file mode 100644
index 0000000..c5e27f5
--- /dev/null
+++ b/src/components/FlatpakTab.tsx
@@ -0,0 +1,178 @@
+import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui";
+import { useCallback, useMemo, useState } from "react";
+import { FaArrowLeft } from "react-icons/fa";
+import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { ProfileDetails } from "./ProfileDetails";
+
+interface Props {
+ apps: FlatpakApp[];
+ runningApp: FlatpakApp | null;
+ loading: boolean;
+ busyAppId: string;
+ onRefresh: () => Promise<void>;
+ onEnable: (appId: string) => Promise<boolean>;
+ onRemove: (appId: string) => Promise<boolean>;
+ onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
+ onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
+}
+
+export function FlatpakTab({
+ apps,
+ runningApp,
+ loading,
+ busyAppId,
+ onRefresh,
+ onEnable,
+ onRemove,
+ onConfigChange,
+ onWorkaroundChange,
+}: Props) {
+ const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
+ const selected = useMemo(
+ () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null,
+ [apps, selectedAppId],
+ );
+ const close = useCallback(() => setSelectedAppId(null), []);
+
+ if (!selectedAppId) {
+ return (
+ <PanelSection title="Flatpak">
+ {/* <PanelSectionRow>
+ <Field
+ label="Flatpak applications"
+ description="Enable LSFG-VK directly for a Flatpak. Steam shortcuts and launcher scripts are not modified."
+ />
+ </PanelSectionRow> */}
+ {apps.map((app) => {
+ const status = app.enabled
+ ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled"
+ : app.prepared && !app.owned ? "Prepared externally" : "Available";
+ return (
+ <PanelSectionRow key={app.app_id}>
+ <Field
+ label={app.app_name}
+ description={`${app.app_id} · ${status}`}
+ onActivate={() => setSelectedAppId(app.app_id)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ );
+ })}
+ {apps.length === 0 && !loading && (
+ <PanelSectionRow>
+ <Field label="No Flatpak applications found" />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={loading || Boolean(busyAppId)} onClick={() => void onRefresh()}>
+ {loading ? "Refreshing..." : "Refresh Flatpaks"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ );
+ }
+
+ if (!selected) {
+ return (
+ <PanelSection title="Flatpak">
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={close}>Back</ButtonItem>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <Field label="Flatpak application is no longer installed" />
+ </PanelSectionRow>
+ </PanelSection>
+ );
+ }
+
+ const busy = busyAppId === selected.app_id;
+ const config = selected.config;
+ const external = selected.prepared && !selected.owned;
+ const profileDescription = [
+ selected.app_id,
+ selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null,
+ selected.enabled ? `profile ${selected.profile}` : null,
+ selected.app_id === runningApp?.app_id ? "Running" : null,
+ ].filter(Boolean).join(" · ");
+
+ const changeConfig = async (
+ field: keyof LsfgConfig,
+ value: boolean | number | string | string[],
+ ) => {
+ if (!config) return;
+ await onConfigChange(selected.app_id, { ...config, [field]: value });
+ };
+
+ return (
+ <Focusable onCancelButton={close}>
+ <PanelSection>
+ <PanelSectionRow>
+ <div style={{ display: "flex", alignItems: "center", width: "100%" }}>
+ <Focusable noFocusRing style={{ flex: "none" }}>
+ <DialogButton
+ aria-label="Back to Flatpaks"
+ onClick={close}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
+ </Focusable>
+ <div
+ className={gamepadDialogClasses.FieldLabel}
+ style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
+ >
+ {selected.app_name}
+ </div>
+ </div>
+ </PanelSectionRow>
+ </PanelSection>
+ {!selected.enabled && (
+ <PanelSection>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy || external || Boolean(selected.error)}
+ onClick={() => void onEnable(selected.app_id)}
+ >
+ {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"}
+ </ButtonItem>
+ </PanelSectionRow>
+ {selected.error && (
+ <PanelSectionRow>
+ <Field label="Unavailable" description={selected.error} />
+ </PanelSectionRow>
+ )}
+ </PanelSection>
+ )}
+ {selected.enabled && config && (
+ <>
+ <FpsMultiplierControl config={config} onConfigChange={changeConfig} />
+ <ConfigurationSection config={config} onConfigChange={changeConfig} />
+ <FlatpakWorkaroundsSection
+ state={selected.workarounds}
+ disabled={busy}
+ onChange={(state) => onWorkaroundChange(selected.app_id, state)}
+ />
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void onRemove(selected.app_id)}>
+ {busy ? "Removing..." : "Remove Flatpak profile"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </>
+ )}
+ <ProfileDetails description={profileDescription} />
+ </Focusable>
+ );
+}
diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx
new file mode 100644
index 0000000..7d9d880
--- /dev/null
+++ b/src/components/FlatpakWorkaroundsSection.tsx
@@ -0,0 +1,144 @@
+import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui";
+import { useEffect, useRef, useState } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import type { WorkaroundState } from "../api/lsfgApi";
+import t from "../i18n/i18n";
+
+interface Props {
+ state: WorkaroundState;
+ disabled?: boolean;
+ onChange: (state: WorkaroundState) => Promise<boolean>;
+}
+
+const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1";
+
+export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false";
+ } catch {
+ return true;
+ }
+ });
+ const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate);
+ const timer = useRef<number | null>(null);
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed));
+ } catch {}
+ }, [collapsed]);
+
+ useEffect(() => {
+ setFpsValue(state.dxvkFrameRate);
+ }, [state.dxvkFrameRate]);
+
+ useEffect(() => () => {
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ }, []);
+
+ const update = (field: keyof WorkaroundState, value: boolean | number) => {
+ void onChange({ ...state, [field]: value });
+ };
+
+ const updateFps = (value: number) => {
+ setFpsValue(value);
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => {
+ timer.current = null;
+ void onChange({ ...state, dxvkFrameRate: value });
+ }, 250);
+ };
+
+ const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off");
+
+ return (
+ <>
+ <PanelSectionRow>
+ <div
+ style={{
+ fontSize: "14px",
+ fontWeight: "bold",
+ marginTop: "8px",
+ marginBottom: "6px",
+ borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
+ paddingBottom: "3px",
+ color: "white",
+ }}
+ >
+ {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")}
+ </div>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={() => setCollapsed((value) => !value)}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </PanelSectionRow>
+ {!collapsed && (
+ <>
+ <PanelSectionRow>
+ <SliderField
+ label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`}
+ description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires app restart to apply.")}
+ value={fpsValue}
+ min={0}
+ max={60}
+ step={1}
+ onChange={updateFps}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_STEAMDECK_MODE", "Disable Steam Deck Mode")}
+ description={t("CONFIG_DISABLE_STEAMDECK_MODE_DESC", "Disables a game-specific Steam Deck compatibility switch. Requires app restart to apply.")}
+ checked={state.disableSteamdeckMode}
+ onChange={(value) => update("disableSteamdeckMode", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_GAMESCOPE_WSI", "Disable Gamescope WSI")}
+ description={t("CONFIG_DISABLE_GAMESCOPE_WSI_DESC", "Adds ENABLE_GAMESCOPE_WSI=0. Requires app restart to apply.")}
+ checked={state.disableGamescopeWsi}
+ onChange={(value) => update("disableGamescopeWsi", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_HDR", "Disable HDR")}
+ description={t("CONFIG_DISABLE_HDR_DESC", "Prevents DXVK from exposing HDR to the app. Requires app restart to apply.")}
+ checked={state.disableHdr}
+ onChange={(value) => update("disableHdr", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_DISABLE_VKBASALT", "Disable vkBasalt")}
+ description={t("CONFIG_DISABLE_VKBASALT_DESC", "Disables vkBasalt which can conflict with LSFG-VK.")}
+ checked={state.disableVkbasalt}
+ onChange={(value) => update("disableVkbasalt", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField
+ label={t("CONFIG_ENABLE_ZINK", "Force Zink for OpenGL Games")}
+ description={t("CONFIG_ENABLE_ZINK_DESC", "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes. Requires app restart to apply.")}
+ checked={state.enableZink}
+ onChange={(value) => update("enableZink", value)}
+ disabled={disabled}
+ />
+ </PanelSectionRow>
+ </>
+ )}
+ </>
+ );
+}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 6bea2e0..7025f78 100644
--- a/src/components/GameConfigurationControls.tsx
+++ b/src/components/GameConfigurationControls.tsx
@@ -10,7 +10,7 @@ interface Props {
autoFocusFpsMultiplier?: boolean;
onFpsMultiplierFocused?: () => void;
showWorkarounds?: boolean;
- workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">;
+ workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">;
onRepairWorkaround?: () => Promise<boolean>;
}
@@ -36,7 +36,6 @@ export function GameConfigurationControls({
<WorkaroundsSection
appId={workaroundTarget.appid}
nonSteam={workaroundTarget.nonSteam}
- transport={workaroundTarget.transport}
onRepair={onRepairWorkaround}
/>
)}
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 92eacba..1f0bc73 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -28,16 +28,13 @@ function usePersistentCollapsed(key: string) {
useEffect(() => {
try {
localStorage.setItem(key, String(collapsed));
- } catch {
- // Persisting the view preference is optional.
- }
+ } catch {}
}, [collapsed, key]);
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
function targetDescription(game: GameTarget): string {
- if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
return game.nonSteam ? "Non-Steam" : "Steam";
}
@@ -137,7 +134,7 @@ export function GameConfigurationSelector({
showModal(
<ConfirmModal
strTitle="Enable all available games?"
- strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults. Flatpak targets will be provisioned as needed."
+ strDescription="Create individual LSFG-VK profiles for every available Steam game and non-Steam shortcut. Flatpak profiles are managed separately in the Flatpak tab."
strOKButtonText="Enable all"
strCancelButtonText="Cancel"
onOK={() => void onEnableAll()}
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 57858db..c067dc0 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,5 +1,4 @@
-import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
-import { useState } from "react";
+import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
@@ -11,11 +10,9 @@ interface Props {
fieldName: keyof ConfigurationData,
value: boolean | number | string | string[],
) => Promise<void>;
- onRepair: (appid: string) => Promise<boolean>;
}
function targetDescription(game: GameTarget): string {
- if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
return game.nonSteam ? "Non-Steam" : "Steam";
}
@@ -23,23 +20,7 @@ export function NowPlayingTab({
game,
config,
onConfigChange,
- onRepair,
}: Props) {
- const [busy, setBusy] = useState(false);
- const supportNeedsRepair =
- game.transport.kind === "flatpak" &&
- game.flatpakSupport?.support_status !== "ready";
-
- const handleRepair = async () => {
- if (busy) return;
- setBusy(true);
- try {
- await onRepair(game.appid);
- } finally {
- setBusy(false);
- }
- };
-
return (
<Focusable>
<PanelSection title="Now Playing">
@@ -47,21 +28,6 @@ export function NowPlayingTab({
<Field label={game.name} description={targetDescription(game)} />
</PanelSectionRow>
</PanelSection>
- {supportNeedsRepair && (
- <PanelSection>
- <PanelSectionRow>
- <Field
- label="Flatpak support needs repair"
- description={game.flatpakSupport?.error || "The target runtime extension is not ready."}
- />
- </PanelSectionRow>
- <PanelSectionRow>
- <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}>
- {busy ? "Repairing..." : "Repair Flatpak support"}
- </ButtonItem>
- </PanelSectionRow>
- </PanelSection>
- )}
<GameConfigurationControls
config={config}
onConfigChange={onConfigChange}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index 5587392..3de5ec1 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -1,7 +1,6 @@
import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui";
import { useEffect, useState } from "react";
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
-import type { TargetTransport } from "../api/lsfgApi";
import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds";
import t from "../i18n/i18n";
import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
@@ -9,7 +8,6 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
interface WorkaroundsSectionProps {
appId: string;
nonSteam: boolean;
- transport: TargetTransport;
onRepair?: () => Promise<boolean>;
}
@@ -73,17 +71,15 @@ function usePersistentCollapsed() {
useEffect(() => {
try {
localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed));
- } catch {
- // Persisting the view preference is optional.
- }
+ } catch {}
}, [collapsed]);
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) {
+export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) {
const [collapsed, toggleCollapsed] = usePersistentCollapsed();
- const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport);
+ const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam);
const [repairing, setRepairing] = useState(false);
const state = snapshot?.state;
const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true;
diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts
new file mode 100644
index 0000000..d5e6f7e
--- /dev/null
+++ b/src/hooks/useFlatpakConfiguration.ts
@@ -0,0 +1,108 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ enableFlatpakApp,
+ getFlatpakApps,
+ getRunningFlatpakApps,
+ removeFlatpakApp,
+ setFlatpakWorkaroundState,
+ updateFlatpakConfig,
+ type FlatpakApp,
+ type LsfgConfig,
+ type RunningFlatpakApp,
+ type WorkaroundState,
+} from "../api/lsfgApi";
+import { showErrorToast } from "../utils/toastUtils";
+
+export function useFlatpakConfiguration(enabled: boolean) {
+ const [apps, setApps] = useState<FlatpakApp[]>([]);
+ const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [busyAppId, setBusyAppId] = useState("");
+
+ const reload = useCallback(async () => {
+ if (!enabled) {
+ setApps([]);
+ return;
+ }
+ setLoading(true);
+ try {
+ const result = await getFlatpakApps();
+ if (!result.success) throw new Error(result.error || "Could not list Flatpak applications");
+ setApps(result.apps || []);
+ } catch (error) {
+ showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error));
+ } finally {
+ setLoading(false);
+ }
+ }, [enabled]);
+
+ const pollRunning = useCallback(async () => {
+ if (!enabled) {
+ setRunningApps([]);
+ return;
+ }
+ try {
+ const result = await getRunningFlatpakApps();
+ if (result.success) setRunningApps(result.apps || []);
+ } catch {}
+ }, [enabled]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ useEffect(() => {
+ void pollRunning();
+ if (!enabled) return;
+ const interval = window.setInterval(() => void pollRunning(), 2000);
+ return () => window.clearInterval(interval);
+ }, [enabled, pollRunning]);
+
+ const operate = useCallback(async (appId: string, operation: () => Promise<{ success: boolean; error?: string | null }>) => {
+ if (busyAppId) return false;
+ setBusyAppId(appId);
+ try {
+ const result = await operation();
+ if (!result.success) throw new Error(result.error || "Flatpak operation failed");
+ await reload();
+ await pollRunning();
+ return true;
+ } catch (error) {
+ showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error));
+ return false;
+ } finally {
+ setBusyAppId("");
+ }
+ }, [busyAppId, pollRunning, reload]);
+
+ const enableApp = useCallback((appId: string) => operate(appId, () => enableFlatpakApp(appId)), [operate]);
+ const removeApp = useCallback((appId: string) => operate(appId, () => removeFlatpakApp(appId)), [operate]);
+ const updateConfig = useCallback(
+ (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)),
+ [operate],
+ );
+ const updateWorkarounds = useCallback(
+ (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)),
+ [operate],
+ );
+
+ const runningApp = useMemo(() => {
+ if (runningApps.length === 0) return null;
+ const running = runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null);
+ if (!running) return null;
+ return apps.find((app) => app.app_id === running.app_id) || null;
+ }, [apps, runningApps]);
+
+ return {
+ apps,
+ runningApps,
+ runningApp,
+ loading,
+ busyAppId,
+ reload,
+ enableApp,
+ removeApp,
+ updateConfig,
+ updateWorkarounds,
+ };
+}
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index 3260019..2e39f5e 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
-import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
+import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
@@ -23,7 +23,6 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
appid: String(appid >>> 0),
name,
nonSteam: true,
- transport: { kind: "host" },
}];
});
} catch {
@@ -80,6 +79,7 @@ export function useGameConfiguration() {
previousQuickAccessVisible.current = quickAccessVisible;
if (initialLoad || becameVisible) void load();
}, [load, quickAccessVisible]);
+
useEffect(() => {
const poll = () => {
if (!configsLoaded) return;
@@ -89,16 +89,25 @@ export function useGameConfiguration() {
const installed = installedGames.find((game) => game.appid === appid);
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
- setRunningGame((current) => current?.appid === appid ? current : {
- ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }),
+ const next: GameTarget = {
+ ...(installed || { appid, name, nonSteam: false }),
name,
configured: games.some((game) => game.appid === appid),
- });
+ };
+ setRunningGame((current) => (
+ current?.appid === next.appid
+ && current.name === next.name
+ && current.nonSteam === next.nonSteam
+ && current.configured === next.configured
+ ? current
+ : next
+ ));
};
poll();
const interval = window.setInterval(poll, 2000);
return () => window.clearInterval(interval);
}, [configsLoaded, games, installedGames]);
+
useEffect(() => {
const appid = runningGame?.appid || null;
if (appid !== previousRunningAppId.current) {
@@ -109,25 +118,15 @@ export function useGameConfiguration() {
const targets = useMemo<GameTarget[]>(() => {
const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) }));
- for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true });
+ for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true });
if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame);
return configured;
}, [games, installedGames, runningGame]);
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
-
- const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise<boolean> => {
- if (target.transport.kind !== "flatpak") return true;
- const result = await ensureFlatpakSupport(target.transport.flatpakAppId);
- if (!result.success || result.support_status !== "ready") {
- showErrorToast(
- "Flatpak support unavailable",
- result.error || result.message || "The required Flatpak runtime extension is not ready",
- );
- return false;
- }
- return true;
- }, []);
+ const runningConfig = runningGame
+ ? games.find((game) => game.appid === runningGame.appid)?.config || template
+ : template;
const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
@@ -148,16 +147,12 @@ export function useGameConfiguration() {
target.nonSteam,
wrapperPath,
commandTokenAdded,
- target.transport,
- target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined,
);
stateWriteAttempted = true;
const saved = await setWorkaroundState(
target.appid,
state,
- integration.originalExecutable ?? null,
integration.commandTokenAdded,
- target.transport,
);
if (!saved.success) throw new Error(saved.error || "Could not save workaround state");
return true;
@@ -169,9 +164,7 @@ export function useGameConfiguration() {
appId,
target.nonSteam,
wrapperPath,
- integration.originalExecutable,
integration.commandTokenAdded,
- target.transport,
);
} catch (rollbackError) {
showErrorToast("Workaround rollback failed", asError(rollbackError).message);
@@ -201,9 +194,7 @@ export function useGameConfiguration() {
appId,
target.nonSteam,
wrapperPath,
- target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined,
existing.command_token_added === true,
- target.transport,
);
const removed = await removeWorkaroundState(target.appid);
if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
@@ -214,33 +205,37 @@ export function useGameConfiguration() {
}
}, [installedGames]);
- const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => {
- const selectedTarget = targets.find((target) => target.appid === selectedAppId);
- if (!selectedTarget?.name) return;
- // The profile owns its wrapper integration. Keep this check on every
- // configuration save so an external edit is detected before the profile
- // is changed; toggles update the sidecar only.
- if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return;
- const result = await updateGameConfig(selectedAppId, selectedTarget.name, next);
+ const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target?.name) return false;
+ if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false;
+ const result = await updateGameConfig(appid, target.name, next);
if (result.success) await load();
- }, [ensureTargetWorkarounds, load, selectedAppId, targets]);
+ return result.success;
+ }, [ensureTargetWorkarounds, load, targets]);
+
+ const save = useCallback(
+ async (next: ConfigurationData, cleanupLaunchOptions = false) => {
+ if (!selectedAppId) return false;
+ return saveFor(selectedAppId, next, cleanupLaunchOptions);
+ },
+ [saveFor, selectedAppId],
+ );
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
- if (!(await ensureTargetFlatpakSupport(target))) return false;
if (!(await ensureTargetWorkarounds(target))) return false;
const result = await updateGameConfig(appid, target.name, template);
if (result.success) await load();
else await removeTargetWorkarounds(target);
return result.success;
- }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+
const enableAll = useCallback(async (): Promise<void> => {
const available = targets.filter((target) => !target.configured && target.name);
if (available.length === 0) return;
-
for (const target of available) {
- if (!(await ensureTargetFlatpakSupport(target))) return;
if (!(await ensureTargetWorkarounds(target))) return;
const result = await updateGameConfig(target.appid, target.name, template);
if (!result.success) {
@@ -253,20 +248,11 @@ export function useGameConfiguration() {
}
}
await load();
- }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+
const repair = useCallback(async (appid: string): Promise<boolean> => {
const target = targets.find((item) => item.appid === appid);
if (!target) return false;
- if (target.transport.kind === "flatpak") {
- const support = await repairFlatpakSupport(target.transport.flatpakAppId);
- if (!support.success || support.support_status !== "ready") {
- showErrorToast(
- "Flatpak support unavailable",
- support.error || support.message || "The required Flatpak runtime extension is not ready",
- );
- return false;
- }
- }
const success = await ensureTargetWorkarounds(target);
if (success) await load();
return success;
@@ -284,6 +270,7 @@ export function useGameConfiguration() {
}
}
}, [load, removeTargetWorkarounds, selectedAppId, targets]);
+
const resetAll = useCallback(async () => {
for (const target of targets.filter((item) => item.configured)) {
if (!(await removeTargetWorkarounds(target))) return;
@@ -296,5 +283,5 @@ export function useGameConfiguration() {
}
}, [load, removeTargetWorkarounds, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load };
+ return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, enable, enableAll, repair, resetSelected, resetAll, reload: load };
}
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index 29e1181..c2b8904 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -3,11 +3,9 @@ import {
getWorkaroundState,
removeWorkaroundState,
setWorkaroundState,
- type TargetTransport,
type WorkaroundState,
} from "../api/lsfgApi";
import {
- assertKnownShortcutTarget,
getDefaultWrapperPath,
installWrapperIntegration,
isWrapperIntegrationInstalled,
@@ -45,8 +43,6 @@ export interface WorkaroundSnapshot {
wrapperOwned: boolean;
integrationInstalled: boolean;
commandTokenAdded: boolean;
- shortcutExe?: string | null;
- transport: TargetTransport;
}
interface PerAppWorkarounds {
@@ -65,49 +61,34 @@ function makeSnapshot(
steam: SteamLaunchOptionsSnapshot,
result: Awaited<ReturnType<typeof getWorkaroundState>>,
nonSteam: boolean,
- transport: TargetTransport,
): WorkaroundSnapshot {
if (!result.state) throw new Error("Workaround state is not initialized for this profile");
const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
- const selectedTransport = result.transport || transport;
- const shortcutExe = selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined;
- assertKnownShortcutTarget(steam, nonSteam, selectedTransport, wrapperPath, shortcutExe);
return {
steam,
state: result.state,
wrapperPath,
wrapperOwned: result.wrapper_owned === true,
- integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, selectedTransport, wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath),
commandTokenAdded: result.command_token_added === true,
- shortcutExe,
- transport: selectedTransport,
};
}
async function adoptWorkaroundState(
appId: string,
nonSteam: boolean,
- transport: TargetTransport,
wrapperPath: string,
): Promise<WorkaroundSnapshot> {
let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
try {
- integration = await installWrapperIntegration(
- Number(appId),
- nonSteam,
- wrapperPath,
- false,
- transport,
- );
+ integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false);
const finalized = await setWorkaroundState(
appId,
DEFAULT_WORKAROUND_STATE,
- integration.originalExecutable ?? null,
integration.commandTokenAdded,
- transport,
);
if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state");
- return makeSnapshot(integration.snapshot, finalized, nonSteam, transport);
+ return makeSnapshot(integration.snapshot, finalized, nonSteam);
} catch (error) {
let rollbackSucceeded = true;
if (integration?.changed) {
@@ -116,12 +97,9 @@ async function adoptWorkaroundState(
Number(appId),
nonSteam,
wrapperPath,
- integration.originalExecutable,
integration.commandTokenAdded,
- transport,
);
} catch {
- // Leave the owned integration in place rather than guessing at cleanup.
rollbackSucceeded = false;
}
}
@@ -133,11 +111,7 @@ async function adoptWorkaroundState(
}
}
-export function usePerAppWorkarounds(
- appId: string,
- nonSteam: boolean,
- transport: TargetTransport = { kind: "host" },
-): PerAppWorkarounds {
+export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds {
const [status, setStatus] = useState<WorkaroundLoadStatus>("loading");
const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -154,12 +128,11 @@ export function usePerAppWorkarounds(
return adoptWorkaroundState(
appId,
nonSteam,
- transport,
result.wrapper_path || getDefaultWrapperPath(),
);
}
- return makeSnapshot(steam, result, nonSteam, transport);
- }, [appId, nonSteam, numericAppId, transport]);
+ return makeSnapshot(steam, result, nonSteam);
+ }, [appId, nonSteam, numericAppId]);
const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
setSnapshot(next);
@@ -194,7 +167,7 @@ export function usePerAppWorkarounds(
setSnapshot((current) => current ? {
...current,
steam,
- integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.transport, current.wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath),
} : current);
},
(subscriptionError) => {
@@ -229,24 +202,18 @@ export function usePerAppWorkarounds(
setError(null);
const nextState = { ...current.state, [field]: value } as WorkaroundState;
try {
- const shortcutExe = current.transport.kind === "flatpak" ? current.shortcutExe ?? null : null;
const result = await setWorkaroundState(
appId,
nextState,
- shortcutExe,
current.commandTokenAdded,
- current.transport,
);
if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
- const selectedTransport = result.transport || current.transport;
applySnapshot({
...current,
state: result.state,
wrapperPath: result.wrapper_path || current.wrapperPath,
wrapperOwned: result.wrapper_owned === true,
- shortcutExe: selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined,
commandTokenAdded: result.command_token_added === true,
- transport: selectedTransport,
});
return true;
} catch (updateError) {
diff --git a/src/types.d.ts b/src/types.d.ts
index e5db40d..4adad61 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -16,8 +16,6 @@ declare module "*.jpg" {
interface SteamAppDetails {
strLaunchOptions?: string;
strShortcutLaunchOptions?: string;
- strShortcutExe?: string;
- strShortcutStartDir?: string;
}
interface SteamAppDetailsRegistration {
@@ -31,7 +29,6 @@ interface SteamApps {
): SteamAppDetailsRegistration;
SetAppLaunchOptions(appId: number, options: string): void | Promise<void>;
SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>;
- SetShortcutExe(appId: number, executable: string): void | Promise<void>;
TerminateApp(appId: string, param1: boolean): void;
GetAllShortcuts?(): Promise<unknown[]>;
}
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
index 82cf116..f03eeab 100644
--- a/src/utils/steamLaunchOptions.ts
+++ b/src/utils/steamLaunchOptions.ts
@@ -1,5 +1,3 @@
-import type { TargetTransport } from "../api/lsfgApi";
-
const DEFAULT_WRAPPER_PATH = "~/.lsfg";
const COMMAND_TOKEN = "%command%";
@@ -26,12 +24,10 @@ export interface SteamLaunchOptionsSnapshot {
appId: number;
nonSteam: boolean;
options: string;
- target: string;
details: SteamAppDetails;
}
export interface WrapperIntegrationResult {
snapshot: SteamLaunchOptionsSnapshot;
- originalExecutable?: string;
commandTokenAdded: boolean;
changed: boolean;
}
@@ -61,7 +57,6 @@ function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): S
appId,
nonSteam,
options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "",
- target: nonSteam ? details.strShortcutExe || "" : "",
details,
};
}
@@ -162,15 +157,6 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token
const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value);
const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value);
const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value);
-const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak";
-
-function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined {
- if (transport.kind !== "flatpak") return undefined;
- const value = candidate?.trim() ? decodeToken(candidate.trim()) : "";
- if (value === "flatpak") return "/usr/bin/flatpak";
- if (value === "/usr/bin/flatpak") return value;
- return undefined;
-}
export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options));
export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value));
@@ -267,25 +253,10 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU
export function isWrapperIntegrationInstalled(
steam: SteamLaunchOptionsSnapshot,
- nonSteam: boolean,
- transport: TargetTransport,
+ _nonSteam: boolean,
wrapperPath = DEFAULT_WRAPPER_PATH,
): boolean {
- return usesShortcutTarget(nonSteam, transport)
- ? steam.target === wrapperPath
- : hasWrapperLaunchIntegration(steam.options, wrapperPath);
-}
-
-export function assertKnownShortcutTarget(
- steam: SteamLaunchOptionsSnapshot,
- nonSteam: boolean,
- transport: TargetTransport,
- wrapperPath: string,
- originalExecutable?: string | null,
-): void {
- if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) {
- throw new Error("Managed shortcut Target has no saved original executable");
- }
+ return hasWrapperLaunchIntegration(steam.options, wrapperPath);
}
const queues = new Map<string, Promise<unknown>>();
@@ -325,18 +296,16 @@ async function writeVerified(
previous: string,
next: string,
write: (value: string) => Promise<void>,
- read: (value: SteamLaunchOptionsSnapshot) => string,
message: string,
): Promise<SteamLaunchOptionsSnapshot> {
- const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value;
try {
await write(next);
- return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message);
+ return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message);
} catch (error) {
const failure = asError(error);
try {
await write(previous);
- await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`);
+ await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options");
} catch (rollback) {
throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`);
}
@@ -344,19 +313,11 @@ async function writeVerified(
}
}
-const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options;
-const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target;
-
function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> {
const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions;
if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`));
return Promise.resolve(setter.call(apps(), appId, value));
}
-function writeTarget(appId: number, value: string): Promise<void> {
- const setter = apps()?.SetShortcutExe;
- if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable"));
- return Promise.resolve(setter.call(apps(), appId, value));
-}
export function updateSteamLaunchOptions(
appId: number,
@@ -368,7 +329,7 @@ export function updateSteamLaunchOptions(
const next = transform(current.options);
return next === current.options ? current : writeVerified(
appId, nonSteam, current.options, next,
- (value) => writeOptions(appId, nonSteam, value), readOptions,
+ (value) => writeOptions(appId, nonSteam, value),
"Steam did not accept the launch options",
);
});
@@ -379,52 +340,16 @@ export function installWrapperIntegration(
nonSteam: boolean,
wrapperPath: string,
commandTokenAdded = false,
- transport: TargetTransport = { kind: "host" },
- originalExecutable?: string,
): Promise<WrapperIntegrationResult> {
return queued(appId, nonSteam, async () => {
- let current = await readSteamLaunchOptions(appId, nonSteam);
- if (usesShortcutTarget(nonSteam, transport)) {
- if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it");
- if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) {
- throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first");
- }
- const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath);
- const launchOptionsChanged = cleaned !== current.options;
- if (launchOptionsChanged) {
- current = await writeVerified(
- appId, true, current.options, cleaned,
- (value) => writeOptions(appId, true, value), readOptions,
- "Steam did not accept shortcut launch options",
- );
- }
- const savedOriginal = selectFlatpakExecutable(transport, originalExecutable);
- if (current.target === wrapperPath) {
- if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable");
- return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged };
- }
- if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) {
- throw new Error("Shortcut Target changed externally; refusing to replace it");
- }
- const currentOriginal = selectFlatpakExecutable(transport, current.target);
- if (!currentOriginal || (originalExecutable && !savedOriginal)) {
- throw new Error("Flatpak shortcut Target is not a supported executable");
- }
- const value = await writeVerified(
- appId, true, current.target, wrapperPath,
- (target) => writeTarget(appId, target), readTarget,
- "Steam did not accept the shortcut Target",
- );
- return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true };
- }
-
+ const current = await readSteamLaunchOptions(appId, nonSteam);
const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options));
const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath);
const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam);
if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false };
const value = await writeVerified(
appId, nonSteam, current.options, rewrite.options,
- (options) => writeOptions(appId, nonSteam, options), readOptions,
+ (options) => writeOptions(appId, nonSteam, options),
"Steam did not accept the launch options",
);
return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true };
@@ -435,43 +360,14 @@ export function removeWrapperIntegration(
appId: number,
nonSteam: boolean,
wrapperPath: string,
- originalExecutable?: string,
commandTokenAdded = false,
- transport: TargetTransport = { kind: "host" },
): Promise<SteamLaunchOptionsSnapshot> {
return queued(appId, nonSteam, async () => {
- let current = await readSteamLaunchOptions(appId, nonSteam);
- if (usesShortcutTarget(nonSteam, transport)) {
- const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath);
- if (cleaned !== current.options) {
- current = await writeVerified(
- appId, true, current.options, cleaned,
- (value) => writeOptions(appId, true, value), readOptions,
- "Steam did not clean shortcut launch options",
- );
- }
- if (current.target !== wrapperPath) {
- if (isWrapperToken(current.target, wrapperPath)) {
- throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target");
- }
- if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) {
- throw new Error("Shortcut Target changed externally; refusing to restore it");
- }
- return current;
- }
- if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) {
- throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target");
- }
- return writeVerified(
- appId, true, wrapperPath, originalExecutable,
- (target) => writeTarget(appId, target), readTarget,
- "Steam did not restore the shortcut Target",
- );
- }
+ const current = await readSteamLaunchOptions(appId, nonSteam);
const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded));
return next === current.options ? current : writeVerified(
appId, nonSteam, current.options, next,
- (options) => writeOptions(appId, nonSteam, options), readOptions,
+ (options) => writeOptions(appId, nonSteam, options),
"Steam did not clean the launch options",
);
});
diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts
index c799907..40aeb3c 100644
--- a/tests/steamLaunchOptions.test.ts
+++ b/tests/steamLaunchOptions.test.ts
@@ -28,7 +28,7 @@ test("inserts one wrapper immediately before an existing command macro", () => {
});
});
-test("normalizes blank and argument-only fields while refusing ambiguous launchers", () => {
+test("normalizes blank and argument-only shortcut fields", () => {
assert.deepEqual(installWrapperLaunchOption("", wrapper), {
options: `${wrapper} %command%`,
commandTokenAdded: true,
@@ -41,7 +41,7 @@ test("normalizes blank and argument-only fields while refusing ambiguous launche
assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/);
});
-test("preserves assignments, quoting, suffixes, and unrelated values", () => {
+test("preserves assignments quoting suffixes and released wrapper cleanup", () => {
const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"';
assert.equal(
installWrapperLaunchOption(options, wrapper).options,
@@ -49,50 +49,40 @@ test("preserves assignments, quoting, suffixes, and unrelated values", () => {
);
assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`);
assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag");
-});
-
-test("cleans current, legacy, and bare Mako wrappers without touching suffix arguments", () => {
for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) {
assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`);
}
- assert.equal(cleanupLegacyWrapper(`FOO=bar ${wrapper} %command%`), "FOO=bar %command%");
assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true);
assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false);
- assert.equal(removeWrapperLaunchOption(`FOO=bar ${wrapper} %command% --arg`, wrapper), "FOO=bar %command% --arg");
});
-test("removes only old plugin assignments and preserves DXVK settings", () => {
+test("removes only managed assignments and preserves unrelated values", () => {
assert.equal(
cleanupPluginAssignments(
'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%',
),
'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%',
);
- assert.equal(
- cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper),
- "%command%",
- );
+ assert.equal(cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), "%command%");
assert.equal(
cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"),
"PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%",
);
});
-test("reads the matching app-details field and installs/removes Steam integration", async () => {
+test("uses launch options for Steam and non-Steam shortcuts without a Target API", async () => {
const previousWindow = (globalThis as Record<string, unknown>).window;
const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
let appOptions = "FOO=bar %command%";
let shortcutOptions = "--windowed";
- let shortcutTarget = '"flatpak"';
const appWrites: string[] = [];
const shortcutWrites: string[] = [];
- const targetWrites: string[] = [];
const unregisters: number[] = [];
const apps = {
RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) {
callback(appId === 42
? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" }
- : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" });
+ : { strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" });
return { unregister: () => unregisters.push(appId) };
},
SetAppLaunchOptions(appId: number, options: string) {
@@ -105,11 +95,6 @@ test("reads the matching app-details field and installs/removes Steam integratio
shortcutWrites.push(options);
shortcutOptions = options;
},
- SetShortcutExe(appId: number, executable: string) {
- assert.equal(appId, 43);
- targetWrites.push(executable);
- shortcutTarget = executable;
- },
};
(globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
(globalThis as Record<string, unknown>).SteamClient = { Apps: apps };
@@ -120,18 +105,15 @@ test("reads the matching app-details field and installs/removes Steam integratio
assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " "));
assert.equal(installed.commandTokenAdded, false);
assert.equal(appWrites.length, 1);
- assert.equal(shortcutWrites.length, 0);
- const shortcut = await installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" });
- assert.equal(shortcut.originalExecutable, "/usr/bin/flatpak");
- assert.equal(shortcut.snapshot.target, wrapper);
- assert.deepEqual(targetWrites, [wrapper]);
- const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, { kind: "flatpak", flatpakAppId: "com.example.Game" });
- assert.equal(restored.target, "/usr/bin/flatpak");
- assert.deepEqual(targetWrites, [wrapper, "/usr/bin/flatpak"]);
- assert.equal(shortcutWrites.length, 0);
+ const shortcut = await installWrapperIntegration(43, true, wrapper);
+ assert.equal(shortcut.snapshot.options, `~/.lsfg %command% --windowed`);
+ assert.deepEqual(shortcutWrites, [`~/.lsfg %command% --windowed`]);
- const cleaned = await removeWrapperIntegration(42, false, wrapper, undefined, installed.commandTokenAdded);
+ const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.commandTokenAdded);
+ assert.equal(restored.options, "--windowed");
+
+ const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded);
assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " "));
assert.ok(unregisters.includes(42));
assert.ok(unregisters.includes(43));
@@ -143,46 +125,46 @@ test("reads the matching app-details field and installs/removes Steam integratio
}
});
-test("uses shortcut launch options for a host shortcut without changing its Target", async () => {
+test("AppImage EmuDeck and direct Flatpak shortcuts all stay launch-option based", async () => {
const previousWindow = (globalThis as Record<string, unknown>).window;
const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
- const originalOptions = 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"';
- let shortcutOptions = originalOptions;
- let shortcutTarget = "env";
- const shortcutWrites: string[] = [];
- const targetWrites: string[] = [];
- const apps = {
- RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) {
- callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions });
- return { unregister() {} };
+ const cases = [
+ {
+ options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"',
+ expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"',
},
- SetShortcutLaunchOptions(_appId: number, options: string) {
- shortcutWrites.push(options);
- shortcutOptions = options;
+ {
+ options: "",
+ expected: "~/.lsfg %command%",
},
- SetShortcutExe(_appId: number, executable: string) {
- targetWrites.push(executable);
- shortcutTarget = executable;
+ {
+ options: "run org.example.Game",
+ expected: "~/.lsfg %command% run org.example.Game",
},
- };
+ ];
(globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
- (globalThis as Record<string, unknown>).SteamClient = { Apps: apps };
try {
- const installed = await installWrapperIntegration(44, true, wrapper, false, { kind: "host" });
- assert.equal(installed.originalExecutable, undefined);
- assert.equal(installed.snapshot.target, "env");
- assert.equal(installed.snapshot.options, 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"');
- assert.deepEqual(targetWrites, []);
- assert.deepEqual(shortcutWrites, [installed.snapshot.options]);
-
- const secondInstall = await installWrapperIntegration(44, true, wrapper, false, { kind: "host" });
- assert.equal(secondInstall.snapshot.options, installed.snapshot.options);
- assert.deepEqual(shortcutWrites, [installed.snapshot.options]);
-
- const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, { kind: "host" });
- assert.equal(restored.target, "env");
- assert.equal(restored.options, originalOptions);
- assert.deepEqual(targetWrites, []);
+ for (const [index, item] of cases.entries()) {
+ let shortcutOptions = item.options;
+ const shortcutWrites: string[] = [];
+ (globalThis as Record<string, unknown>).SteamClient = {
+ Apps: {
+ RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) {
+ callback({ strShortcutLaunchOptions: shortcutOptions });
+ return { unregister() {} };
+ },
+ SetShortcutLaunchOptions(_appId: number, options: string) {
+ shortcutWrites.push(options);
+ shortcutOptions = options;
+ },
+ },
+ };
+ const installed = await installWrapperIntegration(100 + index, true, wrapper);
+ assert.equal(installed.snapshot.options, item.expected);
+ assert.deepEqual(shortcutWrites, [item.expected]);
+ const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded);
+ assert.equal(restored.options, item.options);
+ }
} finally {
if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
else (globalThis as Record<string, unknown>).window = previousWindow;
@@ -191,65 +173,29 @@ test("uses shortcut launch options for a host shortcut without changing its Targ
}
});
-test("fails closed when shortcut Target ownership or setters are unavailable", async () => {
+test("launch option write failure rolls back the original value", async () => {
const previousWindow = (globalThis as Record<string, unknown>).window;
const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
+ let appOptions = "FOO=bar %command%";
+ const writes: string[] = [];
(globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
(globalThis as Record<string, unknown>).SteamClient = {
Apps: {
RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) {
- callback({ strShortcutExe: _appId === 99 ? "/usr/bin/flatpak" : "garbage", strShortcutLaunchOptions: "" });
+ callback({ strLaunchOptions: appOptions });
return { unregister() {} };
},
+ SetAppLaunchOptions(_appId: number, options: string) {
+ writes.push(options);
+ appOptions = options;
+ if (options.includes(wrapper)) throw new Error("simulated launch option failure");
+ },
},
};
try {
- await assert.rejects(installWrapperIntegration(98, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /supported executable/);
- await assert.rejects(installWrapperIntegration(99, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target API is unavailable/);
- await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target changed externally/);
- } finally {
- if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
- else (globalThis as Record<string, unknown>).window = previousWindow;
- if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient;
- else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient;
- }
-});
-
-test("restores launch options and shortcut Target when a setter fails after changing them", async () => {
- const previousWindow = (globalThis as Record<string, unknown>).window;
- const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
- let appOptions = "FOO=bar %command%";
- let shortcutTarget = "/usr/bin/flatpak";
- const appWrites: string[] = [];
- const targetWrites: string[] = [];
- const apps = {
- RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) {
- callback(appId === 42
- ? { strLaunchOptions: appOptions }
- : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" });
- return { unregister() {} };
- },
- SetAppLaunchOptions(_appId: number, options: string) {
- appWrites.push(options);
- appOptions = options;
- if (options.includes(wrapper)) throw new Error("simulated launch-option write failure");
- },
- SetShortcutExe(_appId: number, executable: string) {
- targetWrites.push(executable);
- shortcutTarget = executable;
- if (executable === wrapper) throw new Error("simulated Target write failure");
- },
- };
- (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout };
- (globalThis as Record<string, unknown>).SteamClient = { Apps: apps };
- try {
- await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch-option write failure/);
+ await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch option failure/);
assert.equal(appOptions, "FOO=bar %command%");
- assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]);
-
- await assert.rejects(installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /simulated Target write failure/);
- assert.equal(shortcutTarget, "/usr/bin/flatpak");
- assert.deepEqual(targetWrites, [wrapper, "/usr/bin/flatpak"]);
+ assert.deepEqual(writes, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]);
} finally {
if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
else (globalThis as Record<string, unknown>).window = previousWindow;
diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py
new file mode 100644
index 0000000..789842e
--- /dev/null
+++ b/tests/test_configuration_profiles.py
@@ -0,0 +1,80 @@
+import sys
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import Mock
+
+
+sys.modules.setdefault(
+ "decky",
+ types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()),
+)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
+
+from lsfg_vk.config_schema import ConfigurationManager
+from lsfg_vk.configuration import ConfigurationService
+
+
+class ConfigurationProfileTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.runtime = Mock()
+ self.service = ConfigurationService(runtime_service=self.runtime)
+ self.service.user_home = self.home
+ self.service.config_dir = self.home / ".config/lsfg-vk"
+ self.service.config_file_path = self.service.config_dir / "conf.toml"
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ def test_selector_only_profile_survives_round_trip(self):
+ content = """version = 2
+
+[global]
+allow_fp16 = true
+
+[[profile]]
+name = "flatpak:org.example.Game"
+pacing_mode = "vsync"
+multiplier = 3
+flow_scale = 0.8
+performance_mode = false
+override_present_mode = true
+preserve_swapchain_image_count = false
+"""
+ parsed = ConfigurationManager.parse_toml_content_multi_profile(content)
+ self.assertIn("flatpak:org.example.Game", parsed["profiles"])
+ self.assertEqual(parsed["profiles"]["flatpak:org.example.Game"]["active_in"], [])
+ rendered = ConfigurationManager.generate_toml_content_multi_profile(parsed)
+ reparsed = ConfigurationManager.parse_toml_content_multi_profile(rendered)
+ self.assertEqual(reparsed["profiles"]["flatpak:org.example.Game"]["multiplier"], 3)
+
+ def test_game_reset_all_preserves_flatpak_profiles(self):
+ self.service.update_game_config("123", "Steam Game", {"multiplier": 2})
+ self.service.update_flatpak_config("org.example.Game", {"multiplier": 3})
+
+ result = self.service.reset_all_game_configs()
+ data = self.service._get_profile_data()
+
+ self.assertTrue(result["success"])
+ self.assertNotIn("Steam Game", data["profiles"])
+ self.assertIn("flatpak:org.example.Game", data["profiles"])
+ self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3)
+
+ def test_flatpak_reset_all_preserves_steam_profiles(self):
+ self.service.update_game_config("123", "Steam Game", {"multiplier": 2})
+ self.service.update_flatpak_config("org.example.Game", {"multiplier": 3})
+
+ result = self.service.reset_all_flatpak_configs()
+ data = self.service._get_profile_data()
+
+ self.assertTrue(result["success"])
+ self.assertIn("Steam Game", data["profiles"])
+ self.assertNotIn("flatpak:org.example.Game", data["profiles"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py
new file mode 100644
index 0000000..8d58413
--- /dev/null
+++ b/tests/test_flatpak_profile_service.py
@@ -0,0 +1,210 @@
+import hashlib
+import sys
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import Mock
+
+
+sys.modules.setdefault(
+ "decky",
+ types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()),
+)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
+
+from lsfg_vk.configuration import ConfigurationService
+from lsfg_vk.flatpak_profile_service import FlatpakProfileService
+
+
+class FakeFlatpakService:
+ def __init__(self, home: Path):
+ self.user_home = home
+ self.config_dir = home / ".config/lsfg-vk"
+ self.config_file_path = self.config_dir / "conf.toml"
+ self.backup_dir = self.config_dir / "flatpak-overrides"
+ self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}}
+ self.commands = []
+ self.running = ""
+
+ def _read_state(self):
+ return self.state
+
+ def _write_state(self, state):
+ self.state = state
+
+ def _override_path(self, app_id):
+ return self.user_home / ".local/share/flatpak/overrides" / app_id
+
+ def _backup_path(self, app_id):
+ return self.backup_dir / f"{app_id}.ini"
+
+ @staticmethod
+ def _sha256(content):
+ return hashlib.sha256(content).hexdigest()
+
+ def _snapshot_override(self, app_id):
+ path = self._override_path(app_id)
+ if not path.exists():
+ return False, b""
+ return True, path.read_bytes()
+
+ @staticmethod
+ def _write_file(path, content, mode=0o644):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ path.chmod(mode)
+
+ def prepare_app(self, app_id):
+ apps = self.state["prepared_apps"]
+ if app_id not in apps:
+ existed, original = self._snapshot_override(app_id)
+ if existed:
+ self._write_file(self._backup_path(app_id), original.decode("utf-8"))
+ apps[app_id] = {"override_existed": existed, "managed_sha256": ""}
+ entry = apps[app_id]
+ baseline = ""
+ if entry["override_existed"]:
+ baseline = self._backup_path(app_id).read_text(encoding="utf-8")
+ managed = baseline + "\n[Context]\nfilesystems=/config:ro;/dll:ro;\n[Environment]\nLSFGVK_CONFIG=/config/conf.toml\nLSFGVK_FLATPAK=1\n"
+ self._write_file(self._override_path(app_id), managed)
+ entry["managed_sha256"] = self._sha256(managed.encode())
+ return {"success": True, "owned": True, "prepared": True, "runtime": "org.freedesktop.Platform/x86_64/24.08", "runtime_branch": "24.08"}
+
+ def remove_app_override(self, app_id):
+ entry = self.state["prepared_apps"].get(app_id)
+ if entry is None:
+ return {"success": True, "prepared": False, "owned": False}
+ existed, current = self._snapshot_override(app_id)
+ current_hash = self._sha256(current) if existed else self._sha256(b"")
+ if current_hash != entry["managed_sha256"]:
+ return {"success": False, "error": "Flatpak override changed after preparation"}
+ path = self._override_path(app_id)
+ backup = self._backup_path(app_id)
+ if entry["override_existed"]:
+ self._write_file(path, backup.read_text(encoding="utf-8"))
+ else:
+ path.unlink(missing_ok=True)
+ backup.unlink(missing_ok=True)
+ self.state["prepared_apps"].pop(app_id)
+ return {"success": True, "prepared": False, "owned": False}
+
+ def get_flatpak_apps(self):
+ app_id = "org.example.Game"
+ return {
+ "success": True,
+ "apps": [{
+ "app_id": app_id,
+ "app_name": "Example Game",
+ "runtime": "org.freedesktop.Platform/x86_64/24.08",
+ "runtime_branch": "24.08",
+ "runtime_ready": True,
+ "prepared": app_id in self.state["prepared_apps"],
+ "owned": app_id in self.state["prepared_apps"],
+ "error": None,
+ }],
+ }
+
+ def _run_flatpak_command(self, args, **_kwargs):
+ self.commands.append(args)
+ if args[:3] == ["override", "--user", "--show"]:
+ path = self._override_path(args[3])
+ return types.SimpleNamespace(returncode=0, stdout=path.read_text(encoding="utf-8") if path.exists() else "", stderr="")
+ if args[0] == "override":
+ app_id = args[-1]
+ path = self._override_path(app_id)
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
+ env = [item.removeprefix("--env=") for item in args if item.startswith("--env=")]
+ unset = [item.removeprefix("--unset-env=") for item in args if item.startswith("--unset-env=")]
+ if unset:
+ content += "\n[Context]\nunset-environment=" + ";".join(unset) + ";\n"
+ if env:
+ content += "\n[Environment]\n" + "\n".join(env) + "\n"
+ self._write_file(path, content)
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
+ if args[0] == "ps":
+ return types.SimpleNamespace(returncode=0, stdout=self.running, stderr="")
+ raise AssertionError(args)
+
+
+class FlatpakProfileServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.flatpak = FakeFlatpakService(self.home)
+ self.runtime = Mock()
+ self.configuration = ConfigurationService(runtime_service=self.runtime)
+ self.configuration.user_home = self.home
+ self.configuration.config_dir = self.flatpak.config_dir
+ self.configuration.config_file_path = self.flatpak.config_file_path
+ self.service = FlatpakProfileService(self.flatpak, self.configuration)
+ self.app_id = "org.example.Game"
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ def test_enable_creates_selector_profile_and_default_workarounds(self):
+ result = self.service.enable_app(self.app_id)
+ config = self.configuration.get_flatpak_config(self.app_id)
+ content = self.flatpak._override_path(self.app_id).read_text(encoding="utf-8")
+
+ self.assertTrue(result["success"])
+ self.assertTrue(config["exists"])
+ self.assertEqual(config["profile"], "flatpak:org.example.Game")
+ self.assertEqual(config["config"]["active_in"], [])
+ self.assertIn("LSFGVK_PROFILE=flatpak:org.example.Game", content)
+ self.assertIn("ENABLE_GAMESCOPE_WSI=0", content)
+ self.assertIn("DXVK_HDR=0", content)
+
+ def test_workaround_update_rebuilds_from_original_override(self):
+ baseline = "[Environment]\nDXVK_CONFIG=dxgi.syncInterval = 0\nKEEP=yes\n"
+ self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline)
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+
+ state = self.service.default_state()
+ state.update({"dxvkFrameRate": 30, "disableHdr": False, "enableZink": True})
+ result = self.service.set_workaround_state(self.app_id, state)
+ command = next(
+ args for args in reversed(self.flatpak.commands)
+ if args[0] == "override" and any(item.startswith("--env=LSFGVK_PROFILE=") for item in args)
+ )
+
+ self.assertTrue(result["success"])
+ self.assertIn("--env=DXVK_CONFIG=dxgi.syncInterval = 0; dxvk.maxFrameRate = 30", command)
+ self.assertNotIn("--env=DXVK_HDR=0", command)
+ self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command)
+
+ def test_remove_restores_exact_original_override_and_profile(self):
+ baseline = "[Environment]\nKEEP=yes\n"
+ self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline)
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+
+ removed = self.service.remove_app(self.app_id)
+
+ self.assertTrue(removed["success"])
+ self.assertEqual(self.flatpak._override_path(self.app_id).read_text(encoding="utf-8"), baseline)
+ self.assertFalse(self.configuration.get_flatpak_config(self.app_id)["exists"])
+
+ def test_external_override_change_fails_closed(self):
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+ path = self.flatpak._override_path(self.app_id)
+ path.write_text(path.read_text(encoding="utf-8") + "EXTERNAL=yes\n", encoding="utf-8")
+
+ result = self.service.set_workaround_state(self.app_id, self.service.default_state())
+
+ self.assertFalse(result["success"])
+ self.assertIn("changed after preparation", result["error"])
+
+ def test_running_detection_uses_owned_selector_state(self):
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+ self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n"
+
+ result = self.service.get_running_apps()
+
+ self.assertTrue(result["success"])
+ self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234"}])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py
index d5baf61..4d804c7 100644
--- a/tests/test_flatpak_service.py
+++ b/tests/test_flatpak_service.py
@@ -25,16 +25,17 @@ class FlatpakServiceTests(unittest.TestCase):
self.service.user_home = self.home
self.service.config_dir = self.home / ".config/lsfg-vk"
self.service.config_file_path = self.service.config_dir / "conf.toml"
+ self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True)
self.service.check_flatpak_available = Mock(return_value=True)
self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command)
self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08"
self.runtime_metadata = ""
self.user_branches = set()
self.system_branches = set()
- self.install_branch = "24.08"
- self.bundle = self.home / "lsfg-vk-24.08.flatpak"
- self.bundle.write_bytes(b"bundle")
- self.service._bundled_extension_path = Mock(return_value=self.bundle)
+ self.user_extension_origin = "flathub"
+ self.apps = {"com.example.Game": "Example Game"}
+ self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling"
+ self.service._dll_directory = Mock(return_value=self.dll_dir)
def tearDown(self):
self.tempdir.cleanup()
@@ -47,252 +48,266 @@ class FlatpakServiceTests(unittest.TestCase):
def _extension_line(branch):
return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n"
+ @staticmethod
+ def _parse_override(content):
+ section = None
+ filesystems = []
+ unset_environment = []
+ environment = {}
+ other = []
+ for raw in content.splitlines():
+ line = raw.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1]
+ continue
+ key, separator, value = line.partition("=")
+ if not separator:
+ continue
+ if section == "Context" and key == "filesystems":
+ filesystems.extend(item for item in value.split(";") if item)
+ elif section == "Context" and key == "unset-environment":
+ unset_environment.extend(item for item in value.split(";") if item)
+ elif section == "Environment":
+ environment[key] = value
+ else:
+ other.append((section, key, value))
+ return filesystems, unset_environment, environment, other
+
+ @staticmethod
+ def _serialize_override(filesystems, unset_environment, environment):
+ lines = ["[Context]"]
+ if filesystems:
+ lines.append("filesystems=" + ";".join(filesystems) + ";")
+ if unset_environment:
+ lines.append("unset-environment=" + ";".join(unset_environment) + ";")
+ if environment:
+ lines.append("")
+ lines.append("[Environment]")
+ lines.extend(f"{key}={value}" for key, value in environment.items())
+ return "\n".join(lines) + "\n"
+
+ def _apply_override(self, args):
+ app_id = args[-1]
+ path = self.service._override_path(app_id)
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
+ filesystems, unset_environment, environment, _ = self._parse_override(content)
+ for arg in args[2:-1]:
+ if arg.startswith("--filesystem="):
+ value = arg.split("=", 1)[1]
+ if value not in filesystems:
+ filesystems.append(value)
+ elif arg.startswith("--env="):
+ key, value = arg.split("=", 1)[1].split("=", 1)
+ environment[key] = value
+ if key in unset_environment:
+ unset_environment.remove(key)
+ elif arg.startswith("--unset-env="):
+ key = arg.split("=", 1)[1]
+ environment.pop(key, None)
+ if key not in unset_environment:
+ unset_environment.append(key)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ self._serialize_override(filesystems, unset_environment, environment),
+ encoding="utf-8",
+ )
+ return self._result()
+
def _run_flatpak_command(self, args, **_kwargs):
- if args[0] == "info" and args[1] == "--show-runtime":
+ if args[:2] == ["info", "--show-runtime"]:
return self._result(self.runtime_ref + "\n")
- if args[0] == "info" and args[1] == "--show-metadata":
+ if args[:2] == ["info", "--show-metadata"]:
return self._result(self.runtime_metadata)
+ if args[:3] == ["info", "--user", "--show-origin"]:
+ return self._result(self.user_extension_origin)
+ if args[:2] == ["list", "--app"]:
+ return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items()))
if args[0] == "list":
branches = self.user_branches if "--user" in args else self.system_branches
return self._result("".join(self._extension_line(branch) for branch in sorted(branches)))
if args[0] == "install":
- self.user_branches.add(self.install_branch)
+ self.user_branches.add(self.runtime_ref.rsplit("/", 1)[-1])
return self._result()
if args[0] == "uninstall":
self.user_branches.discard(args[-1].rsplit("/", 1)[-1])
return self._result()
+ if args[:3] == ["override", "--user", "--show"]:
+ path = self.service._override_path(args[-1])
+ return self._result(path.read_text(encoding="utf-8") if path.exists() else "")
+ if args[:2] == ["override", "--user"]:
+ return self._apply_override(args)
raise AssertionError(f"Unexpected Flatpak command: {args}")
- def test_runtime_branch_mapping_is_strict_and_branch_specific(self):
- self.assertEqual(
- FlatpakService.runtime_branch_from_ref(
- "org.freedesktop.Platform/x86_64/24.08"
- ),
- "24.08",
- )
- self.assertEqual(
- FlatpakService.runtime_branch_from_ref(
- "org.freedesktop.Platform//25.08"
- ),
- "25.08",
- )
- with self.assertRaises(ValueError):
- FlatpakService.runtime_branch_from_ref("org.gnome.Sdk/x86_64/46")
- with self.assertRaises(ValueError):
- FlatpakService.runtime_branch_from_ref(
- "org.freedesktop.Platform/x86_64/26.08"
- )
-
- def test_runtime_branch_mapping_reads_documented_gl_metadata(self):
- metadata = """
-[Extension org.freedesktop.Platform.GL]
-versions=25.08;25.08-extra;1.4
-version=1.4
-"""
- self.assertEqual(FlatpakService.runtime_branch_from_metadata(metadata), "25.08")
- with self.assertRaises(ValueError):
- FlatpakService.runtime_branch_from_metadata(
- "[Extension org.freedesktop.Platform.GL]\nversions=26.08;26.08-extra;1.4\n"
- )
-
- def test_resolve_reads_required_runtime_instead_of_any_installed_branch(self):
- self.user_branches = {"23.08"}
-
- response = self.service.resolve_app_support("com.example.Game")
+ def test_resolves_freedesktop_and_derived_runtimes(self):
+ runtime, branch = self.service._resolve_runtime("com.example.Game")
+ self.assertEqual(runtime, self.runtime_ref)
+ self.assertEqual(branch, "24.08")
+
+ self.runtime_ref = "org.kde.Platform/x86_64/6.10"
+ self.runtime_metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n"
+ runtime, branch = self.service._resolve_runtime("com.example.Game")
+ self.assertEqual(runtime, self.runtime_ref)
+ self.assertEqual(branch, "25.08")
+
+ def test_prepare_app_installs_runtime_and_persists_narrow_override(self):
+ response = self.service.prepare_app("com.example.Game")
self.assertTrue(response["success"])
+ self.assertTrue(response["prepared"])
+ self.assertTrue(response["owned"])
self.assertEqual(response["runtime_branch"], "24.08")
- self.assertEqual(response["support_status"], "needs-runtime")
- self.assertFalse(response["extension_installed"])
- self.assertEqual(
- self.service._run_flatpak_command.call_args_list[0].args[0],
- ["info", "--show-runtime", "com.example.Game"],
- )
- self.assertEqual(
- self.service._run_flatpak_command.call_args_list[1].args[0],
- ["list", "--user", "--runtime", "--columns=application,arch,branch"],
- )
+ self.assertEqual(self.user_branches, {"24.08"})
+ install_calls = [
+ call.args[0]
+ for call in self.service._run_flatpak_command.call_args_list
+ if call.args[0][0] == "install"
+ ]
self.assertEqual(
- self.service._run_flatpak_command.call_args_list[2].args[0],
- ["list", "--system", "--runtime", "--columns=application,arch,branch"],
+ install_calls,
+ [[
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ "flathub",
+ "org.freedesktop.Platform.VulkanLayer.lsfgvk//24.08",
+ ]],
)
+ status = self.service._app_override_status("com.example.Game")
+ self.assertTrue(status["prepared"])
+ content = self.service._override_path("com.example.Game").read_text(encoding="utf-8")
+ self.assertIn(str(self.service.config_dir) + ":ro", content)
+ self.assertIn(str(self.dll_dir) + ":ro", content)
+ self.assertIn("LSFGVK_CONFIG=" + str(self.service.config_file_path), content)
+ self.assertIn("LSFGVK_FLATPAK=1", content)
+ self.assertNotIn("ENABLE_GAMESCOPE_WSI", content)
+ state = json.loads(self.service.ownership_path.read_text(encoding="utf-8"))
+ self.assertEqual(state["plugin_owned_branches"], ["24.08"])
+ self.assertIn("com.example.Game", state["prepared_apps"])
+
+ def test_prepare_is_idempotent(self):
+ first = self.service.prepare_app("com.example.Game")
+ first_content = self.service._override_path("com.example.Game").read_bytes()
+ second = self.service.prepare_app("com.example.Game")
+
+ self.assertTrue(first["success"])
+ self.assertTrue(second["success"])
+ self.assertEqual(first_content, self.service._override_path("com.example.Game").read_bytes())
+ install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"]
+ self.assertEqual(len(install_calls), 1)
+
+ def test_replaces_extension_from_another_remote(self):
+ self.user_branches = {"24.08"}
+ self.user_extension_origin = "lsfgvk-origin"
- def test_resolve_maps_kde_and_gnome_runtimes_from_gl_metadata(self):
- metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n"
- for runtime in ("org.kde.Platform/x86_64/6.10", "org.gnome.Platform/x86_64/49"):
- with self.subTest(runtime=runtime):
- self.service._run_flatpak_command.reset_mock()
- self.runtime_ref = runtime
- self.runtime_metadata = metadata
- response = self.service.resolve_app_support("com.example.Game")
- self.assertEqual(response["runtime_branch"], "25.08")
- self.assertEqual(response["support_status"], "needs-runtime")
- self.assertEqual(
- self.service._run_flatpak_command.call_args_list[1].args[0],
- ["info", "--show-metadata", runtime],
- )
-
- def test_system_extension_is_ready_without_installing_a_user_copy(self):
- self.system_branches = {"24.08"}
-
- response = self.service.ensure_app_support("com.example.Game")
+ response = self.service.install_extension("24.08")
self.assertTrue(response["success"])
- self.assertEqual(response["support_status"], "ready")
- self.assertEqual(
- [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list],
- ["info", "list", "list"],
+ commands = [call.args[0] for call in self.service._run_flatpak_command.call_args_list]
+ self.assertIn(
+ [
+ "uninstall",
+ "--user",
+ "--noninteractive",
+ "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08",
+ ],
+ commands,
)
- self.assertFalse(any(call.args[0][0] == "install" for call in self.service._run_flatpak_command.call_args_list))
+ self.assertEqual(self.user_branches, {"24.08"})
- def test_install_records_only_a_new_user_owned_branch(self):
- response = self.service.install_extension("24.08")
+ def test_preinstalled_runtime_is_not_owned(self):
+ self.system_branches = {"24.08"}
+ response = self.service.prepare_app("com.example.Game")
self.assertTrue(response["success"])
- self.assertTrue(response["enabled"])
- self.assertTrue(response["installed"])
- install_args = self.service._run_flatpak_command.call_args_list[2].args[0]
- self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"])
- self.assertEqual(
- json.loads(self.service.ownership_path.read_text(encoding="utf-8")),
- {"version": 1, "plugin_owned_branches": ["24.08"]},
+ state = json.loads(self.service.ownership_path.read_text(encoding="utf-8"))
+ self.assertEqual(state["plugin_owned_branches"], [])
+ self.assertIn("com.example.Game", state["prepared_apps"])
+
+ def test_external_preparation_is_preserved(self):
+ path = self.service._override_path("com.example.Game")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ self._serialize_override(
+ [str(self.service.config_dir) + ":ro", str(self.dll_dir) + ":ro"],
+ ["DISABLE_LSFGVK", "DISABLE_LSFG"],
+ {
+ "LSFGVK_CONFIG": str(self.service.config_file_path),
+ "LSFGVK_FLATPAK": "1",
+ },
+ ),
+ encoding="utf-8",
)
+ self.system_branches = {"24.08"}
- def test_preexisting_branch_is_not_claimed_or_removed(self):
- self.user_branches = {"24.08"}
-
- install_response = self.service.install_extension("24.08")
- cleanup_response = self.service.remove_plugin_owned_extensions()
+ response = self.service.prepare_app("com.example.Game")
- self.assertTrue(install_response["success"])
- self.assertTrue(install_response["enabled"])
- self.assertTrue(install_response["installed"])
+ self.assertTrue(response["success"])
+ self.assertTrue(response["prepared"])
+ self.assertFalse(response["owned"])
self.assertFalse(self.service.ownership_path.exists())
- self.assertTrue(cleanup_response["success"])
- self.assertEqual(self.service._run_flatpak_command.call_count, 2)
- def test_extension_toggle_preserves_preexisting_branch(self):
- self.user_branches = {"24.08"}
+ def test_remove_restores_exact_previous_override(self):
+ self.system_branches = {"24.08"}
+ original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n"
+ path = self.service._override_path("com.example.Game")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(original, encoding="utf-8")
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
- enable_response = self.service.set_extension_enabled("24.08", True)
- disable_response = self.service.set_extension_enabled("24.08", False)
+ response = self.service.remove_app_override("com.example.Game")
- self.assertTrue(enable_response["success"])
- self.assertTrue(enable_response["enabled"])
- self.assertTrue(enable_response["installed"])
- self.assertTrue(disable_response["success"])
- self.assertTrue(disable_response["enabled"])
- self.assertTrue(disable_response["installed"])
- self.assertFalse(disable_response["removed"])
- self.assertEqual(self.user_branches, {"24.08"})
- self.assertEqual(
- [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list],
- ["list", "list", "list", "list"],
- )
+ self.assertTrue(response["success"])
+ self.assertEqual(path.read_text(encoding="utf-8"), original)
+ self.assertFalse(self.service.ownership_path.exists())
- def test_extension_toggle_removes_owned_user_branch_but_preserves_system_branch(self):
- self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True)
- self.service.ownership_path.write_text(
- json.dumps({"version": 1, "plugin_owned_branches": ["24.08"]}),
- encoding="utf-8",
- )
- self.user_branches = {"24.08"}
+ def test_remove_deletes_override_created_by_plugin(self):
self.system_branches = {"24.08"}
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+ path = self.service._override_path("com.example.Game")
+ self.assertTrue(path.exists())
- disable_response = self.service.set_extension_enabled("24.08", False)
- repeat_response = self.service.set_extension_enabled("24.08", False)
+ response = self.service.remove_app_override("com.example.Game")
- self.assertTrue(disable_response["success"])
- self.assertTrue(disable_response["enabled"])
- self.assertTrue(disable_response["removed"])
- self.assertTrue(repeat_response["success"])
- self.assertTrue(repeat_response["enabled"])
- self.assertTrue(repeat_response["installed"])
- self.assertEqual(self.user_branches, set())
- self.assertEqual(self.system_branches, {"24.08"})
+ self.assertTrue(response["success"])
+ self.assertFalse(path.exists())
self.assertFalse(self.service.ownership_path.exists())
- uninstall_commands = [
- call.args[0]
- for call in self.service._run_flatpak_command.call_args_list
- if call.args[0][0] == "uninstall"
- ]
- self.assertEqual(len(uninstall_commands), 1)
- def test_corrupt_ownership_metadata_fails_closed(self):
- self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True)
- self.service.ownership_path.write_text("{not-json", encoding="utf-8")
+ def test_remove_fails_closed_after_external_change(self):
+ self.system_branches = {"24.08"}
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+ path = self.service._override_path("com.example.Game")
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write("EXTERNAL=1\n")
- response = self.service.remove_plugin_owned_extensions()
+ response = self.service.remove_app_override("com.example.Game")
self.assertFalse(response["success"])
- self.assertTrue(response["ownership_uncertain"])
- self.assertEqual(self.service._run_flatpak_command.call_count, 0)
+ self.assertIn("changed after preparation", response["error"])
+ self.assertTrue(path.exists())
+ self.assertTrue(self.service.ownership_path.exists())
- def test_dangling_ownership_symlink_fails_closed(self):
- self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True)
- self.service.ownership_path.symlink_to(self.home / "missing-metadata")
-
- response = self.service.remove_plugin_owned_extensions()
-
- self.assertFalse(response["success"])
- self.assertTrue(response["ownership_uncertain"])
- self.assertEqual(self.service._run_flatpak_command.call_count, 0)
+ def test_full_cleanup_removes_only_owned_state(self):
+ self.system_branches = {"23.08"}
+ self.assertTrue(self.service.prepare_app("com.example.Game")["success"])
+ self.assertEqual(self.user_branches, {"24.08"})
- def test_ensure_app_support_installs_only_the_app_runtime_branch(self):
- response = self.service.ensure_app_support("com.example.Game")
+ response = self.service.remove_plugin_owned_environment()
self.assertTrue(response["success"])
- self.assertEqual(response["support_status"], "ready")
- self.assertEqual(response["runtime_branch"], "24.08")
- install_args = next(
- call.args[0]
- for call in self.service._run_flatpak_command.call_args_list
- if call.args[0][0] == "install"
- )
- self.assertEqual(install_args[0], "install")
- self.assertIn("--user", install_args)
- self.assertNotIn("23.08", install_args)
- self.assertEqual(
- json.loads(self.service.ownership_path.read_text(encoding="utf-8")),
- {"version": 1, "plugin_owned_branches": ["24.08"]},
- )
-
- def test_two_shortcuts_using_one_flatpak_share_one_extension_branch(self):
- first = self.service.ensure_app_support("net.pcsx2.PCSX2")
- second = self.service.ensure_app_support("net.pcsx2.PCSX2.Dev")
-
- self.assertEqual(first["support_status"], "ready")
- self.assertEqual(second["support_status"], "ready")
- install_commands = [
- call.args[0]
- for call in self.service._run_flatpak_command.call_args_list
- if call.args[0][0] == "install"
- ]
- self.assertEqual(len(install_commands), 1)
- self.assertEqual(
- json.loads(self.service.ownership_path.read_text(encoding="utf-8")),
- {"version": 1, "plugin_owned_branches": ["24.08"]},
- )
+ self.assertEqual(response["removed_apps"], ["com.example.Game"])
+ self.assertEqual(response["removed_branches"], ["24.08"])
+ self.assertEqual(self.user_branches, set())
+ self.assertEqual(self.system_branches, {"23.08"})
+ self.assertFalse(self.service.ownership_path.exists())
- def test_cleanup_removes_all_owned_branches_without_reusing_stale_metadata(self):
- self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True)
- self.service.ownership_path.write_text(
- json.dumps({"version": 1, "plugin_owned_branches": ["23.08", "24.08"]}),
- encoding="utf-8",
- )
- self.user_branches = {"23.08", "24.08"}
+ def test_corrupt_ownership_metadata_fails_closed(self):
+ self.service.ownership_path.write_text("{not-json", encoding="utf-8")
- response = self.service.remove_plugin_owned_extensions()
+ response = self.service.remove_plugin_owned_environment()
- self.assertTrue(response["success"])
- self.assertEqual(response["removed_branches"], ["23.08", "24.08"])
- self.assertFalse(self.service.ownership_path.exists())
- uninstall_commands = [
- call.args[0]
- for call in self.service._run_flatpak_command.call_args_list
- if call.args[0][0] == "uninstall"
- ]
- self.assertEqual(len(uninstall_commands), 2)
+ self.assertFalse(response["success"])
+ self.assertEqual(self.service._run_flatpak_command.call_count, 0)
if __name__ == "__main__":
diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py
index a392f85..7ab4621 100644
--- a/tests/test_plugin_migration.py
+++ b/tests/test_plugin_migration.py
@@ -6,7 +6,7 @@ from unittest.mock import Mock
class PluginMigrationTests(unittest.TestCase):
- def test_migration_only_runs_decky_path_migrations(self):
+ def _load_plugin(self):
decky = types.SimpleNamespace(
DECKY_HOME="/decky",
DECKY_USER_HOME="/home/deck",
@@ -17,12 +17,31 @@ class PluginMigrationTests(unittest.TestCase):
)
previous_decky = sys.modules.get("decky")
previous_tomllib = sys.modules.get("tomllib")
+ previous_plugin = sys.modules.pop("lsfg_vk.plugin", None)
sys.modules["decky"] = decky
sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock())
- try:
- sys.path.insert(0, "py_modules")
- from lsfg_vk.plugin import Plugin
+ sys.path.insert(0, "py_modules")
+ from lsfg_vk.plugin import Plugin
+ return Plugin, decky, previous_decky, previous_tomllib, previous_plugin
+
+ def _restore(self, previous_decky, previous_tomllib, previous_plugin):
+ sys.path.remove("py_modules")
+ if previous_decky is None:
+ sys.modules.pop("decky", None)
+ else:
+ sys.modules["decky"] = previous_decky
+ if previous_tomllib is None:
+ sys.modules.pop("tomllib", None)
+ else:
+ sys.modules["tomllib"] = previous_tomllib
+ if previous_plugin is None:
+ sys.modules.pop("lsfg_vk.plugin", None)
+ else:
+ sys.modules["lsfg_vk.plugin"] = previous_plugin
+ def test_migration_only_runs_decky_path_migrations(self):
+ Plugin, decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin()
+ try:
plugin = Plugin.__new__(Plugin)
plugin.installation_service = Mock()
plugin.flatpak_service = Mock()
@@ -33,17 +52,42 @@ class PluginMigrationTests(unittest.TestCase):
decky.migrate_settings.assert_called_once()
decky.migrate_runtime.assert_called_once()
plugin.installation_service.install.assert_not_called()
- plugin.flatpak_service.migrate_v2.assert_not_called()
+ plugin.flatpak_service.prepare_app.assert_not_called()
+ finally:
+ self._restore(previous_decky, previous_tomllib, previous_plugin)
+
+ def test_uninstall_cleans_owned_flatpak_state_and_profiles(self):
+ Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin()
+ try:
+ plugin = Plugin.__new__(Plugin)
+ plugin.installation_service = Mock()
+ plugin.flatpak_service = Mock()
+ plugin.configuration_service = Mock()
+ plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True}
+
+ asyncio.run(plugin._uninstall())
+
+ plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with()
+ plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with()
+ plugin.installation_service.cleanup_on_uninstall.assert_called_once_with()
+ finally:
+ self._restore(previous_decky, previous_tomllib, previous_plugin)
+
+ def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self):
+ Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin()
+ try:
+ plugin = Plugin.__new__(Plugin)
+ plugin.installation_service = Mock()
+ plugin.flatpak_service = Mock()
+ plugin.configuration_service = Mock()
+ plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"}
+
+ asyncio.run(plugin._uninstall())
+
+ plugin.configuration_service.reset_all_flatpak_configs.assert_not_called()
+ plugin.installation_service.cleanup_on_uninstall.assert_called_once_with()
finally:
- sys.path.remove("py_modules")
- if previous_decky is None:
- sys.modules.pop("decky", None)
- else:
- sys.modules["decky"] = previous_decky
- if previous_tomllib is None:
- sys.modules.pop("tomllib", None)
- else:
- sys.modules["tomllib"] = previous_tomllib
+ self._restore(previous_decky, previous_tomllib, previous_plugin)
if __name__ == "__main__":
diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py
index 636c87f..004ffb6 100644
--- a/tests/test_steam_service.py
+++ b/tests/test_steam_service.py
@@ -11,73 +11,11 @@ sys.modules.setdefault(
)
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
-from lsfg_vk.steam_service import SteamService, classify_shortcut_transport
+from lsfg_vk.steam_service import SteamService
-class SteamTransportTests(unittest.TestCase):
- def test_only_direct_canonical_flatpak_forms_are_classified(self):
- self.assertEqual(
- classify_shortcut_transport(
- "/usr/bin/flatpak",
- "run com.example.PCSX2 --fullscreen",
- ),
- {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "flatpak",
- "run com.example.PCSX2 --fullscreen",
- ),
- {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "/usr/bin/flatpak run com.example.PCSX2",
- "--fullscreen",
- ),
- {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "/usr/bin/bash",
- "~/launch-game.sh --fullscreen",
- ),
- {"kind": "host"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "/usr/bin/flatpak",
- "--user run com.example.PCSX2",
- ),
- {"kind": "host"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "/usr/bin/flatpak",
- "run bash ~/launch-game.sh",
- ),
- {"kind": "host"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "~/.lsfg",
- "run --branch=stable --arch=x86_64 com.example.PCSX2",
- ),
- {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"},
- )
- self.assertEqual(
- classify_shortcut_transport(
- "/home/deck/.lsfg",
- "run com.example.PCSX2",
- ),
- {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"},
- )
- self.assertEqual(
- classify_shortcut_transport("~/.lsfg", "--profile high"),
- {"kind": "host"},
- )
-
- def test_shortcut_data_preserves_transport_inputs(self):
+class SteamShortcutTests(unittest.TestCase):
+ def test_direct_flatpak_shortcut_is_ordinary_non_steam_metadata(self):
game = SteamService._shortcut_game(
{
"appid": 123456,
@@ -88,30 +26,33 @@ class SteamTransportTests(unittest.TestCase):
}
)
- self.assertEqual(game["appid"], "123456")
- self.assertEqual(game["transport"], {
- "kind": "flatpak",
- "flatpakAppId": "net.pcsx2.PCSX2",
+ self.assertEqual(game, {
+ "appid": "123456",
+ "name": "PCSX2 shortcut",
+ "nonSteam": True,
})
- self.assertEqual(game["executable"], "/usr/bin/flatpak")
- self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen")
- self.assertEqual(game["startDir"], "/home/deck/Games")
- def test_wrapped_flatpak_shortcut_remains_a_flatpak_target(self):
+ def test_emudeck_launcher_is_ordinary_non_steam_metadata(self):
game = SteamService._shortcut_game(
{
"appid": 987654,
- "AppName": "Wrapped Flatpak",
- "Exe": "~/.lsfg",
- "LaunchOptions": "run --branch=stable --arch=x86_64 com.example.Game",
+ "AppName": "1080 Snowboarding",
+ "Exe": '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64',
+ "LaunchOptions": "",
}
)
- self.assertEqual(game["transport"], {
- "kind": "flatpak",
- "flatpakAppId": "com.example.Game",
+ self.assertEqual(game, {
+ "appid": "987654",
+ "name": "1080 Snowboarding",
+ "nonSteam": True,
})
+ def test_shortcut_rejects_invalid_identity(self):
+ self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"}))
+ self.assertIsNone(SteamService._shortcut_game({"appid": 1, "AppName": ""}))
+ self.assertIsNone(SteamService._shortcut_game("bad"))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py
index 0632d93..5c291c7 100644
--- a/tests/test_wrapper_service.py
+++ b/tests/test_wrapper_service.py
@@ -1,4 +1,3 @@
-import os
import subprocess
import sys
import tempfile
@@ -24,10 +23,10 @@ class WrapperServiceTests(unittest.TestCase):
self.home.mkdir(parents=True)
self.service = WrapperService()
self.service.user_home = self.home
- self.service.local_bin_dir = self.home / ".local/bin"
self.service.config_dir = self.home / ".config/lsfg-vk"
+ self.service.config_file_path = self.service.config_dir / "conf.toml"
self.service.sidecar_path = self.service.config_dir / "workarounds.json"
- self.service.wrapper_path = self.service.local_bin_dir / "lsfg"
+ self.service.wrapper_path = self.home / ".lsfg"
def tearDown(self):
self.tempdir.cleanup()
@@ -59,7 +58,7 @@ class WrapperServiceTests(unittest.TestCase):
self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8"))
self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True))
- def test_dispatch_clears_managed_values_preserves_other_environment_and_appends_config(self):
+ def test_dispatch_exports_appid_config_and_workarounds(self):
self.service.set(
"123",
self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True),
@@ -71,12 +70,16 @@ class WrapperServiceTests(unittest.TestCase):
"DXVK_CONFIG": "dxgi.syncInterval = 0",
"DXVK_FRAME_RATE": "5",
"ENABLE_GAMESCOPE_WSI": "1",
+ "DISABLE_LSFGVK": "1",
+ "DISABLE_LSFG": "1",
"DISABLE_VKBASALT": "0",
"MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe",
"MANGOHUD": "1",
},
)
values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
+ self.assertEqual(values["SteamAppId"], "123")
+ self.assertEqual(values["LSFGVK_CONFIG"], str(self.service.config_file_path))
self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0")
self.assertEqual(values["DXVK_HDR"], "0")
self.assertEqual(values["SteamDeck"], "0")
@@ -88,6 +91,20 @@ class WrapperServiceTests(unittest.TestCase):
self.assertEqual(values["MANGOHUD"], "1")
self.assertNotIn("DXVK_FRAME_RATE", values)
self.assertNotIn("ENABLE_VKBASALT", values)
+ self.assertNotIn("DISABLE_LSFGVK", values)
+ self.assertNotIn("DISABLE_LSFG", values)
+
+ def test_wrapper_is_transport_agnostic(self):
+ self.service.set("123", self._state())
+ fake = self.home / "target"
+ fake.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n", encoding="utf-8")
+ fake.chmod(0o755)
+ result = self._run(123, str(fake), "run", "org.example.Game")
+ self.assertEqual(result.stdout.splitlines(), ["run", "org.example.Game"])
+ content = self.service.wrapper_path.read_text(encoding="utf-8")
+ self.assertNotIn("flatpakAppId", content)
+ self.assertNotIn("shortcut_exe", content)
+ self.assertNotIn("--filesystem", content)
def test_appid_fallback_and_unmatched_passthrough(self):
self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False))
@@ -101,7 +118,7 @@ class WrapperServiceTests(unittest.TestCase):
)
fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line)
self.assertEqual(fallback_values["SteamDeck"], "0")
- self.assertEqual(fallback_values["SteamGameId"], "456")
+ self.assertEqual(fallback_values["SteamAppId"], "456")
passthrough = subprocess.run(
[str(self.service.wrapper_path), "/usr/bin/env"],
@@ -114,139 +131,19 @@ class WrapperServiceTests(unittest.TestCase):
self.assertEqual(passthrough_values["KEEP"], "yes")
self.assertEqual(passthrough_values["DXVK_HDR"], "1")
- def test_flatpak_shortcut_receives_env_arguments_and_original_target(self):
- fake_flatpak = self.home / ".local/bin/flatpak"
- fake_flatpak.parent.mkdir(parents=True, exist_ok=True)
- fake_flatpak.write_text(
- "#!/bin/sh\n"
- "printf 'ARG:%s\\n' \"$@\"\n",
- encoding="utf-8",
- )
- fake_flatpak.chmod(0o755)
- self.service.set(
- "123",
- self._state(dxvkFrameRate=20, enableZink=True),
- str(fake_flatpak),
- False,
- {"kind": "flatpak", "flatpakAppId": "com.example.Game"},
- )
- result = self._run(123, "run", "com.example.Game", "--windowed", env={"DXVK_CONFIG": "foo=1"})
- args = result.stdout.splitlines()
- self.assertEqual(args[0], "ARG:run")
- self.assertIn("ARG:--filesystem=" + str(self.service.config_dir) + ":rw", args)
- self.assertIn("ARG:--filesystem=" + str(self.home / ".local/share/Steam/steamapps/common/Lossless Scaling") + ":ro", args)
- self.assertIn("ARG:--env=LSFGVK_CONFIG=" + str(self.service.config_file_path), args)
- self.assertIn("ARG:--env=LSFGVK_FLATPAK=1", args)
- self.assertIn("ARG:--env=SteamAppId=123", args)
- self.assertIn("ARG:--env=ENABLE_GAMESCOPE_WSI=0", args)
- self.assertIn("ARG:--env=DXVK_HDR=0", args)
- self.assertIn("ARG:--env=__GLX_VENDOR_LIBRARY_NAME=mesa", args)
- self.assertIn("ARG:--env=MESA_LOADER_DRIVER_OVERRIDE=zink", args)
- self.assertIn("ARG:--env=GALLIUM_DRIVER=zink", args)
- self.assertIn("ARG:--env=DXVK_CONFIG=foo=1; dxvk.maxFrameRate = 20", args)
- self.assertIn("ARG:com.example.Game", args)
- self.assertIn("ARG:--windowed", args)
-
- def test_flatpak_full_executable_form_is_preserved(self):
- fake_flatpak = self.home / ".local/bin/flatpak"
- fake_flatpak.parent.mkdir(parents=True, exist_ok=True)
- fake_flatpak.write_text(
- "#!/bin/sh\n"
- "printf 'ARG:%s\\n' \"$@\"\n",
- encoding="utf-8",
- )
- fake_flatpak.chmod(0o755)
- response = self.service.set(
- "123",
- self._state(),
- f"{fake_flatpak} run com.example.Game",
- False,
- {"kind": "flatpak", "flatpakAppId": "com.example.Game"},
- )
- self.assertTrue(response["success"])
- result = self._run(123, "--windowed")
- args = result.stdout.splitlines()
- self.assertEqual(args[0], "ARG:run")
- self.assertIn("ARG:com.example.Game", args)
- self.assertIn("ARG:--windowed", args)
-
- def test_host_transport_does_not_store_shortcut_target(self):
- self.service.set(
- "123",
- self._state(),
- "/usr/bin/flatpak",
- False,
- {"kind": "flatpak", "flatpakAppId": "com.example.Game"},
- )
- response = self.service.set(
- "123",
- self._state(),
- "/usr/bin/ignored",
- False,
- {"kind": "host"},
- )
- self.assertTrue(response["success"])
- self.assertIsNone(response["shortcut_exe"])
- self.assertIsNone(self.service.get("123")["shortcut_exe"])
-
- def test_flatpak_transport_rejects_non_run_invocation(self):
- fake_flatpak = self.home / ".local/bin/flatpak"
- fake_flatpak.parent.mkdir(parents=True, exist_ok=True)
- fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
- fake_flatpak.chmod(0o755)
- response = self.service.set(
- "123",
- self._state(),
- str(fake_flatpak),
- False,
- {"kind": "flatpak", "flatpakAppId": "com.example.Game"},
- )
- self.assertTrue(response["success"])
- result = subprocess.run(
- [str(self.service.wrapper_path), "bash", "launch-game.sh"],
- env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"},
- capture_output=True,
- text=True,
- )
- self.assertEqual(result.returncode, 64)
- self.assertIn("direct flatpak run", result.stderr)
-
- def test_flatpak_transport_rejects_external_app_id_change(self):
- fake_flatpak = self.home / ".local/bin/flatpak"
- fake_flatpak.parent.mkdir(parents=True, exist_ok=True)
- fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
- fake_flatpak.chmod(0o755)
- response = self.service.set(
- "123",
- self._state(),
- str(fake_flatpak),
- False,
- {"kind": "flatpak", "flatpakAppId": "com.example.Game"},
- )
- self.assertTrue(response["success"])
- result = subprocess.run(
- [str(self.service.wrapper_path), "run", "com.other.Game"],
- env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"},
- capture_output=True,
- text=True,
- )
- self.assertEqual(result.returncode, 64)
- self.assertIn("application ID changed externally", result.stderr)
-
def test_invalid_state_and_foreign_wrapper_fail_closed(self):
invalid = self.service.set("0", self.service.default_state())
self.assertFalse(invalid["success"])
invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61})
self.assertFalse(invalid["success"])
- self.service.local_bin_dir.mkdir(parents=True, exist_ok=True)
self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8")
response = self.service.set("123", self.service.default_state())
self.assertFalse(response["success"])
self.assertIn("unowned", response["error"])
self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n")
- def test_remove_keeps_a_safe_owned_passthrough_wrapper(self):
+ def test_remove_keeps_safe_passthrough_wrapper(self):
self.service.set("123", self.service.default_state())
response = self.service.remove("123")
self.assertTrue(response["success"])