summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 19:16:30 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 20:20:58 -0400
commitcc1e6f47dd9838b066822162a607d2859c043aff (patch)
tree532915c1e98c068ab8324d71a0dab160bb53de68
parent102a4a0f0ce4af303a12e7f6f1a4454f4e572a17 (diff)
downloaddecky-lsfg-vk-cc1e6f47dd9838b066822162a607d2859c043aff.tar.gz
decky-lsfg-vk-cc1e6f47dd9838b066822162a607d2859c043aff.zip
refactor: unify flatpak targets with steam profiles
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py724
-rw-r--r--py_modules/lsfg_vk/plugin.py109
-rw-r--r--py_modules/lsfg_vk/steam_service.py86
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py139
-rw-r--r--src/api/lsfgApi.ts81
-rw-r--r--src/components/ConfigurationTab.tsx22
-rw-r--r--src/components/Content.tsx41
-rw-r--r--src/components/FlatpaksTab.tsx196
-rw-r--r--src/components/GameConfigurationControls.tsx3
-rw-r--r--src/components/GameConfigurationSelector.tsx101
-rw-r--r--src/components/NowPlayingTab.tsx93
-rw-r--r--src/components/SetupTab.tsx142
-rw-r--r--src/components/WorkaroundsSection.tsx6
-rw-r--r--src/components/index.ts2
-rw-r--r--src/hooks/useGameConfiguration.ts78
-rw-r--r--src/hooks/usePerAppWorkarounds.ts24
-rw-r--r--src/types.d.ts1
-rw-r--r--tests/test_flatpak_overrides.py158
-rw-r--r--tests/test_flatpak_service.py231
-rw-r--r--tests/test_steam_service.py77
-rw-r--r--tests/test_wrapper_service.py79
21 files changed, 1468 insertions, 925 deletions
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py
index 0e89d3c..071b29c 100644
--- a/py_modules/lsfg_vk/flatpak_service.py
+++ b/py_modules/lsfg_vk/flatpak_service.py
@@ -1,33 +1,52 @@
+"""Flatpak runtime-extension infrastructure for unified game targets."""
+
+from __future__ import annotations
+
+import json
import os
import pwd
import re
import shutil
import subprocess
+import threading
from pathlib import Path
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional, Set, Tuple
from .base_service import BaseService
-from .config_schema import ConfigurationManager
from .constants import (
BIN_DIR,
FLATPAK_23_08_FILENAME,
FLATPAK_24_08_FILENAME,
FLATPAK_25_08_FILENAME,
)
-from .types import BaseResponse
class FlatpakService(BaseService):
+ """Resolve and provision only the runtime support a target actually needs.
+
+ Flatpak application permissions are deliberately not persisted here. The
+ generated per-AppID wrapper supplies the narrow launch-time permissions and
+ environment instead, while this service owns only the shared Vulkan layer
+ runtime extensions installed from the plugin bundle.
+ """
+
EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk"
SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08")
- COMPATIBILITY_ENV = (
- ("ENABLE_GAMESCOPE_WSI", "0"),
- ("DXVK_HDR", "0"),
+ OWNERSHIP_FILENAME = "flatpak_extensions.json"
+ OWNERSHIP_VERSION = 1
+ APP_ID_PATTERN = re.compile(
+ r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$"
)
+ BRANCH_PATTERN = re.compile(r"^[0-9]+\.[0-9]+$")
def __init__(self, logger=None):
super().__init__(logger)
- self.flatpak_command = None
+ self.flatpak_command: Optional[str] = None
+ self._lock = threading.RLock()
+
+ @property
+ def ownership_path(self) -> Path:
+ return self.config_dir / self.OWNERSHIP_FILENAME
def _get_clean_env(self) -> Dict[str, str]:
env = os.environ.copy()
@@ -61,20 +80,39 @@ class FlatpakService(BaseService):
if runuser is None:
raise FileNotFoundError("runuser command not available")
command = [runuser, "--user", target_user.pw_name, "--", *command]
- return subprocess.run(
- command,
- env=self._get_clean_env(),
- **kwargs,
- )
+ return subprocess.run(command, env=self._get_clean_env(), **kwargs)
@classmethod
- def _extension_ref(cls, version: str) -> str:
- return f"{cls.EXTENSION_ID}/x86_64/{version}"
+ def _validate_app_id(cls, app_id: str) -> str:
+ if not isinstance(app_id, str) or not cls.APP_ID_PATTERN.fullmatch(app_id):
+ raise ValueError("Invalid Flatpak application ID")
+ return app_id
@classmethod
- def _validate_runtime(cls, version: str) -> None:
+ def _validate_runtime(cls, version: str) -> str:
if version not in cls.SUPPORTED_RUNTIMES:
- raise ValueError("Unsupported Flatpak runtime")
+ raise ValueError(
+ f"Unsupported Flatpak runtime branch {version}; "
+ f"supported branches are {', '.join(cls.SUPPORTED_RUNTIMES)}"
+ )
+ return version
+
+ @classmethod
+ def _extension_ref(cls, version: str) -> str:
+ return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(version)}"
+
+ @classmethod
+ def runtime_branch_from_ref(cls, runtime_ref: str) -> str:
+ """Return the supported Freedesktop branch from a runtime ref."""
+ if not isinstance(runtime_ref, str):
+ raise ValueError("Flatpak did not return a runtime reference")
+ parts = runtime_ref.strip().split("/")
+ if len(parts) != 3 or parts[0] != "org.freedesktop.Platform":
+ raise ValueError(f"Unsupported Flatpak runtime reference: {runtime_ref}")
+ branch = parts[2]
+ if not cls.BRANCH_PATTERN.fullmatch(branch):
+ raise ValueError(f"Unrecognized Flatpak runtime branch: {branch}")
+ return cls._validate_runtime(branch)
@classmethod
def _bundle_filename(cls, version: str) -> str:
@@ -82,320 +120,432 @@ class FlatpakService(BaseService):
"23.08": FLATPAK_23_08_FILENAME,
"24.08": FLATPAK_24_08_FILENAME,
"25.08": FLATPAK_25_08_FILENAME,
- }[version]
+ }[cls._validate_runtime(version)]
def _bundled_extension_path(self, version: str) -> Path:
- self._validate_runtime(version)
- return Path(__file__).resolve().parent.parent.parent / BIN_DIR / self._bundle_filename(version)
+ return (
+ Path(__file__).resolve().parent.parent.parent
+ / BIN_DIR
+ / self._bundle_filename(version)
+ )
- def get_extension_status(self) -> Dict[str, Any]:
- try:
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
+ def _installed_extension_branches(self) -> Set[str]:
+ result = self._run_flatpak_command(
+ ["list", "--runtime", "--columns=application,arch,branch"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ installed: Set[str] = set()
+ for line in result.stdout.splitlines():
+ if not line.strip():
+ continue
+ fields = line.split("\t")
+ if len(fields) < 3:
+ fields = line.split()
+ if len(fields) < 3:
+ continue
+ application, arch, branch = (field.strip() for field in fields[:3])
+ if application == self.EXTENSION_ID and arch == "x86_64":
+ installed.add(branch)
+ return installed
- result = self._run_flatpak_command(
- ["list", "--user", "--runtime", "--columns=application,arch,branch"],
- capture_output=True,
- text=True,
- check=True,
- )
- installed = {
- tuple(line.split("\t")[:3])
- for line in result.stdout.splitlines()
- if line.strip()
+ def _read_owned_branches(self) -> Tuple[Set[str], bool]:
+ """Read ownership without guessing when metadata is damaged."""
+ path = self.ownership_path
+ if path.is_symlink():
+ self.log.warning(f"Flatpak ownership metadata is not a regular file: {path}")
+ return set(), True
+ if not path.exists():
+ return set(), False
+ if not path.is_file():
+ self.log.warning(f"Flatpak ownership metadata is not a regular file: {path}")
+ return set(), True
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(raw, dict) or raw.get("version") != self.OWNERSHIP_VERSION:
+ raise ValueError("unsupported ownership metadata version")
+ branches = raw.get("plugin_owned_branches")
+ if not isinstance(branches, list):
+ raise ValueError("plugin_owned_branches is not a list")
+ normalized = {
+ self._validate_runtime(branch)
+ for branch in branches
+ if isinstance(branch, str)
}
- return self._success_response(
- BaseResponse,
- "Flatpak runtime status retrieved",
- installed_23_08=(self.EXTENSION_ID, "x86_64", "23.08") in installed,
- installed_24_08=(self.EXTENSION_ID, "x86_64", "24.08") in installed,
- installed_25_08=(self.EXTENSION_ID, "x86_64", "25.08") in installed,
- )
- except Exception as error:
- return self._error_response(
- BaseResponse,
- str(error),
- installed_23_08=False,
- installed_24_08=False,
- installed_25_08=False,
- )
+ if len(normalized) != len(branches):
+ raise ValueError("ownership metadata contains invalid branches")
+ return normalized, False
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
+ self.log.warning(f"Could not trust Flatpak ownership metadata: {error}")
+ return set(), True
- def install_extension(self, version: str) -> Dict[str, Any]:
+ def _write_owned_branches(self, branches: Set[str]) -> None:
+ if not branches:
+ if self.ownership_path.exists() or self.ownership_path.is_symlink():
+ self.ownership_path.unlink()
+ return
+ document = {
+ "version": self.OWNERSHIP_VERSION,
+ "plugin_owned_branches": sorted(branches),
+ }
+ self._write_file(self.ownership_path, json.dumps(document, indent=2) + "\n")
+
+ def get_extension_status(self) -> Dict[str, Any]:
+ """Return global extension inventory for Setup diagnostics."""
try:
- self._validate_runtime(version)
if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
- bundle_path = self._bundled_extension_path(version)
- if not bundle_path.is_file():
- raise FileNotFoundError(
- f"Bundled Flatpak extension not found at {bundle_path}; reinstall the plugin"
+ return self._success_response(
+ dict,
+ "Flatpak is not available",
+ available=False,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=[],
+ owned_branches=[],
+ ownership_uncertain=False,
)
- result = self._run_flatpak_command(
- [
- "install",
- "--user",
- "--noninteractive",
- "--or-update",
- str(bundle_path),
- ],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Flatpak installation failed")
+ installed = self._installed_extension_branches()
+ owned, uncertain = self._read_owned_branches()
return self._success_response(
- BaseResponse,
- f"lsfg-vk {version} runtime extension installed from the bundled asset",
+ dict,
+ "Flatpak runtime extension status retrieved",
+ available=True,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=sorted(installed),
+ owned_branches=sorted(owned),
+ ownership_uncertain=uncertain,
)
except Exception as error:
- return self._error_response(BaseResponse, str(error))
-
- def uninstall_extension(self, version: str) -> Dict[str, Any]:
- try:
- self._validate_runtime(version)
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
- result = self._run_flatpak_command(
- ["uninstall", "--user", "--noninteractive", self._extension_ref(version)],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Flatpak uninstall failed")
- return self._success_response(
- BaseResponse,
- f"lsfg-vk {version} runtime extension uninstalled",
+ return self._error_response(
+ dict,
+ str(error),
+ available=self.check_flatpak_available(),
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=[],
+ owned_branches=[],
+ ownership_uncertain=False,
)
- except Exception as error:
- return self._error_response(BaseResponse, str(error))
- def _override_output(self, app_id: str) -> str:
+ def get_flatpak_support_status(self) -> Dict[str, Any]:
+ return self.get_extension_status()
+
+ def _resolve_runtime(self, app_id: str) -> Dict[str, Any]:
+ self._validate_app_id(app_id)
+ if not self.check_flatpak_available():
+ raise FileNotFoundError("Flatpak is not available on this system")
result = self._run_flatpak_command(
- ["override", "--user", "--show", app_id],
+ ["info", "--show-runtime", app_id],
capture_output=True,
text=True,
)
if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Failed to read Flatpak overrides")
- return result.stdout
-
- def _dll_directory(self) -> Path:
- if self.config_file_path.exists():
- try:
- profile_data = ConfigurationManager.parse_toml_content_multi_profile(
- self.config_file_path.read_text(encoding="utf-8")
- )
- dll_path = profile_data["global_config"].get("dll")
- if dll_path:
- return Path(dll_path).parent
- except Exception:
- pass
-
- return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling"
+ raise OSError(result.stderr.strip() or f"Could not inspect Flatpak app {app_id}")
+ runtime_ref = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
+ branch = self.runtime_branch_from_ref(runtime_ref)
+ return {"runtime": runtime_ref, "runtime_branch": branch}
- def _override_paths(self) -> Dict[str, str]:
- return {
- "config_dir": str(self.config_dir),
- "config_file": str(self.config_file_path),
- "dll_dir": str(self._dll_directory()),
- "legacy_home": str(self.user_home),
- "legacy_dll": str(
- self.user_home
- / ".local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"
- ),
- "legacy_script": str(self.legacy_script_path),
- }
-
- def _override_file_path(self, app_id: str) -> Path:
- if not app_id or Path(app_id).name != app_id or app_id in {".", ".."}:
- raise ValueError("Invalid Flatpak application ID")
- return self.user_home / ".local/share/flatpak/overrides" / app_id
-
- @staticmethod
- def _filesystem_entry_path(entry: str) -> str:
- value = entry.strip()
- if value.startswith("!"):
- value = value[1:]
- for suffix in (":ro", ":rw", ":create"):
- if value.endswith(suffix):
- return value[: -len(suffix)]
- return value
-
- @staticmethod
- def _override_value(content: str, key: str) -> str:
- match = re.search(rf"(?m)^[ \t]*{re.escape(key)}[ \t]*=([^\r\n]*)", content)
- return match.group(1).strip() if match else ""
-
- def _clean_override_file(self, app_id: str, paths: Dict[str, str]) -> bool:
- path = self._override_file_path(app_id)
- if not path.is_file():
- return False
-
- owner = path.stat().st_uid, path.stat().st_gid
- original = path.read_text(encoding="utf-8")
- managed_paths = {
- paths[name]
- for name in ("config_dir", "dll_dir", "legacy_home", "legacy_dll", "legacy_script")
- }
- managed_env = {
- "LSFGVK_CONFIG",
- "LSFG_CONFIG",
- *(name for name, _ in self.COMPATIBILITY_ENV),
- }
- def clean_list(match):
- key, value, newline = match.groups()
- is_filesystem = key.split("=", 1)[0].strip() == "filesystems"
- keep = [item for item in value.split(";") if item and (
- self._filesystem_entry_path(item) not in managed_paths
- if is_filesystem else item.strip() not in managed_env
- )]
- return f"{key}{';'.join(keep)}{newline}" if keep else ""
-
- updated = re.sub(
- r"(?m)^([ \t]*(?:filesystems|unset-environment)[ \t]*=)([^\r\n]*)(\r?\n|$)",
- clean_list,
- original,
- )
- env_pattern = "|".join(re.escape(name) for name in managed_env)
- updated = re.sub(
- rf"(?m)^[ \t]*(?:{env_pattern})[ \t]*=[^\r\n]*(?:\r?\n|$)",
- "",
- updated,
- )
- if updated != original:
- self._write_file(path, updated)
- if os.geteuid() == 0:
- os.chown(path, *owner)
- return updated != original
-
- def _check_app_override_status(self, app_id: str) -> Dict[str, bool]:
- output = self._override_output(app_id)
- paths = self._override_paths()
- filesystem_entries = self._override_value(output, "filesystems").split(";")
- positive_filesystems = {
- self._filesystem_entry_path(entry)
- for entry in filesystem_entries
- if not entry.strip().startswith("!")
- }
- blocked_filesystems = {
- self._filesystem_entry_path(entry)
- for entry in filesystem_entries
- if entry.strip().startswith("!")
- }
- unset_environment = set(
- item.strip()
- for item in self._override_value(output, "unset-environment").split(";")
- if item.strip()
- )
- return {
- "filesystem": all(
- path in positive_filesystems and path not in blocked_filesystems
- for path in (paths["config_dir"], paths["dll_dir"])
- ),
- "env": all(
- self._override_value(output, name) == value and name not in unset_environment
- for name, value in (
- ("LSFGVK_CONFIG", paths["config_file"]),
- *self.COMPATIBILITY_ENV,
- )
- ),
- }
-
- def get_flatpak_apps(self) -> Dict[str, Any]:
+ def resolve_app_support(self, app_id: str) -> Dict[str, Any]:
+ """Resolve the exact runtime branch required by one Flatpak app."""
try:
- if not self.check_flatpak_available():
- raise FileNotFoundError("Flatpak is not available on this system")
- result = self._run_flatpak_command(
- ["list", "--app", "--columns=name,application"],
- capture_output=True,
- text=True,
- check=True,
+ app_id = self._validate_app_id(app_id)
+ resolved = self._resolve_runtime(app_id)
+ installed = self._installed_extension_branches()
+ branch = resolved["runtime_branch"]
+ ready = branch in installed
+ 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=resolved["runtime"],
+ runtime_branch=branch,
+ support_status="ready" if ready else "needs-runtime",
+ extension_installed=ready,
+ installed_branches=sorted(installed),
)
- apps = []
- for line in result.stdout.splitlines():
- parts = line.split("\t", 1)
- if len(parts) != 2:
- continue
- status = self._check_app_override_status(parts[1])
- apps.append(
- {
- "app_id": parts[1],
- "app_name": parts[0],
- "has_filesystem_override": status["filesystem"],
- "has_env_override": status["env"],
- }
- )
+ except ValueError as error:
return self._success_response(
- BaseResponse,
- f"Found {len(apps)} Flatpak applications",
- apps=apps,
- total_apps=len(apps),
+ 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(
- BaseResponse,
+ dict,
str(error),
- apps=[],
- total_apps=0,
+ flatpak_app_id=app_id,
+ runtime=None,
+ runtime_branch=None,
+ support_status="error",
+ extension_installed=False,
+ installed_branches=[],
)
- def set_app_override(self, app_id: str) -> Dict[str, Any]:
+ def install_extension(self, version: str) -> Dict[str, Any]:
+ """Install one missing branch and record ownership only after readback."""
try:
+ version = self._validate_runtime(version)
if not self.check_flatpak_available():
raise FileNotFoundError("Flatpak is not available on this system")
- paths = self._override_paths()
- self._clean_override_file(app_id, paths)
- result = self._run_flatpak_command(
- [
- "override",
- "--user",
- f"--filesystem={paths['config_dir']}:rw",
- f"--filesystem={paths['dll_dir']}:ro",
- f"--env=LSFGVK_CONFIG={paths['config_file']}",
- *(f"--env={name}={value}" for name, value in self.COMPATIBILITY_ENV),
- app_id,
- ],
- capture_output=True,
- text=True,
+ bundle_path = self._bundled_extension_path(version)
+ if not bundle_path.is_file():
+ raise FileNotFoundError(
+ f"Bundled Flatpak extension not found at {bundle_path}; reinstall the plugin"
+ )
+ with self._lock:
+ owned, uncertain = self._read_owned_branches()
+ if uncertain:
+ raise RuntimeError(
+ "Flatpak ownership metadata is uncertain; refusing to install "
+ "until it is repaired"
+ )
+ installed_before = self._installed_extension_branches()
+ if version in installed_before:
+ return self._success_response(
+ dict,
+ f"lsfg-vk {version} runtime extension is already installed",
+ runtime_branch=version,
+ installed=True,
+ owned_by_plugin=version in owned,
+ )
+ result = self._run_flatpak_command(
+ [
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ str(bundle_path),
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Flatpak installation failed")
+ installed_after = self._installed_extension_branches()
+ if version not in installed_after:
+ raise RuntimeError(
+ f"Flatpak install completed but {self._extension_ref(version)} "
+ "was not visible afterwards"
+ )
+ owned.add(version)
+ self._write_owned_branches(owned)
+ return self._success_response(
+ dict,
+ f"lsfg-vk {version} runtime extension installed",
+ runtime_branch=version,
+ installed=True,
+ owned_by_plugin=True,
+ )
+ except Exception as error:
+ return self._error_response(
+ dict,
+ str(error),
+ runtime_branch=version,
+ installed=False,
+ owned_by_plugin=False,
)
- if result.returncode != 0:
- raise OSError(result.stderr.strip() or "Failed to set Flatpak overrides")
- status = self._check_app_override_status(app_id)
- if not status["filesystem"] or not status["env"]:
- raise RuntimeError("Flatpak overrides could not be verified after setting")
+
+ def ensure_extension(self, version: str) -> Dict[str, Any]:
+ status = self.get_extension_status()
+ if not status.get("success"):
+ return status
+ if not status.get("available"):
+ return self._error_response(
+ dict,
+ "Flatpak is not available on this system",
+ runtime_branch=version,
+ support_status="error",
+ )
+ try:
+ version = self._validate_runtime(version)
+ except ValueError as error:
+ return self._error_response(dict, str(error), runtime_branch=version, support_status="unsupported")
+ if version in status.get("installed_branches", []):
return self._success_response(
- BaseResponse,
- f"lsfg-vk overrides set for {app_id}",
- app_id=app_id,
- operation="set",
+ dict,
+ f"lsfg-vk {version} runtime extension is ready",
+ runtime_branch=version,
+ installed=True,
+ owned_by_plugin=version in status.get("owned_branches", []),
)
- except Exception as error:
+ return self.install_extension(version)
+
+ def ensure_app_support(self, app_id: str) -> Dict[str, Any]:
+ """Provision only the branch returned by flatpak info for this app."""
+ resolved = self.resolve_app_support(app_id)
+ if not resolved.get("success") or resolved.get("support_status") != "needs-runtime":
+ return resolved
+ branch = resolved.get("runtime_branch")
+ result = self.ensure_extension(branch)
+ if not result.get("success"):
return self._error_response(
- BaseResponse,
- str(error),
- app_id=app_id,
- operation="set",
+ dict,
+ result.get("error") or "Could not install the required Flatpak runtime extension",
+ flatpak_app_id=app_id,
+ runtime=resolved.get("runtime"),
+ runtime_branch=branch,
+ support_status="error",
+ extension_installed=False,
)
+ final = self.resolve_app_support(app_id)
+ if final.get("success") and final.get("support_status") == "ready":
+ return final
+ return self._error_response(
+ dict,
+ final.get("error") or "Required Flatpak runtime extension could not be verified",
+ flatpak_app_id=app_id,
+ runtime=resolved.get("runtime"),
+ runtime_branch=branch,
+ support_status="error",
+ extension_installed=False,
+ )
- def remove_app_override(self, app_id: str) -> Dict[str, Any]:
+ def uninstall_extension(self, version: str) -> Dict[str, Any]:
+ """Uninstall only when explicitly requested for a plugin-owned branch."""
try:
+ version = self._validate_runtime(version)
if not self.check_flatpak_available():
raise FileNotFoundError("Flatpak is not available on this system")
- paths = self._override_paths()
- self._clean_override_file(app_id, paths)
- status = self._check_app_override_status(app_id)
- if status["filesystem"] or status["env"]:
- raise RuntimeError("Flatpak overrides could not be verified after removal")
- return self._success_response(
- BaseResponse,
- f"lsfg-vk overrides removed for {app_id}",
- app_id=app_id,
- operation="remove",
+ with self._lock:
+ owned, uncertain = self._read_owned_branches()
+ if uncertain:
+ raise RuntimeError(
+ "Flatpak ownership metadata is uncertain; refusing to uninstall"
+ )
+ if version not in owned:
+ return self._success_response(
+ dict,
+ f"Preserved Flatpak extension {version}; it is not plugin-owned",
+ runtime_branch=version,
+ removed=False,
+ preserved=True,
+ )
+ installed = self._installed_extension_branches()
+ if version in installed:
+ result = self._run_flatpak_command(
+ [
+ "uninstall",
+ "--user",
+ "--noninteractive",
+ self._extension_ref(version),
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Flatpak uninstall failed")
+ if version in self._installed_extension_branches():
+ raise RuntimeError(
+ f"Flatpak uninstall completed but {self._extension_ref(version)} "
+ "is still installed"
+ )
+ owned.remove(version)
+ self._write_owned_branches(owned)
+ return self._success_response(
+ dict,
+ f"Plugin-owned lsfg-vk {version} runtime extension removed",
+ runtime_branch=version,
+ removed=True,
+ preserved=False,
+ )
+ except Exception as error:
+ return self._error_response(
+ dict,
+ str(error),
+ runtime_branch=version,
+ removed=False,
+ preserved=False,
)
+
+ def remove_plugin_owned_extensions(self) -> Dict[str, Any]:
+ """Uninstall only branches recorded as installed by this plugin."""
+ try:
+ with self._lock:
+ owned, uncertain = self._read_owned_branches()
+ if uncertain:
+ return self._error_response(
+ dict,
+ "Flatpak ownership metadata is uncertain; no extensions were removed",
+ removed_branches=[],
+ preserved_branches=[],
+ ownership_uncertain=True,
+ )
+ if not owned:
+ return self._success_response(
+ dict,
+ "No plugin-owned Flatpak extensions to remove",
+ removed_branches=[],
+ preserved_branches=[],
+ ownership_uncertain=False,
+ )
+ if not self.check_flatpak_available():
+ return self._error_response(
+ dict,
+ "Flatpak is not available; plugin-owned extension metadata was preserved",
+ removed_branches=[],
+ preserved_branches=sorted(owned),
+ ownership_uncertain=False,
+ )
+ removed: List[str] = []
+ failures: List[str] = []
+ for branch in sorted(owned):
+ try:
+ installed = self._installed_extension_branches()
+ if branch in installed:
+ 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 "Flatpak uninstall failed")
+ if branch in self._installed_extension_branches():
+ raise RuntimeError(
+ f"Flatpak uninstall completed but {self._extension_ref(branch)} "
+ "is still installed"
+ )
+ removed.append(branch)
+ except Exception as error:
+ failures.append(f"{branch}: {error}")
+ remaining = owned - set(removed)
+ self._write_owned_branches(remaining)
+ if failures:
+ return self._error_response(
+ dict,
+ "; ".join(failures),
+ removed_branches=removed,
+ preserved_branches=sorted(remaining),
+ ownership_uncertain=False,
+ )
+ return self._success_response(
+ dict,
+ "Plugin-owned Flatpak extensions removed",
+ removed_branches=removed,
+ preserved_branches=[],
+ ownership_uncertain=False,
+ )
except Exception as error:
return self._error_response(
- BaseResponse,
+ dict,
str(error),
- app_id=app_id,
- operation="remove",
+ removed_branches=[],
+ preserved_branches=[],
+ ownership_uncertain=False,
)
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index f80c635..cb2d3df 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -67,7 +67,24 @@ class Plugin:
return self.configuration_service.get_game_configs()
async def get_installed_games(self) -> Dict[str, Any]:
- return self.steam_service.get_installed_games()
+ result = self.steam_service.get_installed_games()
+ if not result.get("success"):
+ return result
+
+ support_cache: Dict[str, Dict[str, Any]] = {}
+ for game in result.get("games", []):
+ transport = game.get("transport") if isinstance(game, dict) else None
+ if not isinstance(transport, dict) or transport.get("kind") != "flatpak":
+ continue
+ flatpak_app_id = transport.get("flatpakAppId")
+ if not isinstance(flatpak_app_id, str) or not flatpak_app_id:
+ continue
+ if flatpak_app_id not in support_cache:
+ support_cache[flatpak_app_id] = self.flatpak_service.resolve_app_support(
+ flatpak_app_id
+ )
+ game["flatpakSupport"] = support_cache[flatpak_app_id]
+ return result
async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
return self.configuration_service.update_game_config(appid, game_name, config)
@@ -87,8 +104,15 @@ class Plugin:
state: Dict[str, Any],
shortcut_exe: Optional[str] = None,
command_token_added: bool = False,
+ transport: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
- return self.wrapper_service.set(appid, state, shortcut_exe, command_token_added)
+ return self.wrapper_service.set(
+ appid,
+ state,
+ shortcut_exe,
+ command_token_added,
+ transport,
+ )
async def remove_workaround_state(self, appid: str) -> Dict[str, Any]:
return self.wrapper_service.remove(appid)
@@ -124,68 +148,20 @@ class Plugin:
"error": f"Error reading config file: {str(e)}"
}
- async def check_flatpak_extension_status(self) -> Dict[str, Any]:
- """Check status of lsfg-vk Flatpak runtime extensions
-
- Returns:
- FlatpakExtensionStatus dict with installation status for all supported runtime versions
- """
- return self.flatpak_service.get_extension_status()
-
- async def install_flatpak_extension(self, version: str) -> Dict[str, Any]:
- """Install lsfg-vk Flatpak runtime extension
-
- Args:
- version: Runtime version to install ("23.08", "24.08", or "25.08")
-
- Returns:
- BaseResponse dict with success status and message/error
- """
- return self.flatpak_service.install_extension(version)
-
- async def uninstall_flatpak_extension(self, version: str) -> Dict[str, Any]:
- """Uninstall lsfg-vk Flatpak runtime extension
-
- Args:
- version: Runtime version to uninstall ("23.08", "24.08", or "25.08")
-
- Returns:
- BaseResponse dict with success status and message/error
- """
- return self.flatpak_service.uninstall_extension(version)
-
- async def get_flatpak_apps(self) -> Dict[str, Any]:
- """Get list of installed Flatpak apps and their lsfg-vk override status
-
- Returns:
- FlatpakAppInfo dict with apps list and override status
- """
- return self.flatpak_service.get_flatpak_apps()
-
async def get_lossless_scaling_branch_status(self) -> Dict[str, Any]:
return self.steam_service.get_branch_status()
- async def set_flatpak_app_override(self, app_id: str) -> Dict[str, Any]:
- """Set lsfg-vk overrides for a Flatpak app
-
- Args:
- app_id: Flatpak application ID
-
- Returns:
- FlatpakOverrideResponse dict with operation result
- """
- return self.flatpak_service.set_app_override(app_id)
+ async def get_flatpak_support_status(self) -> Dict[str, Any]:
+ return self.flatpak_service.get_flatpak_support_status()
- async def remove_flatpak_app_override(self, app_id: str) -> Dict[str, Any]:
- """Remove lsfg-vk overrides for a Flatpak app
-
- Args:
- app_id: Flatpak application ID
-
- Returns:
- FlatpakOverrideResponse dict with operation result
- """
- return self.flatpak_service.remove_app_override(app_id)
+ async def ensure_flatpak_support(self, flatpak_app_id: str) -> Dict[str, Any]:
+ return self.flatpak_service.ensure_app_support(flatpak_app_id)
+
+ async def repair_flatpak_support(self, flatpak_app_id: str) -> Dict[str, Any]:
+ return self.flatpak_service.ensure_app_support(flatpak_app_id)
+
+ async def remove_plugin_owned_flatpak_extensions(self) -> Dict[str, Any]:
+ return self.flatpak_service.remove_plugin_owned_extensions()
async def _main(self):
"""
@@ -224,16 +200,9 @@ class Plugin:
self.installation_service.cleanup_on_uninstall()
try:
- extension_status = self.flatpak_service.get_extension_status()
- for version, key in (
- ("23.08", "installed_23_08"),
- ("24.08", "installed_24_08"),
- ("25.08", "installed_25_08"),
- ):
- if extension_status.get(key):
- result = self.flatpak_service.uninstall_extension(version)
- if not result.get("success"):
- decky.logger.warning(result.get("error"))
+ result = self.flatpak_service.remove_plugin_owned_extensions()
+ if not result.get("success"):
+ decky.logger.warning(result.get("error"))
except Exception as error:
decky.logger.error(f"Error during Flatpak cleanup: {error}")
diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py
index 9a6570f..b3bdb69 100644
--- a/py_modules/lsfg_vk/steam_service.py
+++ b/py_modules/lsfg_vk/steam_service.py
@@ -1,4 +1,5 @@
import re
+import shlex
from pathlib import Path
from typing import Dict, Optional, Tuple
@@ -6,6 +7,46 @@ from .base_service import BaseService
from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH
+_FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$")
+
+
+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 classify_shortcut_transport(
+ executable: Optional[str],
+ launch_options: Optional[str] = None,
+) -> Dict[str, object]:
+ """Classify only direct Flatpak invocations; leave shell launchers on host."""
+ 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"}
+
+ if executable_tokens[0] != "/usr/bin/flatpak":
+ 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 == "--":
+ continue
+ if argument.startswith("-"):
+ continue
+ if _FLATPAK_APP_ID.fullmatch(argument):
+ return {"kind": "flatpak", "flatpakAppId": argument}
+ return {"kind": "host"}
+ return {"kind": "host"}
+
+
class SteamService(BaseService):
DEFAULT_BRANCH = "public"
MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf"
@@ -114,7 +155,43 @@ class SteamService(BaseService):
name = shortcut.get("AppName") or shortcut.get("appname")
if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name:
return None
- return {"appid": str(appid & 0xffffffff), "name": name, "nonSteam": True}
+ executable = next(
+ (
+ shortcut.get(key)
+ for key in ("Exe", "exe", "executable")
+ if isinstance(shortcut.get(key), str)
+ ),
+ None,
+ )
+ launch_options = next(
+ (
+ shortcut.get(key)
+ for key in ("LaunchOptions", "launchoptions", "launch_options", "arguments")
+ if isinstance(shortcut.get(key), str)
+ ),
+ None,
+ )
+ start_dir = next(
+ (
+ shortcut.get(key)
+ for key in ("StartDir", "startdir", "start_dir")
+ if isinstance(shortcut.get(key), str)
+ ),
+ None,
+ )
+ game: Dict[str, object] = {
+ "appid": str(appid & 0xffffffff),
+ "name": name,
+ "nonSteam": True,
+ "transport": classify_shortcut_transport(executable, launch_options),
+ }
+ if executable is not None:
+ game["executable"] = executable
+ if launch_options is not None:
+ game["arguments"] = launch_options
+ if start_dir is not None:
+ game["startDir"] = start_dir
+ return game
def _shortcut_games(self):
games = {}
@@ -294,7 +371,12 @@ class SteamService(BaseService):
if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS:
continue
name = self._section_value(content, "AppState", "name") or f"App {appid}"
- games[appid] = {"appid": appid, "name": name, "nonSteam": False}
+ games[appid] = {
+ "appid": appid,
+ "name": name,
+ "nonSteam": False,
+ "transport": {"kind": "host"},
+ }
for game in self._shortcut_games():
games.setdefault(str(game["appid"]), game)
return self._success_response(
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
index a3cba6e..dae565b 100644
--- a/py_modules/lsfg_vk/wrapper_service.py
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -16,8 +16,10 @@ from .constants import WRAPPER_FILENAME
class WrapperService(BaseService):
"""Persist workaround state and compile it into a safe POSIX wrapper."""
- FORMAT_VERSION = 1
- MARKER = "# lsfg-vk-wrapper-format: 1"
+ LEGACY_FORMAT_VERSION = 1
+ FORMAT_VERSION = 2
+ LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1"
+ MARKER = "# lsfg-vk-wrapper-format: 2"
WRAPPER_TOKEN = "~/.lsfg"
STATE_FIELDS = (
"dxvkFrameRate",
@@ -82,12 +84,38 @@ 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")
@@ -105,7 +133,10 @@ class WrapperService(BaseService):
@classmethod
def _validate_document(cls, raw: Any) -> Dict[str, Any]:
- if not isinstance(raw, dict) or raw.get("version") != cls.FORMAT_VERSION:
+ if not isinstance(raw, dict) or raw.get("version") not in (
+ cls.LEGACY_FORMAT_VERSION,
+ cls.FORMAT_VERSION,
+ ):
raise ValueError("Unsupported lsfg-vk workaround state version")
apps = raw.get("apps")
if not isinstance(apps, dict):
@@ -142,7 +173,7 @@ class WrapperService(BaseService):
prefix = "\n".join(self.wrapper_path.read_text(encoding="utf-8").splitlines()[:8])
except OSError as error:
raise RuntimeError(f"Could not read lsfg wrapper: {error}") from error
- return self.MARKER in prefix
+ return self.MARKER in prefix or self.LEGACY_MARKER in prefix
def _assert_wrapper_owned_or_absent(self) -> bool:
if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink():
@@ -157,6 +188,17 @@ class WrapperService(BaseService):
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)]
@@ -194,9 +236,31 @@ class WrapperService(BaseService):
lines.append(f" shortcut_exe={cls._shell(shortcut_exe or '')}")
return lines
- @classmethod
- def _flatpak_args(cls, state: Dict[str, Any]) -> list[str]:
+ 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
@@ -230,11 +294,10 @@ class WrapperService(BaseService):
" fi",
]
- @classmethod
- def _render_wrapper(cls, document: Dict[str, Any]) -> str:
+ def _render_wrapper(self, document: Dict[str, Any]) -> str:
lines = [
"#!/bin/sh",
- cls.MARKER,
+ self.MARKER,
"# Generated by Decky LSFG-VK; edits will be rejected on the next update.",
"",
"appid=",
@@ -260,29 +323,61 @@ class WrapperService(BaseService):
for appid in sorted(document["apps"], key=lambda value: int(value)):
entry = document["apps"][appid]
lines.append(f" {appid})")
- lines.extend(cls._state_lines(entry["state"], entry.get("shortcut_exe")))
+ lines.extend(self._state_lines(entry["state"], entry.get("shortcut_exe")))
lines.append(" ;;")
lines.extend([
"esac",
"",
'if [ -n "$shortcut_exe" ]; then',
- ' if [ "${1-}" = "run" ]; then',
- ' flatpak_command="$1"',
- " shift",
])
# The arguments are emitted per branch below so the values are static and
# the wrapper never needs a JSON parser or another helper executable.
lines.append(' case "$appid" in')
for appid in sorted(document["apps"], key=lambda value: int(value)):
entry = document["apps"][appid]
- if not entry.get("shortcut_exe", "").endswith("/flatpak"):
+ 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(cls._flatpak_args(entry["state"]))
+ 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",
- " fi",
' exec "$shortcut_exe" "$@"',
"fi",
'exec "$@"',
@@ -328,6 +423,7 @@ class WrapperService(BaseService):
"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]:
@@ -354,6 +450,7 @@ class WrapperService(BaseService):
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)
@@ -364,9 +461,19 @@ class WrapperService(BaseService):
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] = {
"state": validated_state,
"command_token_added": bool(command_token_added),
+ "transport": selected_transport,
}
if shortcut_exe is not None:
entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe})
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 087228c..f487dff 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -43,7 +43,34 @@ export interface GameConfigEntry {
profile: string;
config: LsfgConfig;
}
-export interface InstalledGame { appid: string; name: string; nonSteam: boolean; }
+export type TargetTransport =
+ | { kind: "host" }
+ | { kind: "flatpak"; flatpakAppId: string };
+
+export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error";
+
+export interface FlatpakTargetSupport {
+ success: boolean;
+ message?: string;
+ error?: string | null;
+ flatpak_app_id?: string;
+ runtime?: string | null;
+ runtime_branch?: string | null;
+ support_status: FlatpakTargetSupportStatus;
+ extension_installed: boolean;
+ installed_branches: string[];
+}
+
+export interface InstalledGame {
+ appid: string;
+ name: string;
+ nonSteam: boolean;
+ transport: TargetTransport;
+ executable?: string;
+ arguments?: string;
+ startDir?: string;
+ flatpakSupport?: FlatpakTargetSupport;
+}
export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; }
export interface GlobalConfig { dll: string; no_fp16: boolean; }
@@ -79,6 +106,7 @@ export interface WorkaroundStateResult {
wrapper_owned?: boolean;
shortcut_exe?: string | null;
command_token_added?: boolean;
+ transport?: TargetTransport | null;
}
export interface FileContentResult {
@@ -88,37 +116,25 @@ export interface FileContentResult {
error?: string;
}
-// Flatpak management interfaces
export interface FlatpakExtensionStatus {
success: boolean;
message: string;
- error?: string;
- installed_23_08: boolean;
- installed_24_08: boolean;
- installed_25_08: boolean;
+ error?: string | null;
+ available: boolean;
+ extension_id: string;
+ supported_branches: string[];
+ installed_branches: string[];
+ owned_branches: string[];
+ ownership_uncertain: boolean;
}
-export interface FlatpakApp {
- app_id: string;
- app_name: string;
- has_filesystem_override: boolean;
- has_env_override: boolean;
-}
-
-export interface FlatpakAppInfo {
- success: boolean;
- message: string;
- error?: string;
- apps: FlatpakApp[];
- total_apps: number;
-}
-
-export interface FlatpakOperationResult {
+export interface FlatpakCleanupResult {
success: boolean;
message: string;
- error?: string;
- app_id?: string;
- operation?: string;
+ error?: string | null;
+ removed_branches: string[];
+ preserved_branches: string[];
+ ownership_uncertain: boolean;
}
// API functions
@@ -128,13 +144,13 @@ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg
export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status");
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
-// Flatpak management API functions
-export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status");
-export const installFlatpakExtension = callable<[string], FlatpakOperationResult>("install_flatpak_extension");
-export const uninstallFlatpakExtension = callable<[string], FlatpakOperationResult>("uninstall_flatpak_extension");
-export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps");
-export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override");
-export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override");
+export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status");
+export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support");
+export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support");
+export const removePluginOwnedFlatpakExtensions = callable<
+ [],
+ FlatpakCleanupResult
+>("remove_plugin_owned_flatpak_extensions");
export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs");
export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games");
@@ -147,5 +163,6 @@ export const setWorkaroundState = callable<[
WorkaroundState,
string | null | undefined,
boolean,
+ TargetTransport | null | undefined,
], WorkaroundStateResult>("set_workaround_state");
export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state");
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 62a971b..90c1a7b 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -14,7 +14,6 @@ interface ConfigurationTabProps {
onSelect: (appid: string) => void;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
- onEnableAll: () => Promise<void>;
onRepair: (appid: string) => Promise<boolean>;
onReset: () => Promise<void>;
onResetAll: () => Promise<void>;
@@ -27,7 +26,6 @@ export function ConfigurationTab({
onSelect,
onConfigChange,
onEnable,
- onEnableAll,
onRepair,
onReset,
onResetAll,
@@ -86,7 +84,6 @@ export function ConfigurationTab({
onSelect(appid);
setDetailAppId(appid);
}}
- onEnableAll={onEnableAll}
onResetAll={onResetAll}
focusConfiguredToggle={focusConfiguredToggle}
onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
@@ -96,8 +93,13 @@ export function ConfigurationTab({
}
const profileLabel = selectedTarget?.name || "Game profile";
+ const profileTransport = selectedTarget
+ ? selectedTarget.transport.kind === "flatpak"
+ ? "Non-Steam · Flatpak"
+ : selectedTarget.nonSteam ? "Non-Steam" : "Steam"
+ : "Game";
const profileDescription = selectedTarget
- ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
+ ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
const handleProfileAction = async () => {
if (selectedTarget?.configured) {
@@ -151,6 +153,18 @@ export function ConfigurationTab({
</PanelSectionRow>
)}
</PanelSection>
+ {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && (
+ <PanelSection>
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={() => void onRepair(selectedTarget.appid)}
+ >
+ Repair Flatpak support
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
{selectedTarget?.configured && (
<GameConfigurationControls
config={config}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 59dc514..ff2376b 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,22 +1,18 @@
import { Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
-import { FaFileAlt, FaGamepad, FaLayerGroup, FaList, FaTools } from "react-icons/fa";
+import { FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
import { tabStyles } from "../styles";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallationActions } from "../hooks/useInstallationActions";
import { useInstallationStatus } from "../hooks/useLsfgHooks";
-import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
-import { FlatpaksTab } from "./FlatpaksTab";
import { NowPlayingTab } from "./NowPlayingTab";
import { SetupTab } from "./SetupTab";
const tabIcons = {
nowPlaying: <FaGamepad size={18} />,
- configuration: <FaList size={18} />,
- flatpak: <FaLayerGroup size={18} />,
- configFile: <FaFileAlt size={18} />,
+ games: <FaList size={18} />,
setup: <FaTools size={18} />,
};
@@ -38,7 +34,6 @@ export function Content() {
setSelectedAppId,
save,
enable,
- enableAll,
repair,
resetSelected,
resetAll,
@@ -52,25 +47,25 @@ export function Content() {
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
- const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null);
+ const previousRunningAppId = useRef<string | null>(null);
useEffect(() => {
if (!setupComplete) {
setTab("Setup");
return;
}
- setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current);
- }, [runningGame?.configured, setupComplete]);
+ setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current);
+ }, [runningGame?.appid, setupComplete]);
useEffect(() => {
if (!setupComplete) return;
- const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null;
- const previous = previousRunningState.current;
- previousRunningState.current = current;
- if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) {
- setTab(current.configured ? "NowPlaying" : "Configuration");
- } else if (!current && previous) {
- setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab);
+ const appid = runningGame?.appid || null;
+ const previous = previousRunningAppId.current;
+ previousRunningAppId.current = appid;
+ if (appid && appid !== previous) {
+ setTab("NowPlaying");
+ } else if (!appid && previous) {
+ setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab);
}
}, [runningGame?.appid, runningGame?.configured, setupComplete]);
@@ -105,12 +100,13 @@ export function Content() {
isUninstalling={isUninstalling}
onInstall={onInstall}
onUninstall={onUninstall}
+ flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")}
/>
);
const tabs = setupComplete
? [
- ...(runningGame?.configured ? [{
+ ...(runningGame ? [{
id: "NowPlaying",
title: tabIcons.nowPlaying,
content: (
@@ -118,12 +114,14 @@ export function Content() {
game={runningGame}
config={config}
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)}
+ onEnable={enable}
+ onRepair={repair}
/>
),
}] : []),
{
- id: "Configuration",
- title: tabIcons.configuration,
+ id: "Games",
+ title: tabIcons.games,
content: (
<ConfigurationTab
config={config}
@@ -132,15 +130,12 @@ export function Content() {
onSelect={setSelectedAppId}
onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)}
onEnable={enable}
- onEnableAll={enableAll}
onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
/>
),
},
- { id: "Flatpak", title: tabIcons.flatpak, content: <FlatpaksTab /> },
- { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, // comment out for prod
{ id: "Setup", title: tabIcons.setup, content: setupContent },
]
: [
diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx
deleted file mode 100644
index 8aab940..0000000
--- a/src/components/FlatpaksTab.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useEffect, useState } from "react";
-import {
- ConfirmModal,
- Field,
- PanelSection,
- PanelSectionRow,
- ToggleField,
- showModal,
-} from "@decky/ui";
-import {
- checkFlatpakExtensionStatus,
- FlatpakApp,
- FlatpakAppInfo,
- FlatpakExtensionStatus,
- getFlatpakApps,
- installFlatpakExtension,
- removeFlatpakAppOverride,
- setFlatpakAppOverride,
- uninstallFlatpakExtension,
-} from "../api/lsfgApi";
-import { showErrorToast } from "../utils/toastUtils";
-import t from "../i18n/i18n";
-
-const runtimeVersions = [
- { version: "23.08", key: "installed_23_08" },
- { version: "24.08", key: "installed_24_08" },
- { version: "25.08", key: "installed_25_08" },
-] as const;
-
-interface RuntimeRowProps {
- version: string;
- installed: boolean;
- busy: boolean;
- onAction: () => void;
-}
-
-function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) {
- return (
- <PanelSectionRow>
- <ToggleField
- label={`Runtime ${version}`}
- description={busy ? "Updating..." : installed ? t("FLATPAK_INSTALLED", "Installed") : t("FLATPAK_NOT_INSTALLED", "Not installed")}
- checked={installed}
- onChange={() => onAction()}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-interface AppRowProps {
- app: FlatpakApp;
- runtimeReady: boolean;
- busy: boolean;
- onToggle: () => void;
-}
-
-function AppRow({ app, runtimeReady, busy, onToggle }: AppRowProps) {
- const configured = app.has_filesystem_override && app.has_env_override;
- const partial = app.has_filesystem_override || app.has_env_override;
- const status = configured
- ? runtimeReady
- ? t("FLATPAK_STATUS_READY", "Ready")
- : t("FLATPAK_STATUS_RUNTIME_MISSING", "Runtime missing")
- : partial
- ? t("FLATPAK_STATUS_PARTIAL", "Partial")
- : t("FLATPAK_STATUS_NOT_ENABLED", "Not enabled");
-
- return (
- <PanelSectionRow>
- <ToggleField
- label={app.app_name || app.app_id}
- description={`${app.app_id} - ${status}`}
- checked={configured}
- onChange={onToggle}
- disabled={busy}
- />
- </PanelSectionRow>
- );
-}
-
-export function FlatpaksTab() {
- const [extensionStatus, setExtensionStatus] = useState<FlatpakExtensionStatus | null>(null);
- const [apps, setApps] = useState<FlatpakAppInfo | null>(null);
- const [loading, setLoading] = useState(true);
- const [operation, setOperation] = useState<string | null>(null);
- const [error, setError] = useState<string | null>(null);
- const runtimeReady = extensionStatus?.success === true
- && runtimeVersions.some(({ key }) => extensionStatus[key]);
-
- const load = async () => {
- setLoading(true);
- try {
- const [nextStatus, nextApps] = await Promise.all([
- checkFlatpakExtensionStatus(),
- getFlatpakApps(),
- ]);
- setExtensionStatus(nextStatus);
- setApps(nextApps);
- } catch (loadError) {
- setError(String(loadError));
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- void load();
- }, []);
-
- const runExtensionOperation = async (version: string, installed: boolean) => {
- const action = installed ? "uninstall" : "install";
- setOperation(`${action}-${version}`);
- setError(null);
- try {
- const result = installed
- ? await uninstallFlatpakExtension(version)
- : await installFlatpakExtension(version);
- if (!result.success) throw new Error(result.error || result.message);
- setExtensionStatus(await checkFlatpakExtensionStatus());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak operation failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- const confirmExtensionOperation = (version: string, installed: boolean) => {
- if (!installed) {
- void runExtensionOperation(version, false);
- return;
- }
- showModal(
- <ConfirmModal
- strTitle={t("FLATPAK_UNINSTALL_TITLE", "Uninstall Runtime Extension")}
- strDescription={`${t("FLATPAK_UNINSTALL_CONFIRM_PREFIX", "Are you sure you want to uninstall the")} ${version} ${t("FLATPAK_UNINSTALL_CONFIRM_SUFFIX", "runtime extension?")}`}
- onOK={() => void runExtensionOperation(version, true)}
- onCancel={() => {}}
- />,
- );
- };
-
- const toggleApp = async (app: FlatpakApp) => {
- const configured = app.has_filesystem_override && app.has_env_override;
- setOperation(`app-${app.app_id}`);
- setError(null);
- try {
- const result = configured
- ? await removeFlatpakAppOverride(app.app_id)
- : await setFlatpakAppOverride(app.app_id);
- if (!result.success) throw new Error(result.error || result.message);
- setApps(await getFlatpakApps());
- } catch (operationError) {
- const message = String(operationError);
- setError(message);
- showErrorToast("Flatpak override failed", message);
- } finally {
- setOperation(null);
- }
- };
-
- if (loading) {
- return <PanelSection title="Flatpak Runtimes" spinner />;
- }
-
- return (
- <>
- <PanelSection title="Flatpak Runtimes">
- {error && <PanelSectionRow><Field label={t("FLATPAK_OPERATION_ERROR", "Operation failed")} description={error} /></PanelSectionRow>}
- {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => (
- <RuntimeRow
- key={version}
- version={version}
- installed={extensionStatus[key]}
- busy={operation === `${extensionStatus[key] ? "uninstall" : "install"}-${version}`}
- onAction={() => confirmExtensionOperation(version, extensionStatus[key])}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={extensionStatus?.error || error || t("FLATPAK_ERROR_STATUS", "Failed to check extension status")} /></PanelSectionRow>}
- </PanelSection>
-
- <PanelSection title="Applications">
- {apps?.success ? apps.apps.length ? apps.apps.map((app) => (
- <AppRow
- key={app.app_id}
- app={app}
- runtimeReady={runtimeReady}
- busy={operation === `app-${app.app_id}`}
- onToggle={() => void toggleApp(app)}
- />
- )) : <PanelSectionRow><Field label={t("FLATPAK_NO_APPS", "No Flatpak Apps Found")} description={t("FLATPAK_NO_APPS_DESC", "No Flatpak applications are currently installed")} /></PanelSectionRow> : <PanelSectionRow><Field label={t("FLATPAK_ERROR", "Error")} description={apps?.error || error || t("FLATPAK_ERROR_APPS", "Failed to load Flatpak applications")} /></PanelSectionRow>}
- </PanelSection>
- </>
- );
-}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 7025f78..6bea2e0 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">;
+ workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">;
onRepairWorkaround?: () => Promise<boolean>;
}
@@ -36,6 +36,7 @@ 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 df0c3e9..2a92c67 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -7,14 +7,13 @@ interface Props {
targets: GameTarget[];
runningGame: GameTarget | null;
onSelect: (appid: string) => void;
- onEnableAll: () => Promise<void>;
onResetAll: () => Promise<void>;
focusConfiguredToggle?: boolean;
onConfiguredToggleFocused?: () => void;
}
-const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3";
-const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v2";
+const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4";
+const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3";
function usePersistentCollapsed(key: string) {
const [collapsed, setCollapsed] = useState(() => {
@@ -36,6 +35,11 @@ function usePersistentCollapsed(key: string) {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
+function targetDescription(game: GameTarget): string {
+ if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
function GameGroup({
title,
games,
@@ -56,7 +60,7 @@ function GameGroup({
return (
<>
<PanelSectionRow>
- <Field label={`${title} (${games.length})`} bottomSeparator="none" />
+ <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
</PanelSectionRow>
<PanelSectionRow>
<div
@@ -69,11 +73,7 @@ function GameGroup({
bottomSeparator={collapsed ? "standard" : "none"}
onClick={onToggle}
>
- {collapsed ? (
- <RiArrowDownSFill />
- ) : (
- <RiArrowUpSFill />
- )}
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
</ButtonItem>
</div>
</PanelSectionRow>
@@ -81,7 +81,7 @@ function GameGroup({
<PanelSectionRow key={game.appid}>
<Field
label={game.name}
- description={game.nonSteam ? "Non-Steam" : "Steam"}
+ description={targetDescription(game)}
onActivate={() => onSelect(game.appid)}
highlightOnFocus
/>
@@ -95,7 +95,6 @@ export function GameConfigurationSelector({
targets,
runningGame,
onSelect,
- onEnableAll,
onResetAll,
focusConfiguredToggle = false,
onConfiguredToggleFocused,
@@ -105,26 +104,20 @@ export function GameConfigurationSelector({
if (b.appid === runningGame?.appid) return 1;
return a.name.localeCompare(b.name);
});
- const configuredGames = sortGames(targets.filter((game) => game.configured));
+ const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
- const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam);
- const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam);
- const availableSteamGames = availableGames.filter((game) => !game.nonSteam);
- const availableNonSteamGames = availableGames.filter((game) => game.nonSteam);
- const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY);
- const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
- const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`);
- const configuredToggleRef = useRef<HTMLDivElement>(null);
+ const enabledToggleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!focusConfiguredToggle) return;
const frame = requestAnimationFrame(() => {
- configuredToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus();
+ enabledToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus();
onConfiguredToggleFocused?.();
});
return () => cancelAnimationFrame(frame);
- }, [configuredGames.length, focusConfiguredToggle, onConfiguredToggleFocused]);
+ }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]);
const confirmResetAll = () => {
showModal(
@@ -137,79 +130,29 @@ export function GameConfigurationSelector({
/>,
);
};
- const confirmEnableAll = () => {
- showModal(
- <ConfirmModal
- strTitle="Enable all available games?"
- strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults."
- strOKButtonText="Enable all"
- strCancelButtonText="Cancel"
- onOK={() => void onEnableAll()}
- onCancel={() => {}}
- />,
- );
- };
return (
<>
- <style>
- {`
- .LSFG_GameGroupCollapseButton_Container > div > div > div > button,
- .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button {
- height: 24px !important;
- padding: 0 !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- }
-
- .LSFG_GameGroupCollapseButton_Container svg {
- display: block;
- margin: 0;
- }
- `}
- </style>
{targets.length === 0 && (
<PanelSectionRow>
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
<GameGroup
- title="LSFG-VK Enabled"
- games={configuredSteamGames}
- collapsed={configuredCollapsed}
- onToggle={toggleConfigured}
+ title="Enabled"
+ games={enabledGames}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
onSelect={onSelect}
- toggleRef={configuredToggleRef}
+ toggleRef={enabledToggleRef}
/>
<GameGroup
- title="LSFG-VK Enabled (Non-Steam)"
- games={configuredNonSteamGames}
- collapsed={configuredNonSteamCollapsed}
- onToggle={toggleConfiguredNonSteam}
- onSelect={onSelect}
- />
- <GameGroup
- title="Available games"
- games={availableSteamGames}
+ title="Available"
+ games={availableGames}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
/>
- <GameGroup
- title="Available games (Non-Steam)"
- games={availableNonSteamGames}
- collapsed={availableNonSteamCollapsed}
- onToggle={toggleAvailableNonSteam}
- onSelect={onSelect}
- />
- {availableGames.length > 0 && (
- <PanelSectionRow>
- <ButtonItem layout="below" onClick={confirmEnableAll}>
- Enable all available games
- </ButtonItem>
- </PanelSectionRow>
- )}
<PanelSectionRow>
<ButtonItem
layout="below"
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 188c56a..5bce5c4 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,4 +1,5 @@
-import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { useState } from "react";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
@@ -6,20 +7,96 @@ import { GameConfigurationControls } from "./GameConfigurationControls";
interface Props {
game: GameTarget;
config: ConfigurationData;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onConfigChange: (
+ fieldName: keyof ConfigurationData,
+ value: boolean | number | string | string[],
+ ) => Promise<void>;
+ onEnable: (appid: string) => Promise<boolean>;
+ onRepair: (appid: string) => Promise<boolean>;
}
-export function NowPlayingTab({ game, config, onConfigChange }: Props) {
+function targetDescription(game: GameTarget): string {
+ if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak";
+ return game.nonSteam ? "Non-Steam" : "Steam";
+}
+
+export function NowPlayingTab({
+ game,
+ config,
+ onConfigChange,
+ onEnable,
+ onRepair,
+}: Props) {
+ const [busy, setBusy] = useState(false);
+ const supportNeedsRepair =
+ game.configured &&
+ game.transport.kind === "flatpak" &&
+ game.flatpakSupport?.support_status !== "ready";
+
+ const handleEnable = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await onEnable(game.appid);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleRepair = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await onRepair(game.appid);
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
<Focusable>
- <PanelSection>
+ <PanelSection title="Now Playing">
<PanelSectionRow>
- <Field
- label={game.name}
- />
+ <Field label={game.name} description={targetDescription(game)} />
</PanelSectionRow>
</PanelSection>
- <GameConfigurationControls config={config} onConfigChange={onConfigChange} showWorkarounds={false} />
+ {!game.configured && (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field
+ label="LSFG-VK is available"
+ description="This target is not enabled yet. Create its AppID profile before the next launch."
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void handleEnable()}>
+ {busy ? "Enabling..." : "Enable LSFG-VK"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
+ {game.configured && supportNeedsRepair && (
+ <PanelSection>
+ <PanelSectionRow>
+ <Field
+ label="Flatpak support needs repair"
+ description={game.flatpakSupport?.error || "The target runtime extension is not ready."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}>
+ {busy ? "Repairing..." : "Repair Flatpak support"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </PanelSection>
+ )}
+ {game.configured && (
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={onConfigChange}
+ showWorkarounds={false}
+ />
+ )}
</Focusable>
);
}
diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx
index 98d6e78..9c854e1 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SetupTab.tsx
@@ -1,5 +1,11 @@
-import { PanelSection } from "@decky/ui";
-import type { SteamBranchStatus } from "../api/lsfgApi";
+import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, showModal } from "@decky/ui";
+import { useEffect, useState } from "react";
+import {
+ getFlatpakSupportStatus,
+ removePluginOwnedFlatpakExtensions,
+ type FlatpakExtensionStatus,
+ type SteamBranchStatus,
+} from "../api/lsfgApi";
import { InstallationButton } from "./InstallationButton";
import { StatusDisplay } from "./StatusDisplay";
@@ -13,6 +19,104 @@ interface SetupTabProps {
isUninstalling: boolean;
onInstall: () => void;
onUninstall: () => void;
+ flatpakRelevant: boolean;
+}
+
+function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) {
+ const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null);
+ const [advanced, setAdvanced] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const refresh = async () => {
+ try {
+ setStatus(await getFlatpakSupportStatus());
+ } catch (error) {
+ setStatus({
+ success: false,
+ message: "",
+ error: String(error),
+ available: false,
+ extension_id: "",
+ supported_branches: [],
+ installed_branches: [],
+ owned_branches: [],
+ ownership_uncertain: false,
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (relevant) void refresh();
+ }, [relevant]);
+
+ if (!relevant || !status?.available) return null;
+
+ const confirmCleanup = () => {
+ showModal(
+ <ConfirmModal
+ strTitle="Remove plugin-installed Flatpak extensions?"
+ strDescription="Shared runtime branches recorded as installed by this plugin will be removed. Existing unowned branches are preserved."
+ strOKButtonText="Remove extensions"
+ strCancelButtonText="Cancel"
+ onOK={async () => {
+ setBusy(true);
+ try {
+ await removePluginOwnedFlatpakExtensions();
+ await refresh();
+ } finally {
+ setBusy(false);
+ }
+ }}
+ onCancel={() => {}}
+ />,
+ );
+ };
+
+ return (
+ <PanelSection title="Flatpak support">
+ <PanelSectionRow>
+ <Field
+ label="Runtime extension support"
+ description={status.message || "Flatpak is available for classified targets."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => setAdvanced((value) => !value)}>
+ {advanced ? "Hide runtime details" : "Show runtime details"}
+ </ButtonItem>
+ </PanelSectionRow>
+ {advanced && (
+ <>
+ {status.supported_branches.map((branch) => (
+ <PanelSectionRow key={branch}>
+ <Field
+ label={branch}
+ description={
+ status.installed_branches.includes(branch)
+ ? "Installed" + (status.owned_branches.includes(branch) ? " · plugin-owned" : "")
+ : "Not installed"
+ }
+ />
+ </PanelSectionRow>
+ ))}
+ {status.ownership_uncertain && (
+ <PanelSectionRow>
+ <Field label="Ownership metadata is uncertain" description="Cleanup is disabled until the metadata is repaired." />
+ </PanelSectionRow>
+ )}
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ disabled={busy || status.ownership_uncertain || status.owned_branches.length === 0}
+ onClick={confirmCleanup}
+ >
+ {busy ? "Removing..." : "Remove plugin-installed extensions"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </>
+ )}
+ </PanelSection>
+ );
}
export function SetupTab({
@@ -25,22 +129,26 @@ export function SetupTab({
isUninstalling,
onInstall,
onUninstall,
+ flatpakRelevant,
}: SetupTabProps) {
return (
- <PanelSection title="Setup">
- <StatusDisplay
- installationStatus={installationStatus}
- losslessScalingInstalled={losslessScalingInstalled}
- losslessScalingStatus={losslessScalingStatus}
- steamBranchStatus={steamBranchStatus}
- />
- <InstallationButton
- isInstalled={isInstalled}
- isInstalling={isInstalling}
- isUninstalling={isUninstalling}
- onInstall={onInstall}
- onUninstall={onUninstall}
- />
- </PanelSection>
+ <>
+ <PanelSection title="Setup">
+ <StatusDisplay
+ installationStatus={installationStatus}
+ losslessScalingInstalled={losslessScalingInstalled}
+ losslessScalingStatus={losslessScalingStatus}
+ steamBranchStatus={steamBranchStatus}
+ />
+ <InstallationButton
+ isInstalled={isInstalled}
+ isInstalling={isInstalling}
+ isUninstalling={isUninstalling}
+ onInstall={onInstall}
+ onUninstall={onUninstall}
+ />
+ </PanelSection>
+ <FlatpakSupportDiagnostics relevant={flatpakRelevant} />
+ </>
);
}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
index dbdd6a3..5587392 100644
--- a/src/components/WorkaroundsSection.tsx
+++ b/src/components/WorkaroundsSection.tsx
@@ -1,6 +1,7 @@
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";
@@ -8,6 +9,7 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds";
interface WorkaroundsSectionProps {
appId: string;
nonSteam: boolean;
+ transport: TargetTransport;
onRepair?: () => Promise<boolean>;
}
@@ -79,9 +81,9 @@ function usePersistentCollapsed() {
return [collapsed, () => setCollapsed((value) => !value)] as const;
}
-export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) {
+export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) {
const [collapsed, toggleCollapsed] = usePersistentCollapsed();
- const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam);
+ const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport);
const [repairing, setRepairing] = useState(false);
const state = snapshot?.state;
const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true;
diff --git a/src/components/index.ts b/src/components/index.ts
index 37a8edb..7c3ee0a 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -5,8 +5,6 @@ export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
export { ConfigurationTab } from "./ConfigurationTab";
export { SetupTab } from "./SetupTab";
-export { ConfigFileTab } from "./ConfigFileTab";
-export { FlatpaksTab } from "./FlatpaksTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
export { GameConfigurationControls } from "./GameConfigurationControls";
export { NowPlayingTab } from "./NowPlayingTab";
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index c66596a..b59d592 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 { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
+import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
@@ -19,7 +19,12 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
const appid = Number(shortcut?.appid);
const name = shortcut?.data?.strAppName;
if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return [];
- return [{ appid: String(appid >>> 0), name, nonSteam: true }];
+ return [{
+ appid: String(appid >>> 0),
+ name,
+ nonSteam: true,
+ transport: { kind: "host" },
+ }];
});
} catch {
return [];
@@ -28,7 +33,10 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> {
function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) {
const games = new Map(backendGames.map((game) => [game.appid, game]));
- for (const game of shortcutGames) games.set(game.appid, game);
+ for (const game of shortcutGames) {
+ const existing = games.get(game.appid);
+ games.set(game.appid, existing ? { ...existing, name: game.name, nonSteam: true } : game);
+ }
return Array.from(games.values());
}
@@ -82,7 +90,7 @@ export function useGameConfiguration() {
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
setRunningGame((current) => current?.appid === appid ? current : {
- ...(installed || { appid, name, nonSteam: false }),
+ ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }),
name,
configured: games.some((game) => game.appid === appid),
});
@@ -101,13 +109,26 @@ export function useGameConfiguration() {
const targets = useMemo<GameTarget[]>(() => {
const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) }));
- for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true });
+ for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true });
if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame);
return configured;
}, [games, installedGames, runningGame]);
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
+ const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (target.transport.kind !== "flatpak") return true;
+ const result = await ensureFlatpakSupport(target.transport.flatpakAppId);
+ if (!result.success || result.support_status !== "ready") {
+ showErrorToast(
+ "Flatpak support unavailable",
+ result.error || result.message || "The required Flatpak runtime extension is not ready",
+ );
+ return false;
+ }
+ return true;
+ }, []);
+
const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
const appId = Number(target.appid);
@@ -119,6 +140,7 @@ export function useGameConfiguration() {
const oldState = existing.state;
const oldShortcutExe = existing.shortcut_exe || undefined;
const oldCommandTokenAdded = existing.command_token_added === true;
+ const oldTransport = existing.transport || target.transport;
if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) {
throw new Error("Managed shortcut Target has no saved original executable");
}
@@ -138,6 +160,7 @@ export function useGameConfiguration() {
state,
originalExecutable || null,
oldCommandTokenAdded,
+ target.transport,
);
if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state");
@@ -149,6 +172,7 @@ export function useGameConfiguration() {
state,
target.nonSteam ? (integration.originalExecutable || originalExecutable || null) : null,
integration.commandTokenAdded,
+ target.transport,
);
if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state");
return true;
@@ -170,7 +194,13 @@ export function useGameConfiguration() {
}
if (rollbackSucceeded) {
const restored = oldState
- ? await setWorkaroundState(target.appid, oldState, oldShortcutExe || null, oldCommandTokenAdded)
+ ? await setWorkaroundState(
+ target.appid,
+ oldState,
+ oldShortcutExe || null,
+ oldCommandTokenAdded,
+ oldTransport,
+ )
: await removeWorkaroundState(target.appid);
if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state");
}
@@ -227,30 +257,30 @@ export function useGameConfiguration() {
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;
- }, [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 ensureTargetWorkarounds(target))) return;
- const result = await updateGameConfig(target.appid, target.name, template);
- if (!result.success) {
- showErrorToast("Could not enable all games", result.error || "A game profile could not be created");
- await removeTargetWorkarounds(target);
- return;
- }
- }
- await load();
- }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
+ }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]);
const repair = useCallback(async (appid: string): Promise<boolean> => {
const target = targets.find((item) => item.appid === appid);
- return target ? ensureTargetWorkarounds(target) : false;
- }, [ensureTargetWorkarounds, targets]);
+ if (!target) return false;
+ if (target.transport.kind === "flatpak") {
+ const support = await repairFlatpakSupport(target.transport.flatpakAppId);
+ if (!support.success || support.support_status !== "ready") {
+ showErrorToast(
+ "Flatpak support unavailable",
+ support.error || support.message || "The required Flatpak runtime extension is not ready",
+ );
+ return false;
+ }
+ }
+ const success = await ensureTargetWorkarounds(target);
+ if (success) await load();
+ return success;
+ }, [ensureTargetWorkarounds, load, targets]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
@@ -276,5 +306,5 @@ export function useGameConfiguration() {
}
}, [load, removeTargetWorkarounds, targets]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load };
+ return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, repair, resetSelected, resetAll, reload: load };
}
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index 9e283db..c7413b0 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -3,6 +3,7 @@ import {
getWorkaroundState,
removeWorkaroundState,
setWorkaroundState,
+ type TargetTransport,
type WorkaroundState,
} from "../api/lsfgApi";
import {
@@ -45,6 +46,7 @@ export interface WorkaroundSnapshot {
integrationInstalled: boolean;
commandTokenAdded: boolean;
shortcutExe?: string | null;
+ transport: TargetTransport;
}
interface PerAppWorkarounds {
@@ -85,12 +87,14 @@ function makeSnapshot(
integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath),
commandTokenAdded: result.command_token_added === true,
shortcutExe: result.shortcut_exe,
+ transport: result.transport || { kind: "host" },
};
}
async function adoptWorkaroundState(
appId: string,
nonSteam: boolean,
+ transport: TargetTransport,
steam: SteamLaunchOptionsSnapshot,
wrapperPath: string,
): Promise<WorkaroundSnapshot> {
@@ -98,7 +102,13 @@ async function adoptWorkaroundState(
throw new Error("Shortcut Target is a wrapper but its original Target is unknown");
}
const originalExecutable = nonSteam ? steam.target : null;
- const initial = await setWorkaroundState(appId, DEFAULT_WORKAROUND_STATE, originalExecutable, false);
+ const initial = await setWorkaroundState(
+ appId,
+ DEFAULT_WORKAROUND_STATE,
+ originalExecutable,
+ false,
+ transport,
+ );
if (!initial.success) throw new Error(initial.error || "Could not create workaround state");
let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
try {
@@ -112,6 +122,7 @@ async function adoptWorkaroundState(
DEFAULT_WORKAROUND_STATE,
nonSteam ? (integration.originalExecutable || originalExecutable) : null,
integration.commandTokenAdded,
+ transport,
);
if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state");
return makeSnapshot(integration.snapshot, finalized, nonSteam);
@@ -139,7 +150,11 @@ async function adoptWorkaroundState(
}
}
-export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds {
+export function usePerAppWorkarounds(
+ appId: string,
+ nonSteam: boolean,
+ transport: TargetTransport = { kind: "host" },
+): PerAppWorkarounds {
const [status, setStatus] = useState<WorkaroundLoadStatus>("loading");
const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -156,12 +171,13 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
return adoptWorkaroundState(
appId,
nonSteam,
+ transport,
steam,
result.wrapper_path || getDefaultWrapperPath(),
);
}
return makeSnapshot(steam, result, nonSteam);
- }, [appId, nonSteam, numericAppId]);
+ }, [appId, nonSteam, numericAppId, transport]);
const applySnapshot = useCallback((next: WorkaroundSnapshot) => {
setSnapshot(next);
@@ -236,6 +252,7 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
nextState,
current.shortcutExe ?? null,
current.commandTokenAdded,
+ current.transport,
);
if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
applySnapshot({
@@ -245,6 +262,7 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
wrapperOwned: result.wrapper_owned === true,
shortcutExe: result.shortcut_exe,
commandTokenAdded: result.command_token_added === true,
+ transport: result.transport || current.transport,
});
return true;
} catch (updateError) {
diff --git a/src/types.d.ts b/src/types.d.ts
index 7b5d055..df433e0 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -17,6 +17,7 @@ interface SteamAppDetails {
strLaunchOptions?: string;
strShortcutLaunchOptions?: string;
strShortcutExe?: string;
+ strShortcutStartDir?: string;
}
interface SteamAppDetailsRegistration {
diff --git a/tests/test_flatpak_overrides.py b/tests/test_flatpak_overrides.py
deleted file mode 100644
index ed3ef6a..0000000
--- a/tests/test_flatpak_overrides.py
+++ /dev/null
@@ -1,158 +0,0 @@
-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.modules.setdefault("tomllib", types.SimpleNamespace(loads=Mock()))
-sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
-
-from lsfg_vk.flatpak_service import FlatpakService
-
-
-class FlatpakOverrideTests(unittest.TestCase):
- def setUp(self):
- self.tempdir = tempfile.TemporaryDirectory()
- home = Path(self.tempdir.name) / "home" / "deck"
- home.mkdir(parents=True)
- self.service = FlatpakService()
- self.service.user_home = home
- self.service.config_dir = home / ".config/lsfg-vk"
- self.service.config_file_path = self.service.config_dir / "conf.toml"
- self.service.legacy_script_path = home / "lsfg"
- self.service.check_flatpak_available = Mock(return_value=True)
- self.service._run_flatpak_command = Mock(
- return_value=types.SimpleNamespace(returncode=0, stderr="", stdout="")
- )
- self.app_id = "com.example.Game"
- self.override_path = self.service._override_file_path(self.app_id)
-
- def tearDown(self):
- self.tempdir.cleanup()
- sys.modules.pop("lsfg_vk.plugin", None)
- sys.modules.pop("lsfg_vk", None)
-
- def _paths(self):
- return self.service._override_paths()
-
- def _write_override(self, content):
- self.override_path.parent.mkdir(parents=True, exist_ok=True)
- self.override_path.write_text(content, encoding="utf-8")
-
- def _show_response(self, content):
- return types.SimpleNamespace(returncode=0, stderr="", stdout=content)
-
- def test_set_cleans_legacy_entries_and_verifies_readback(self):
- paths = self._paths()
- self._write_override(
- "[Context]\n"
- f"filesystems=/home/deck/keep;{paths['config_dir']}:rw;!{paths['legacy_home']};"
- f"{paths['legacy_script']};{paths['legacy_dll']}:ro;{paths['dll_dir']}:ro;\n"
- "unset-environment=KEEP_UNSET;LSFG_CONFIG;\n\n"
- "[Environment]\n"
- "KEEP_ENV=1\n"
- "LSFG_CONFIG=\n"
- "LSFGVK_CONFIG=old\n"
- "ENABLE_GAMESCOPE_WSI=1\n"
- "DXVK_HDR=1\n"
- )
- expected = (
- "[Context]\n"
- f"filesystems={paths['config_dir']}:rw;{paths['dll_dir']}:ro\n"
- "[Environment]\n"
- f"LSFGVK_CONFIG={paths['config_file']}\n"
- "ENABLE_GAMESCOPE_WSI=0\n"
- "DXVK_HDR=0\n"
- )
- self.service._run_flatpak_command.side_effect = [
- self._show_response(""),
- self._show_response(expected),
- ]
-
- response = self.service.set_app_override(self.app_id)
- command_args = self.service._run_flatpak_command.call_args_list[0].args[0]
- cleaned = self.override_path.read_text(encoding="utf-8")
-
- self.assertTrue(response["success"])
- self.assertIn("--env=ENABLE_GAMESCOPE_WSI=0", command_args)
- self.assertIn("--env=DXVK_HDR=0", command_args)
- self.assertNotIn("--nofilesystem=/home/deck", command_args)
- self.assertNotIn("--unset-env=LSFG_CONFIG", command_args)
- self.assertIn("/home/deck/keep", cleaned)
- self.assertIn("KEEP_ENV=1", cleaned)
- self.assertNotIn("LSFG_CONFIG", cleaned)
- self.assertNotIn(paths["legacy_home"], cleaned)
-
- def test_set_reports_failed_readback(self):
- paths = self._paths()
- self.service._run_flatpak_command.side_effect = [
- self._show_response(""),
- self._show_response(
- f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n"
- f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n"
- ),
- ]
-
- response = self.service.set_app_override(self.app_id)
-
- self.assertFalse(response["success"])
- self.assertIn("verified", response["error"])
-
- def test_remove_cleans_known_entries_preserves_unrelated_and_verifies(self):
- paths = self._paths()
- self._write_override(
- "[Context]\n"
- f"filesystems=/home/deck/keep;{paths['config_dir']};!{paths['legacy_home']};"
- f"{paths['legacy_dll']};{paths['legacy_script']}\n"
- "unset-environment=KEEP_UNSET;LSFG_CONFIG;ENABLE_GAMESCOPE_WSI\n\n"
- "[Environment]\n"
- "KEEP_ENV=1\n"
- "LSFGVK_CONFIG=/old/path\n"
- "DXVK_HDR=0\n"
- )
- self.service._run_flatpak_command.side_effect = [
- self._show_response(
- "[Context]\nfilesystems=/home/deck/keep\n"
- "[Environment]\nKEEP_ENV=1\n"
- )
- ]
-
- response = self.service.remove_app_override(self.app_id)
- cleaned = self.override_path.read_text(encoding="utf-8")
-
- self.assertTrue(response["success"])
- self.assertEqual(self.service._run_flatpak_command.call_count, 1)
- self.assertIn("/home/deck/keep", cleaned)
- self.assertIn("KEEP_UNSET", cleaned)
- self.assertIn("KEEP_ENV", cleaned)
- for name in ("LSFGVK_CONFIG", "LSFG_CONFIG", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"):
- self.assertNotIn(name, cleaned)
- for path in paths.values():
- if path != paths["config_file"]:
- self.assertNotIn(path, cleaned)
-
- def test_remove_reports_failed_readback(self):
- self._write_override("[Context]\nfilesystems=/home/deck/keep\n")
- paths = self._paths()
- self.service._run_flatpak_command.side_effect = [
- self._show_response(
- f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n"
- f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n"
- "ENABLE_GAMESCOPE_WSI=0\nDXVK_HDR=0\n"
- )
- ]
-
- response = self.service.remove_app_override(self.app_id)
-
- self.assertFalse(response["success"])
- self.assertIn("verified", response["error"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py
new file mode 100644
index 0000000..38dfcb5
--- /dev/null
+++ b/tests/test_flatpak_service.py
@@ -0,0 +1,231 @@
+import json
+import sys
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import Mock
+
+
+sys.modules.setdefault(
+ "decky",
+ types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()),
+)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
+
+from lsfg_vk.flatpak_service import FlatpakService
+
+
+class FlatpakServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.TemporaryDirectory()
+ self.home = Path(self.tempdir.name) / "home" / "deck"
+ self.home.mkdir(parents=True)
+ self.service = FlatpakService()
+ self.service.user_home = self.home
+ self.service.config_dir = self.home / ".config/lsfg-vk"
+ self.service.config_file_path = self.service.config_dir / "conf.toml"
+ self.service.check_flatpak_available = Mock(return_value=True)
+ self.service._run_flatpak_command = Mock()
+ 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)
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ @staticmethod
+ def _result(stdout="", returncode=0, stderr=""):
+ return types.SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr)
+
+ @staticmethod
+ def _extension_line(branch):
+ return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n"
+
+ 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_resolve_reads_required_runtime_instead_of_any_installed_branch(self):
+ self.service._run_flatpak_command.side_effect = [
+ self._result("org.freedesktop.Platform/x86_64/24.08\n"),
+ self._result(self._extension_line("23.08")),
+ ]
+
+ response = self.service.resolve_app_support("com.example.Game")
+
+ self.assertTrue(response["success"])
+ 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", "--runtime", "--columns=application,arch,branch"],
+ )
+
+ def test_install_records_only_a_new_user_owned_branch(self):
+ self.service._run_flatpak_command.side_effect = [
+ self._result(""),
+ self._result(""),
+ self._result(self._extension_line("24.08")),
+ ]
+
+ response = self.service.install_extension("24.08")
+
+ self.assertTrue(response["success"])
+ self.assertTrue(response["owned_by_plugin"])
+ install_args = self.service._run_flatpak_command.call_args_list[1].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"]},
+ )
+
+ def test_preexisting_branch_is_not_claimed_or_removed(self):
+ self.service._run_flatpak_command.return_value = self._result(
+ self._extension_line("24.08")
+ )
+
+ install_response = self.service.install_extension("24.08")
+ cleanup_response = self.service.remove_plugin_owned_extensions()
+
+ self.assertTrue(install_response["success"])
+ self.assertFalse(install_response["owned_by_plugin"])
+ self.assertFalse(self.service.ownership_path.exists())
+ self.assertTrue(cleanup_response["success"])
+ self.assertEqual(self.service._run_flatpak_command.call_count, 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")
+
+ 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_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_ensure_app_support_installs_only_the_app_runtime_branch(self):
+ self.service._run_flatpak_command.side_effect = [
+ self._result("org.freedesktop.Platform/x86_64/24.08\n"),
+ self._result(""),
+ self._result(""),
+ self._result(""),
+ self._result(""),
+ self._result(self._extension_line("24.08")),
+ self._result("org.freedesktop.Platform/x86_64/24.08\n"),
+ self._result(self._extension_line("24.08")),
+ ]
+
+ response = self.service.ensure_app_support("com.example.Game")
+
+ self.assertTrue(response["success"])
+ self.assertEqual(response["support_status"], "ready")
+ self.assertEqual(response["runtime_branch"], "24.08")
+ install_args = self.service._run_flatpak_command.call_args_list[4].args[0]
+ 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):
+ self.service._run_flatpak_command.side_effect = [
+ self._result("org.freedesktop.Platform/x86_64/24.08\n"),
+ self._result(""),
+ self._result(""),
+ self._result(""),
+ self._result(""),
+ self._result(self._extension_line("24.08")),
+ self._result("org.freedesktop.Platform/x86_64/24.08\n"),
+ self._result(self._extension_line("24.08")),
+ self._result("org.freedesktop.Platform/x86_64/24.08\n"),
+ self._result(self._extension_line("24.08")),
+ ]
+
+ 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"]},
+ )
+
+ 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.service._run_flatpak_command.side_effect = [
+ self._result(
+ "\n".join(
+ [
+ "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "23.08"]),
+ "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]),
+ ]
+ )
+ + "\n"
+ ),
+ self._result(""),
+ self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"),
+ self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"),
+ self._result(""),
+ self._result(""),
+ ]
+
+ response = self.service.remove_plugin_owned_extensions()
+
+ 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)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py
new file mode 100644
index 0000000..849bb01
--- /dev/null
+++ b/tests/test_steam_service.py
@@ -0,0 +1,77 @@
+import sys
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import Mock
+
+
+sys.modules.setdefault(
+ "decky",
+ types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()),
+)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
+
+from lsfg_vk.steam_service import SteamService, classify_shortcut_transport
+
+
+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(
+ "/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"},
+ )
+
+ def test_shortcut_data_preserves_transport_inputs(self):
+ game = SteamService._shortcut_game(
+ {
+ "appid": 123456,
+ "AppName": "PCSX2 shortcut",
+ "Exe": "/usr/bin/flatpak",
+ "LaunchOptions": "run net.pcsx2.PCSX2 --fullscreen",
+ "StartDir": "/home/deck/Games",
+ }
+ )
+
+ self.assertEqual(game["appid"], "123456")
+ self.assertEqual(game["transport"], {
+ "kind": "flatpak",
+ "flatpakAppId": "net.pcsx2.PCSX2",
+ })
+ self.assertEqual(game["executable"], "/usr/bin/flatpak")
+ self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen")
+ self.assertEqual(game["startDir"], "/home/deck/Games")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py
index f17a944..5010d23 100644
--- a/tests/test_wrapper_service.py
+++ b/tests/test_wrapper_service.py
@@ -123,10 +123,20 @@ class WrapperServiceTests(unittest.TestCase):
encoding="utf-8",
)
fake_flatpak.chmod(0o755)
- self.service.set("123", self._state(dxvkFrameRate=20, enableZink=True), str(fake_flatpak))
+ 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)
@@ -137,6 +147,73 @@ class WrapperServiceTests(unittest.TestCase):
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_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"])