diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 19:16:30 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-09 20:20:58 -0400 |
| commit | cc1e6f47dd9838b066822162a607d2859c043aff (patch) | |
| tree | 532915c1e98c068ab8324d71a0dab160bb53de68 | |
| parent | 102a4a0f0ce4af303a12e7f6f1a4454f4e572a17 (diff) | |
| download | decky-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.py | 724 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 109 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/steam_service.py | 86 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/wrapper_service.py | 139 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 81 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 22 | ||||
| -rw-r--r-- | src/components/Content.tsx | 41 | ||||
| -rw-r--r-- | src/components/FlatpaksTab.tsx | 196 | ||||
| -rw-r--r-- | src/components/GameConfigurationControls.tsx | 3 | ||||
| -rw-r--r-- | src/components/GameConfigurationSelector.tsx | 101 | ||||
| -rw-r--r-- | src/components/NowPlayingTab.tsx | 93 | ||||
| -rw-r--r-- | src/components/SetupTab.tsx | 142 | ||||
| -rw-r--r-- | src/components/WorkaroundsSection.tsx | 6 | ||||
| -rw-r--r-- | src/components/index.ts | 2 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 78 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 24 | ||||
| -rw-r--r-- | src/types.d.ts | 1 | ||||
| -rw-r--r-- | tests/test_flatpak_overrides.py | 158 | ||||
| -rw-r--r-- | tests/test_flatpak_service.py | 231 | ||||
| -rw-r--r-- | tests/test_steam_service.py | 77 | ||||
| -rw-r--r-- | tests/test_wrapper_service.py | 79 |
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"]) |
