summaryrefslogtreecommitdiff
path: root/py_modules
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 19:16:30 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-09 20:20:58 -0400
commitcc1e6f47dd9838b066822162a607d2859c043aff (patch)
tree532915c1e98c068ab8324d71a0dab160bb53de68 /py_modules
parent102a4a0f0ce4af303a12e7f6f1a4454f4e572a17 (diff)
downloaddecky-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.py724
-rw-r--r--py_modules/lsfg_vk/plugin.py109
-rw-r--r--py_modules/lsfg_vk/steam_service.py86
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py139
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})