summaryrefslogtreecommitdiff
path: root/py_modules/lsfg_vk
diff options
context:
space:
mode:
Diffstat (limited to 'py_modules/lsfg_vk')
-rw-r--r--py_modules/lsfg_vk/configuration.py26
-rw-r--r--py_modules/lsfg_vk/constants.py3
-rw-r--r--py_modules/lsfg_vk/flatpak_profile_service.py6
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py90
-rw-r--r--py_modules/lsfg_vk/installation.py31
-rw-r--r--py_modules/lsfg_vk/plugin.py38
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py109
7 files changed, 249 insertions, 54 deletions
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index 17dcaf3..d1852a0 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -72,20 +72,30 @@ class ConfigurationService(BaseService):
self.log.error(f"Error reading game configs: {error}")
return self._error_response(dict, str(error), games=[])
+ def update_global_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ data = self._get_profile_data()
+ merged_config = {**data["global_config"], **config}
+ validated = self._public_config(merged_config)
+ data["global_config"] = {
+ "dll": validated["dll"],
+ "no_fp16": validated["no_fp16"],
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]))
+ except Exception as error:
+ return self._error_response(dict, str(error), global_config=None)
+
def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
try:
data = self._get_profile_data()
old_name, _ = self._profile_for_appid(data, appid)
name = self._profile_name(data, appid, game_name)
- merged_config = {**data["global_config"], **config}
+ merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}}
if not config.get("dll"):
merged_config["dll"] = data["global_config"].get("dll", "")
validated = self._public_config(merged_config)
validated["active_in"] = [str(appid)]
- data["global_config"] = {
- "dll": validated["dll"],
- "no_fp16": validated["no_fp16"],
- }
if old_name and old_name != name:
data["profiles"].pop(old_name, None)
data["profiles"][name] = validated
@@ -114,15 +124,11 @@ class ConfigurationService(BaseService):
try:
data = self._get_profile_data()
name = self.flatpak_profile_name(app_id)
- merged_config = {**data["global_config"], **config}
+ merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}}
if not config.get("dll"):
merged_config["dll"] = data["global_config"].get("dll", "")
validated = self._public_config(merged_config)
validated["active_in"] = []
- data["global_config"] = {
- "dll": validated["dll"],
- "no_fp16": validated["no_fp16"],
- }
data["profiles"][name] = validated
self._save_profile_data(data)
return self._success_response(dict, app_id=str(app_id), profile=name, exists=True, config=validated)
diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py
index 960d230..45ac4c5 100644
--- a/py_modules/lsfg_vk/constants.py
+++ b/py_modules/lsfg_vk/constants.py
@@ -16,9 +16,6 @@ CLI_FILENAME = "lsfg-vk-cli"
UI_FILENAME = "lsfg-vk-ui"
UI_DESKTOP_FILENAME = "gay.pancake.lsfg-vk-ui.desktop"
UI_ICON_FILENAME = "gay.pancake.lsfg-vk-ui.png"
-FLATPAK_23_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak"
-FLATPAK_24_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak"
-FLATPAK_25_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak"
STEAM_LOSSLESS_SCALING_APP_ID = "993090"
STEAM_LOSSLESS_SCALING_BRANCH = "lsfg-vk"
diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py
index ddec116..9d2d104 100644
--- a/py_modules/lsfg_vk/flatpak_profile_service.py
+++ b/py_modules/lsfg_vk/flatpak_profile_service.py
@@ -202,7 +202,7 @@ class FlatpakProfileService:
result = self.configuration_service.update_flatpak_config(app_id, config)
if not result.get("success"):
raise RuntimeError(result.get("error") or "Could not update Flatpak profile")
- return self.get_app(app_id)
+ return result
except Exception as error:
return {
"success": False,
@@ -374,8 +374,10 @@ class FlatpakProfileService:
"app_id": fields[0],
"active": active,
"pid": fields[2].strip() if len(fields) > 2 else "",
+ "start_time": self.flatpak_service._process_start_time(
+ fields[2].strip() if len(fields) > 2 else ""
+ ),
})
- running.sort(key=lambda item: (not item["active"], item["app_id"]))
return {"success": True, "message": "", "error": None, "apps": running}
except Exception as error:
return {"success": False, "message": "", "error": str(error), "apps": []}
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py
index 62f6a50..f2ed90c 100644
--- a/py_modules/lsfg_vk/flatpak_service.py
+++ b/py_modules/lsfg_vk/flatpak_service.py
@@ -12,17 +12,12 @@ from pathlib import Path
from typing import Dict, Optional, Set
from .base_service import BaseService
-from .constants import (
- BIN_DIR,
- FLATPAK_23_08_FILENAME,
- FLATPAK_24_08_FILENAME,
- FLATPAK_25_08_FILENAME,
-)
class FlatpakService(BaseService):
EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk"
- SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08")
+ FLATHUB_REMOTE = "flathub"
+ SUPPORTED_RUNTIMES = ("24.08", "25.08")
DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"}
RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL"
OWNERSHIP_FILENAME = "flatpak_state.json"
@@ -34,6 +29,7 @@ class FlatpakService(BaseService):
def __init__(self, logger=None):
super().__init__(logger)
self.flatpak_command: Optional[str] = None
+ self._verified_branches: Set[str] = set()
self._lock = threading.RLock()
@property
@@ -48,6 +44,13 @@ class FlatpakService(BaseService):
env = os.environ.copy()
env.pop("LD_LIBRARY_PATH", None)
env["HOME"] = str(self.user_home)
+ try:
+ user_id = self.user_home.stat().st_uid
+ except OSError:
+ user_id = None
+ if user_id is not None:
+ env["XDG_RUNTIME_DIR"] = f"/run/user/{user_id}"
+ env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{user_id}/bus"
path = [entry for entry in env.get("PATH", "").split(":") if entry]
for entry in ("/usr/bin", "/usr/local/bin", "/bin"):
if entry not in path:
@@ -115,14 +118,6 @@ class FlatpakService(BaseService):
def _extension_ref(cls, branch: str) -> str:
return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}"
- def _bundled_extension_path(self, branch: str) -> Path:
- filename = {
- "23.08": FLATPAK_23_08_FILENAME,
- "24.08": FLATPAK_24_08_FILENAME,
- "25.08": FLATPAK_25_08_FILENAME,
- }[self._validate_runtime(branch)]
- return Path(__file__).resolve().parent.parent.parent / BIN_DIR / filename
-
def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]:
scopes = ("user", "system") if scope is None else (scope,)
installed = set()
@@ -139,6 +134,14 @@ class FlatpakService(BaseService):
installed.add(fields[2])
return installed
+ def _user_extension_origin(self, branch: str) -> str:
+ result = self._run_flatpak_command(
+ ["info", "--user", "--show-origin", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ return result.stdout.strip() if result.returncode == 0 else ""
+
def _empty_state(self) -> Dict[str, object]:
return {
"version": self.OWNERSHIP_VERSION,
@@ -200,6 +203,29 @@ class FlatpakService(BaseService):
def _sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
+ @staticmethod
+ def _parse_process_start_time(stat_content: str) -> Optional[int]:
+ closing_command = stat_content.rfind(")")
+ if closing_command < 0:
+ return None
+ fields = stat_content[closing_command + 2:].split()
+ if len(fields) <= 19:
+ return None
+ try:
+ return int(fields[19])
+ except (TypeError, ValueError):
+ return None
+
+ @classmethod
+ def _process_start_time(cls, pid: str) -> Optional[int]:
+ if not isinstance(pid, str) or re.fullmatch(r"[0-9]+", pid) is None:
+ return None
+ try:
+ stat_content = (Path("/proc") / pid / "stat").read_text(encoding="utf-8")
+ except OSError:
+ return None
+ return cls._parse_process_start_time(stat_content)
+
def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]:
path = self._override_path(app_id)
if path.is_symlink():
@@ -343,14 +369,29 @@ class FlatpakService(BaseService):
if not self.check_flatpak_available():
raise FileNotFoundError("Flatpak is not available on this system")
with self._lock:
- installed = self._installed_extension_branches()
- if branch in installed:
+ if branch in self._verified_branches:
+ return self._extension_result(branch, True, False, "is ready")
+ user_installed = self._installed_extension_branches("user")
+ system_installed = self._installed_extension_branches("system")
+ if branch in system_installed and branch not in user_installed:
return self._extension_result(branch, True, False, "is ready")
- bundle = self._bundled_extension_path(branch)
- if not bundle.is_file():
- raise FileNotFoundError(f"Bundled Flatpak extension not found at {bundle}; reinstall the plugin")
+ if branch in user_installed and self._user_extension_origin(branch) != self.FLATHUB_REMOTE:
+ result = self._run_flatpak_command(
+ ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Could not replace the existing Flatpak extension")
result = self._run_flatpak_command(
- ["install", "--user", "--noninteractive", "--or-update", str(bundle)],
+ [
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ self.FLATHUB_REMOTE,
+ f"{self.EXTENSION_ID}//{branch}",
+ ],
capture_output=True,
text=True,
)
@@ -363,6 +404,7 @@ class FlatpakService(BaseService):
owned.add(branch)
state["plugin_owned_branches"] = sorted(owned)
self._write_state(state)
+ self._verified_branches.add(branch)
return self._extension_result(branch, True, False, "installed")
except Exception as error:
return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False)
@@ -410,12 +452,6 @@ class FlatpakService(BaseService):
return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False)
def ensure_extension(self, branch: str):
- try:
- branch = self._validate_runtime(branch)
- if branch in self._installed_extension_branches():
- return self._extension_result(branch, True, False, "is ready")
- except Exception as error:
- return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False)
return self.install_extension(branch)
def set_extension_enabled(self, branch: str, enabled: bool):
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index a73706c..e7366ee 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -162,6 +162,25 @@ class InstallationService(BaseService):
for path in (self.legacy_lib_file, self.legacy_json_file):
self._remove_if_exists(path)
+ def _prune_empty_directories(self) -> None:
+ # Only prune directories created by this plugin. Never remove a
+ # non-empty directory because the user's other tools may use it.
+ candidates = (
+ self.config_dir,
+ self.local_bin_dir,
+ self.local_lib_dir,
+ self.local_share_dir,
+ self.user_home / LOCAL_SHARE / "applications",
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps",
+ )
+ for directory in candidates:
+ try:
+ if directory.is_dir() and not directory.is_symlink():
+ directory.rmdir()
+ self.log.info(f"Removed empty directory {directory}")
+ except OSError:
+ continue
+
def check_installation(self) -> InstallationCheckResponse:
try:
installation_error = None
@@ -200,9 +219,12 @@ class InstallationService(BaseService):
self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME,
self.legacy_lib_file,
self.legacy_json_file,
+ self.legacy_script_path,
+ self.config_file_path,
)
if self._remove_if_exists(path)
]
+ self._prune_empty_directories()
if not removed:
return self._success_response(
UninstallationResponse,
@@ -221,9 +243,14 @@ class InstallationService(BaseService):
removed_files=None,
)
- def cleanup_on_uninstall(self) -> None:
+ def cleanup_on_uninstall(self) -> bool:
try:
- self.uninstall()
+ result = self.uninstall()
+ if not result.get("success"):
+ self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {result.get('error')}")
+ return False
+ return True
except Exception as error:
self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {error}")
self.log.error(traceback.format_exc())
+ return False
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index d20fabf..a1fd40f 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -34,21 +34,35 @@ class Plugin:
async def check_lsfg_vk_installed(self):
return self.installation_service.check_installation()
- async def uninstall_lsfg_vk(self):
+ def _cleanup_runtime_state(self, preserve_wrapper: bool = False):
flatpak = self.flatpak_service.remove_plugin_owned_environment()
if not flatpak.get("success"):
+ return flatpak.get("error") or "Could not clean up Flatpak support"
+ profiles = self.configuration_service.reset_all_flatpak_configs()
+ if not profiles.get("success"):
+ return profiles.get("error") or "Could not remove Flatpak profiles"
+ wrapper = self.wrapper_service.neutralize() if preserve_wrapper else self.wrapper_service.purge()
+ if not wrapper.get("success"):
+ return wrapper.get("error") or "Could not remove workaround state"
+ return None
+
+ async def uninstall_lsfg_vk(self):
+ error = self._cleanup_runtime_state()
+ if error:
return {
"success": False,
"message": "",
- "error": flatpak.get("error") or "Could not clean up Flatpak support",
+ "error": error,
"removed_files": None,
}
- self.configuration_service.reset_all_flatpak_configs()
return self.installation_service.uninstall()
async def get_game_configs(self):
return self.configuration_service.get_game_configs()
+ async def update_global_config(self, config: Dict[str, Any]):
+ return self.configuration_service.update_global_config(config)
+
async def get_installed_games(self):
return self.steam_service.get_installed_games()
@@ -64,13 +78,17 @@ class Plugin:
async def get_workaround_state(self, appid: str):
return self.wrapper_service.get(appid)
+ async def get_workaround_apps(self):
+ return self.wrapper_service.list_apps()
+
async def set_workaround_state(
self,
appid: str,
state: Dict[str, Any],
command_token_added: bool = False,
+ non_steam: bool = False,
):
- return self.wrapper_service.set(appid, state, command_token_added)
+ return self.wrapper_service.set(appid, state, command_token_added, non_steam)
async def remove_workaround_state(self, appid: str):
return self.wrapper_service.remove(appid)
@@ -172,13 +190,13 @@ class Plugin:
async def _uninstall(self):
decky.logger.info("decky-lsfg-vk plugin being uninstalled")
try:
- result = self.flatpak_service.remove_plugin_owned_environment()
- if result.get("success"):
- self.configuration_service.reset_all_flatpak_configs()
- else:
- decky.logger.warning(result.get("error"))
+ error = self._cleanup_runtime_state(preserve_wrapper=True)
+ if error:
+ decky.logger.warning(f"Preserving lsfg-vk files because uninstall cleanup failed: {error}")
+ return
except Exception as error:
- decky.logger.error(f"Error during Flatpak cleanup: {error}")
+ decky.logger.error(f"Error during lsfg-vk cleanup: {error}")
+ return
self.installation_service.cleanup_on_uninstall()
decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed")
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
index ebe9526..30c2323 100644
--- a/py_modules/lsfg_vk/wrapper_service.py
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -87,9 +87,12 @@ class WrapperService(BaseService):
entry = {
"state": cls._validate_state(raw.get("state")),
"command_token_added": raw.get("command_token_added", False),
+ "non_steam": raw.get("non_steam", False),
}
if type(entry["command_token_added"]) is not bool:
raise ValueError("command_token_added must be a boolean")
+ if type(entry["non_steam"]) is not bool:
+ raise ValueError("non_steam must be a boolean")
return entry
@classmethod
@@ -263,6 +266,7 @@ class WrapperService(BaseService):
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": self._wrapper_marker() if document["apps"] else False,
"command_token_added": entry.get("command_token_added", False) if entry else False,
+ "non_steam": entry.get("non_steam", False) if entry else False,
}
def get(self, appid: str) -> Dict[str, Any]:
@@ -281,6 +285,7 @@ class WrapperService(BaseService):
"state": None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
+ "non_steam": False,
}
def set(
@@ -288,18 +293,22 @@ class WrapperService(BaseService):
appid: str,
state: Dict[str, Any],
command_token_added: bool = False,
+ non_steam: bool = False,
) -> Dict[str, Any]:
try:
normalized = self._valid_appid(appid)
validated_state = self._validate_state(state)
if type(command_token_added) is not bool:
raise ValueError("command_token_added must be a boolean")
+ if type(non_steam) is not bool:
+ raise ValueError("non_steam must be a boolean")
with self._lock:
self._assert_wrapper_owned_or_absent()
document, _, _ = self._read_document()
document["apps"][normalized] = {
"state": validated_state,
"command_token_added": command_token_added,
+ "non_steam": non_steam,
}
self._write_pair(document)
return self._response(document, normalized)
@@ -312,6 +321,7 @@ class WrapperService(BaseService):
"state": None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
+ "non_steam": False,
}
def remove(self, appid: str) -> Dict[str, Any]:
@@ -334,6 +344,7 @@ class WrapperService(BaseService):
"state": None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
+ "non_steam": False,
}
def repair(self) -> Dict[str, Any]:
@@ -353,3 +364,101 @@ class WrapperService(BaseService):
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
}
+
+ def list_apps(self) -> Dict[str, Any]:
+ try:
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ apps = [
+ {
+ "appid": appid,
+ "non_steam": entry.get("non_steam", False),
+ "command_token_added": entry.get("command_token_added", False),
+ }
+ for appid, entry in document["apps"].items()
+ ]
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "apps": apps,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "apps": [],
+ "wrapper_path": self.WRAPPER_TOKEN,
+ }
+
+ def purge(self) -> Dict[str, Any]:
+ """Remove the plugin-owned wrapper and its sidecar during uninstall.
+
+ This is deliberately separate from ``remove``: normal profile removal
+ leaves a safe passthrough wrapper for the remaining profiles, while an
+ uninstall should remove the wrapper entirely. Both files are
+ validated before anything is removed so a user's replacement wrapper
+ or damaged state is left untouched.
+ """
+ removed = []
+ try:
+ with self._lock:
+ _document, sidecar_exists, _ = self._read_document()
+ wrapper_owned = self._assert_wrapper_owned_or_absent()
+ if wrapper_owned:
+ self.wrapper_path.unlink()
+ removed.append(str(self.wrapper_path))
+ if sidecar_exists:
+ self.sidecar_path.unlink()
+ removed.append(str(self.sidecar_path))
+ return {
+ "success": True,
+ "message": "Removed lsfg-vk workaround state",
+ "error": None,
+ "removed_files": removed,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "removed_files": removed or None,
+ }
+
+ def neutralize(self) -> Dict[str, Any]:
+ """Leave an owned passthrough wrapper for Decky-level uninstall.
+
+ Decky can remove this plugin without giving the frontend a chance to
+ clean Steam launch options first. Keeping a dependency-free wrapper
+ prevents those options from turning into a broken executable path.
+ """
+ try:
+ with self._lock:
+ _document, sidecar_exists, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ self._write_file(
+ self.wrapper_path,
+ "#!/bin/sh\n"
+ f"{self.MARKER}\n"
+ "# Safe passthrough retained for existing Steam launch options.\n"
+ "exec \"$@\"\n",
+ 0o755,
+ )
+ if sidecar_exists:
+ self.sidecar_path.unlink()
+ return {
+ "success": True,
+ "message": "Replaced lsfg-vk wrapper with a safe passthrough",
+ "error": None,
+ "removed_files": [str(self.sidecar_path)] if sidecar_exists else [],
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "removed_files": None,
+ }