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 /py_modules | |
| parent | 102a4a0f0ce4af303a12e7f6f1a4454f4e572a17 (diff) | |
| download | decky-lsfg-vk-cc1e6f47dd9838b066822162a607d2859c043aff.tar.gz decky-lsfg-vk-cc1e6f47dd9838b066822162a607d2859c043aff.zip | |
refactor: unify flatpak targets with steam profiles
Diffstat (limited to 'py_modules')
| -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 |
4 files changed, 683 insertions, 375 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}) |
