diff options
Diffstat (limited to 'py_modules/lsfg_vk')
| -rw-r--r-- | py_modules/lsfg_vk/config_schema.py | 9 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 13 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/constants.py | 16 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/dll_detection.py | 223 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 80 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 70 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 93 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/runtime_service.py | 120 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/types.py | 17 |
9 files changed, 245 insertions, 396 deletions
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 92a74a3..e39be54 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -60,15 +60,6 @@ class ConfigurationManager: return cast(ConfigurationData, dict(get_defaults())) @staticmethod - def get_defaults_with_dll_detection(dll_detection_service=None) -> ConfigurationData: - defaults = ConfigurationManager.get_defaults() - if dll_detection_service is not None: - result = dll_detection_service.check_lossless_scaling_dll() - if result.get("detected") and result.get("path"): - defaults["dll"] = result["path"] - return defaults - - @staticmethod def get_field_names() -> list[str]: return list(CONFIG_SCHEMA) diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index 9b4d536..12710cc 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -3,10 +3,15 @@ import shlex from .base_service import BaseService from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData from .config_schema_generated import ConfigurationData, get_script_generation_logic +from .runtime_service import RuntimeService from .types import ConfigurationResponse, ProfileResponse, ProfilesResponse class ConfigurationService(BaseService): + def __init__(self, logger=None, runtime_service: RuntimeService = None): + super().__init__(logger) + self.runtime_service = runtime_service or RuntimeService(logger=self.log) + def get_config(self) -> ConfigurationResponse: try: profile_data = self._get_profile_data() @@ -69,9 +74,7 @@ class ConfigurationService(BaseService): def _get_profile_data(self) -> ProfileData: if not self.config_file_path.exists(): - from .dll_detection import DllDetectionService - - default = ConfigurationManager.get_defaults_with_dll_detection(DllDetectionService(self.log)) + default = ConfigurationManager.get_defaults() return ProfileData( current_profile=DEFAULT_PROFILE_NAME, profiles={DEFAULT_PROFILE_NAME: dict(default)}, @@ -97,9 +100,11 @@ class ConfigurationService(BaseService): return profile_data def _save_profile_data(self, profile_data: ProfileData) -> None: + content = ConfigurationManager.generate_toml_content_multi_profile(profile_data) + self.runtime_service.validate_config_content(content) self._write_file( self.config_file_path, - ConfigurationManager.generate_toml_content_multi_profile(profile_data), + content, 0o644, ) diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 7bcd0dc..1f78a59 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -1,6 +1,5 @@ -from pathlib import Path - LOCAL_BIN = ".local/bin" +LOCAL_SHARE = ".local/share" LOCAL_LIB = ".local/lib" VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d" CONFIG_DIR = ".config/lsfg-vk" @@ -13,15 +12,14 @@ LIB_X86_FILENAME = "liblsfg-vk-layer.x86.so" JSON_FILENAME = "VkLayer_LSFGVK_frame_generation.json" JSON_X86_FILENAME = "VkLayer_LSFGVK_frame_generation.x86.json" 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" LEGACY_LIB_FILENAME = "liblsfg-vk.so" LEGACY_JSON_FILENAME = "VkLayer_LS_frame_generation.json" BIN_DIR = "bin" - -STEAM_COMMON_PATH = Path("steamapps/common/Lossless Scaling") -LOSSLESS_DLL_NAME = "lsfg-vk.dll" - -ENV_LSFG_DLL_PATH = "LSFGVK_DLL_PATH" -ENV_XDG_DATA_HOME = "XDG_DATA_HOME" -ENV_HOME = "HOME" diff --git a/py_modules/lsfg_vk/dll_detection.py b/py_modules/lsfg_vk/dll_detection.py deleted file mode 100644 index f7ba444..0000000 --- a/py_modules/lsfg_vk/dll_detection.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -DLL detection service for Lossless Scaling. -""" - -import os -import re -from pathlib import Path -from typing import Dict, Any, List - -from .base_service import BaseService -from .constants import ( - ENV_LSFG_DLL_PATH, ENV_XDG_DATA_HOME, ENV_HOME, - STEAM_COMMON_PATH, LOSSLESS_DLL_NAME -) -from .types import DllDetectionResponse - - -class DllDetectionService(BaseService): - """Service for detecting Lossless Scaling DLL""" - - def check_lossless_scaling_dll(self) -> DllDetectionResponse: - """Check if Lossless Scaling DLL is available at the expected paths - - Search order: - 1. LSFG_DLL_PATH environment variable - 2. XDG_DATA_HOME Steam directory - 3. HOME/.local/share Steam directory - 4. All Steam library folders (including SD cards) - - Returns: - DllDetectionResponse with detection status and path information - """ - try: - dll_path = self._check_env_dll_path() - if dll_path: - return dll_path - - xdg_path = self._check_xdg_data_home() - if xdg_path: - return xdg_path - - home_path = self._check_home_local_share() - if home_path: - return home_path - - steam_libraries_path = self._check_steam_library_folders() - if steam_libraries_path: - return steam_libraries_path - - return { - "detected": False, - "path": None, - "source": None, - "message": "Lossless Scaling DLL not found in expected locations", - "error": None - } - - except Exception as e: - error_msg = f"Error checking Lossless Scaling DLL: {str(e)}" - self.log.error(error_msg) - return { - "detected": False, - "path": None, - "source": None, - "message": None, - "error": str(e) - } - - def _check_env_dll_path(self) -> DllDetectionResponse | None: - """Check LSFG_DLL_PATH environment variable - - Returns: - DllDetectionResponse if found, None otherwise - """ - dll_path = os.getenv(ENV_LSFG_DLL_PATH) - if dll_path and dll_path.strip(): - dll_path_obj = Path(dll_path.strip()) - if dll_path_obj.exists(): - self.log.info(f"Found DLL via {ENV_LSFG_DLL_PATH}: {dll_path_obj}") - return { - "detected": True, - "path": str(dll_path_obj), - "source": f"{ENV_LSFG_DLL_PATH} environment variable", - "message": None, - "error": None - } - return None - - def _check_xdg_data_home(self) -> DllDetectionResponse | None: - """Check XDG_DATA_HOME Steam directory - - Returns: - DllDetectionResponse if found, None otherwise - """ - data_dir = os.getenv(ENV_XDG_DATA_HOME) - if data_dir and data_dir.strip(): - dll_path = Path(data_dir.strip()) / "Steam" / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME - if dll_path.exists(): - self.log.info(f"Found DLL via {ENV_XDG_DATA_HOME}: {dll_path}") - return { - "detected": True, - "path": str(dll_path), - "source": f"{ENV_XDG_DATA_HOME} Steam directory", - "message": None, - "error": None - } - return None - - def _check_home_local_share(self) -> DllDetectionResponse | None: - """Check HOME/.local/share Steam directory - - Returns: - DllDetectionResponse if found, None otherwise - """ - home_dir = os.getenv(ENV_HOME) - if home_dir and home_dir.strip(): - dll_path = Path(home_dir.strip()) / ".local" / "share" / "Steam" / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME - if dll_path.exists(): - self.log.info(f"Found DLL via {ENV_HOME}/.local/share: {dll_path}") - return { - "detected": True, - "path": str(dll_path), - "source": f"{ENV_HOME}/.local/share Steam directory", - "message": None, - "error": None - } - return None - - def _check_steam_library_folders(self) -> DllDetectionResponse | None: - """Check all Steam library folders for Lossless Scaling DLL - - This method parses Steam's libraryfolders.vdf file to find all - Steam library locations and checks each one for the DLL. - - Returns: - DllDetectionResponse if found, None otherwise - """ - steam_libraries = self._get_steam_library_paths() - - for library_path in steam_libraries: - dll_path = Path(library_path) / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME - if dll_path.exists(): - self.log.info(f"Found DLL in Steam library: {dll_path}") - return { - "detected": True, - "path": str(dll_path), - "source": f"Steam library folder: {library_path}", - "message": None, - "error": None - } - - return None - - def _get_steam_library_paths(self) -> List[str]: - """Get all Steam library folder paths from libraryfolders.vdf - - Returns: - List of Steam library folder paths - """ - library_paths = [] - - steam_paths = [] - - data_dir = os.getenv(ENV_XDG_DATA_HOME) - if data_dir and data_dir.strip(): - steam_paths.append(Path(data_dir.strip()) / "Steam") - - home_dir = os.getenv(ENV_HOME) - if home_dir and home_dir.strip(): - steam_paths.append(Path(home_dir.strip()) / ".local" / "share" / "Steam") - - for steam_path in steam_paths: - if steam_path.exists(): - library_paths.append(str(steam_path)) - - vdf_path = steam_path / "steamapps" / "libraryfolders.vdf" - if vdf_path.exists(): - try: - additional_paths = self._parse_library_folders_vdf(vdf_path) - library_paths.extend(additional_paths) - except Exception as e: - self.log.warning(f"Failed to parse {vdf_path}: {str(e)}") - - seen = set() - unique_paths = [] - for path in library_paths: - if path not in seen: - seen.add(path) - unique_paths.append(path) - - self.log.info(f"Found {len(unique_paths)} Steam library paths: {unique_paths}") - return unique_paths - - def _parse_library_folders_vdf(self, vdf_path: Path) -> List[str]: - """Parse Steam's libraryfolders.vdf file to extract library paths - - Args: - vdf_path: Path to the libraryfolders.vdf file - - Returns: - List of additional Steam library folder paths - """ - library_paths = [] - - try: - with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - - path_pattern = r'"path"\s*"([^"]+)"' - matches = re.findall(path_pattern, content, re.IGNORECASE) - - for path_match in matches: - path = path_match.replace('\\\\', '/').replace('\\', '/') - library_path = Path(path) - - if library_path.exists() and (library_path / "steamapps").exists(): - library_paths.append(str(library_path)) - self.log.info(f"Found additional Steam library: {library_path}") - - except Exception as e: - self.log.error(f"Error parsing libraryfolders.vdf: {str(e)}") - - return library_paths diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 627bbd8..302efc7 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -6,13 +6,18 @@ from typing import Any, Dict, List from .base_service import BaseService from .config_schema import ConfigurationManager -from .dll_detection import DllDetectionService +from .constants import ( + BIN_DIR, + FLATPAK_23_08_FILENAME, + FLATPAK_24_08_FILENAME, + FLATPAK_25_08_FILENAME, +) from .types import BaseResponse class FlatpakService(BaseService): EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" - SUPPORTED_RUNTIMES = ("24.08", "25.08") + SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") def __init__(self, logger=None): super().__init__(logger) @@ -51,6 +56,18 @@ class FlatpakService(BaseService): if version not in cls.SUPPORTED_RUNTIMES: raise ValueError("Unsupported Flatpak runtime") + @classmethod + def _bundle_filename(cls, version: str) -> str: + return { + "23.08": FLATPAK_23_08_FILENAME, + "24.08": FLATPAK_24_08_FILENAME, + "25.08": FLATPAK_25_08_FILENAME, + }[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) + def get_extension_status(self) -> Dict[str, Any]: try: if not self.check_flatpak_available(): @@ -70,6 +87,7 @@ class FlatpakService(BaseService): 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, ) @@ -77,6 +95,7 @@ class FlatpakService(BaseService): return self._error_response( BaseResponse, str(error), + installed_23_08=False, installed_24_08=False, installed_25_08=False, ) @@ -86,14 +105,18 @@ class FlatpakService(BaseService): 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" + ) result = self._run_flatpak_command( [ "install", "--user", "--noninteractive", "--or-update", - "flathub", - f"{self.EXTENSION_ID}//{version}", + str(bundle_path), ], capture_output=True, text=True, @@ -102,7 +125,7 @@ class FlatpakService(BaseService): raise OSError(result.stderr.strip() or "Flatpak installation failed") return self._success_response( BaseResponse, - f"lsfg-vk {version} runtime extension installed", + f"lsfg-vk {version} runtime extension installed from the bundled asset", ) except Exception as error: return self._error_response(BaseResponse, str(error)) @@ -126,6 +149,14 @@ class FlatpakService(BaseService): except Exception as error: return self._error_response(BaseResponse, str(error)) + def _override_output(self, app_id: str) -> str: + result = self._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + return result.stdout if result.returncode == 0 else "" + def _dll_directory(self) -> Path: if self.config_file_path.exists(): try: @@ -138,24 +169,14 @@ class FlatpakService(BaseService): except Exception: pass - result = DllDetectionService(self.log).check_lossless_scaling_dll() - if result.get("detected") and result.get("path"): - return Path(result["path"]).parent - return self.user_home / ".local/share/Steam/steamapps/common" - - def _override_output(self, app_id: str) -> str: - result = self._run_flatpak_command( - ["override", "--user", "--show", app_id], - capture_output=True, - text=True, - ) - return result.stdout if result.returncode == 0 else "" + return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling" 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" @@ -224,6 +245,9 @@ class FlatpakService(BaseService): f"--filesystem={paths['config_dir']}:rw", f"--filesystem={paths['dll_dir']}:ro", f"--env=LSFGVK_CONFIG={paths['config_file']}", + # Remove permissions/env from the pre-v2 plugin when an + # existing app is explicitly migrated or reconfigured. + f"--nofilesystem={paths['legacy_home']}", f"--nofilesystem={paths['legacy_dll']}", f"--nofilesystem={paths['legacy_script']}", "--unset-env=LSFG_CONFIG", @@ -259,6 +283,7 @@ class FlatpakService(BaseService): "--user", f"--nofilesystem={paths['config_dir']}", f"--nofilesystem={paths['dll_dir']}", + f"--nofilesystem={paths['legacy_home']}", f"--nofilesystem={paths['legacy_dll']}", f"--nofilesystem={paths['legacy_script']}", "--unset-env=LSFGVK_CONFIG", @@ -288,17 +313,6 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): return - status = self.get_extension_status() - for version, key in ( - ("24.08", "installed_24_08"), - ("25.08", "installed_25_08"), - ): - if not status.get(key): - continue - result = self.install_extension(version) - if not result.get("success"): - self.log.warning(result.get("error")) - apps_result = self._run_flatpak_command( ["list", "--user", "--app", "--columns=application"], capture_output=True, @@ -311,7 +325,15 @@ class FlatpakService(BaseService): app_id = app_id.strip() if not app_id: continue - if "LSFG_CONFIG=" in self._override_output(app_id): + output = self._override_output(app_id) + paths = self._override_paths() + legacy_markers = ( + "LSFG_CONFIG=", + paths["legacy_home"], + paths["legacy_dll"], + paths["legacy_script"], + ) + if any(marker in output for marker in legacy_markers): result = self.set_app_override(app_id) if not result.get("success"): self.log.warning(result.get("error")) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 0566d6b..e979ae5 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -18,13 +18,19 @@ from .constants import ( LEGACY_LIB_FILENAME, LIB_FILENAME, LIB_X86_FILENAME, + LOCAL_SHARE, + UI_DESKTOP_FILENAME, + UI_FILENAME, + UI_ICON_FILENAME, ) +from .runtime_service import RuntimeService from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse class InstallationService(BaseService): - def __init__(self, logger=None): + def __init__(self, logger=None, runtime_service: RuntimeService = None): super().__init__(logger) + self.runtime_service = runtime_service or RuntimeService(logger=self.log) self.lib_file = self.local_lib_dir / LIB_FILENAME self.lib_x86_file = self.local_lib_dir / LIB_X86_FILENAME self.json_file = self.local_share_dir / JSON_FILENAME @@ -43,9 +49,11 @@ class InstallationService(BaseService): self._ensure_directories() profile_data = self._prepare_config() self._install_archive(archive_path) + config_content = ConfigurationManager.generate_toml_content_multi_profile(profile_data) + self.runtime_service.validate_config_content(config_content) self._write_file( self.config_file_path, - ConfigurationManager.generate_toml_content_multi_profile(profile_data), + config_content, 0o644, ) self._create_lsfg_launch_script(profile_data) @@ -58,16 +66,25 @@ class InstallationService(BaseService): def _payload_destinations(self) -> Dict[str, tuple[Path, int]]: return { f"bin/{CLI_FILENAME}": (self.cli_file, 0o755), + f"bin/{UI_FILENAME}": (self.local_bin_dir / UI_FILENAME, 0o755), f"lib/{LIB_FILENAME}": (self.lib_file, 0o644), f"lib/{LIB_X86_FILENAME}": (self.lib_x86_file, 0o644), f"share/vulkan/implicit_layer.d/{JSON_FILENAME}": (self.json_file, 0o644), f"share/vulkan/implicit_layer.d/{JSON_X86_FILENAME}": (self.json_x86_file, 0o644), + f"share/applications/{UI_DESKTOP_FILENAME}": ( + self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME, + 0o644, + ), + f"share/icons/hicolor/256x256/apps/{UI_ICON_FILENAME}": ( + self.user_home / LOCAL_SHARE / "icons" / "hicolor" / "256x256" / "apps" / UI_ICON_FILENAME, + 0o644, + ), } def _install_archive(self, archive_path: Path) -> None: destinations = self._payload_destinations() found = set() - with tarfile.open(archive_path, "r:xz") as archive: + with tarfile.open(archive_path, "r:*") as archive: members = { member.name.removeprefix("./"): member for member in archive.getmembers() @@ -126,13 +143,6 @@ class InstallationService(BaseService): }, ) - from .dll_detection import DllDetectionService - - if not profile_data["global_config"].get("dll"): - dll_result = DllDetectionService(self.log).check_lossless_scaling_dll() - if dll_result.get("detected") and dll_result.get("path"): - profile_data["global_config"]["dll"] = dll_result["path"] - defaults = dict(ConfigurationManager.get_defaults()) for profile_name, raw_profile in list(profile_data["profiles"].items()): validated = ConfigurationManager.validate_config({**defaults, **raw_profile}) @@ -189,35 +199,38 @@ class InstallationService(BaseService): ) except OSError: legacy_config = False - return legacy_layer or legacy_config + if legacy_layer or legacy_config: + return True + try: + return not self.runtime_service.is_healthy() + except Exception: + return True def get_launch_script_path(self) -> str: return str(self.lsfg_launch_script_path) def check_installation(self) -> InstallationCheckResponse: try: - lib_exists = self.lib_file.exists() and self.lib_x86_file.exists() - json_exists = self.json_file.exists() and self.json_x86_file.exists() script_exists = self.lsfg_launch_script_path.exists() + installation_error = None + try: + installed = script_exists and self.runtime_service.is_healthy() + except Exception as error: + installed = False + installation_error = str(error) + + lossless_scaling = self.runtime_service.check_lossless_scaling() return { - "installed": lib_exists and json_exists, - "lib_exists": lib_exists, - "json_exists": json_exists, - "script_exists": script_exists, - "lib_path": str(self.lib_file), - "json_path": str(self.json_file), - "script_path": str(self.lsfg_launch_script_path), - "error": None, + "installed": installed, + "lossless_scaling_installed": bool(lossless_scaling["installed"]), + "lossless_scaling_status": str(lossless_scaling["status"]), + "error": installation_error, } except Exception as error: return { "installed": False, - "lib_exists": False, - "json_exists": False, - "script_exists": False, - "lib_path": str(self.lib_file), - "json_path": str(self.json_file), - "script_path": str(self.lsfg_launch_script_path), + "lossless_scaling_installed": False, + "lossless_scaling_status": str(error), "error": str(error), } @@ -230,6 +243,9 @@ class InstallationService(BaseService): self.json_file, self.json_x86_file, self.cli_file, + self.local_bin_dir / UI_FILENAME, + self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME, + self.user_home / LOCAL_SHARE / "icons" / "hicolor" / "256x256" / "apps" / UI_ICON_FILENAME, self.legacy_lib_file, self.legacy_json_file, self.lsfg_launch_script_path, diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index a9eb6a6..6a8dfc5 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -6,17 +6,16 @@ Vulkan layer for frame generation on Steam Deck. """ import os -import hashlib from typing import Dict, Any from pathlib import Path import decky from .installation import InstallationService -from .dll_detection import DllDetectionService from .configuration import ConfigurationService from .config_schema import ConfigurationManager from .flatpak_service import FlatpakService +from .runtime_service import RuntimeService class Plugin: @@ -24,15 +23,15 @@ class Plugin: Main plugin class for lsfg-vk management. This class provides a unified interface for installation, configuration, - and DLL detection services. It implements the Decky Loader plugin lifecycle + and Flatpak management services. It implements the Decky Loader plugin lifecycle methods (_main, _unload, _uninstall, _migration). """ def __init__(self): """Initialize the plugin with all necessary services""" - self.installation_service = InstallationService() - self.dll_detection_service = DllDetectionService() - self.configuration_service = ConfigurationService() + self.runtime_service = RuntimeService() + self.installation_service = InstallationService(runtime_service=self.runtime_service) + self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() async def install_lsfg_vk(self) -> Dict[str, Any]: @@ -59,72 +58,6 @@ class Plugin: """ return self.installation_service.uninstall() - async def check_lossless_scaling_dll(self) -> Dict[str, Any]: - """Check if Lossless Scaling DLL is available at the expected paths - - Returns: - DllDetectionResponse dict with detection status and path info - """ - return self.dll_detection_service.check_lossless_scaling_dll() - - async def get_dll_stats(self) -> Dict[str, Any]: - """Get detailed statistics about the detected DLL - - Returns: - Dict containing DLL path, SHA256 hash, and other stats - """ - try: - dll_result = self.dll_detection_service.check_lossless_scaling_dll() - - if not dll_result.get("detected") or not dll_result.get("path"): - return { - "success": False, - "error": "DLL not detected", - "dll_path": None, - "dll_sha256": None - } - - dll_path = dll_result["path"] - if dll_path is None: - return { - "success": False, - "error": "DLL path is None", - "dll_path": None, - "dll_sha256": None - } - - dll_path_obj = Path(dll_path) - - sha256_hash = hashlib.sha256() - try: - with open(dll_path_obj, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - sha256_hash.update(chunk) - dll_sha256 = sha256_hash.hexdigest() - except Exception as e: - return { - "success": False, - "error": f"Failed to calculate SHA256: {str(e)}", - "dll_path": dll_path, - "dll_sha256": None - } - - return { - "success": True, - "dll_path": dll_path, - "dll_sha256": dll_sha256, - "dll_source": dll_result.get("source"), - "error": None - } - - except Exception as e: - return { - "success": False, - "error": f"Failed to get DLL stats: {str(e)}", - "dll_path": None, - "dll_sha256": None - } - async def get_lsfg_config(self) -> Dict[str, Any]: """Read current lsfg script configuration @@ -353,7 +286,7 @@ class Plugin: """Check status of lsfg-vk Flatpak runtime extensions Returns: - FlatpakExtensionStatus dict with installation status for both runtime versions + FlatpakExtensionStatus dict with installation status for all supported runtime versions """ return self.flatpak_service.get_extension_status() @@ -361,7 +294,7 @@ class Plugin: """Install lsfg-vk Flatpak runtime extension Args: - version: Runtime version to install ("24.08" or "25.08") + version: Runtime version to install ("23.08", "24.08", or "25.08") Returns: BaseResponse dict with success status and message/error @@ -372,7 +305,7 @@ class Plugin: """Uninstall lsfg-vk Flatpak runtime extension Args: - version: Runtime version to uninstall ("24.08" or "25.08") + version: Runtime version to uninstall ("23.08", "24.08", or "25.08") Returns: BaseResponse dict with success status and message/error @@ -442,6 +375,7 @@ class Plugin: 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"), ): @@ -479,10 +413,9 @@ class Plugin: if not result.get("success"): decky.logger.warning(f"Native v2 migration failed: {result.get('error')}") - if self.installation_service.check_installation().get("installed"): - try: - self.flatpak_service.migrate_v2() - except Exception as error: - decky.logger.warning(f"Flatpak v2 migration skipped: {error}") + try: + self.flatpak_service.migrate_v2() + except Exception as error: + decky.logger.warning(f"Flatpak v2 migration skipped: {error}") decky.logger.info("decky-lsfg-vk plugin migrations completed") diff --git a/py_modules/lsfg_vk/runtime_service.py b/py_modules/lsfg_vk/runtime_service.py new file mode 100644 index 0000000..a61220e --- /dev/null +++ b/py_modules/lsfg_vk/runtime_service.py @@ -0,0 +1,120 @@ +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Sequence + +from .base_service import BaseService +from .constants import CLI_FILENAME + + +class RuntimeService(BaseService): + COMMAND_TIMEOUT_SECONDS = 10 + MAX_OUTPUT_LENGTH = 12_000 + DLL_MISSING_MARKER = "! The DLL file does not exist:" + DLL_NONE_MARKER = "DLL override: (none)" + + def __init__(self, logger=None): + super().__init__(logger) + self.cli_path = self.local_bin_dir / CLI_FILENAME + + def _environment(self) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + HOME=str(self.user_home), + XDG_CONFIG_HOME=str(self.user_home / ".config"), + ) + for name in ("LSFGVK_CONFIG", "LSFGVK_PROFILE", "LSFGVK_ENV"): + environment.pop(name, None) + return environment + + @classmethod + def _output(cls, stdout: Any, stderr: Any) -> str: + values = [] + for value in (stdout, stderr): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + if value: + values.append(str(value).strip()) + output = "\n".join(value for value in values if value) + return output if len(output) <= cls.MAX_OUTPUT_LENGTH else output[: cls.MAX_OUTPUT_LENGTH] + + def _run(self, arguments: Sequence[str]) -> tuple[int, str]: + if not self.cli_path.is_file() or not os.access(self.cli_path, os.X_OK): + raise FileNotFoundError(f"{CLI_FILENAME} is not installed at {self.cli_path}") + result = subprocess.run( + [str(self.cli_path), *arguments], + cwd=str(self.user_home), + env=self._environment(), + capture_output=True, + text=True, + timeout=self.COMMAND_TIMEOUT_SECONDS, + check=False, + ) + return result.returncode, self._output(result.stdout, result.stderr) + + def validate_config_content(self, content: str) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=self.config_dir, + prefix=".conf.toml.", + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + + returncode, output = self._run(("validate", "--config", str(temporary_path))) + if returncode != 0: + raise ValueError(output or "lsfg-vk rejected the generated configuration") + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + def is_healthy(self) -> bool: + returncode, output = self._run(("healthcheck",)) + return returncode == 0 and "Healthcheck found issues" not in output + + def check_lossless_scaling(self) -> dict[str, Any]: + """Report Lossless Scaling state from lsfg-vk's own validator.""" + if not self.config_file_path.is_file(): + return { + "installed": False, + "status": "lsfg-vk configuration is not installed", + } + + try: + returncode, output = self._run( + ("validate", "--config", str(self.config_file_path), "--print") + ) + except Exception as error: + return {"installed": False, "status": str(error)} + + if returncode != 0: + return { + "installed": False, + "status": output or "lsfg-vk could not validate its configuration", + } + + if self.DLL_MISSING_MARKER in output: + return { + "installed": False, + "status": "Lossless Scaling's lsfg-vk.dll was not found", + } + + if self.DLL_NONE_MARKER in output: + return { + "installed": False, + "status": "Lossless Scaling's lsfg-vk.dll is not configured", + } + + return { + "installed": True, + "status": "Lossless Scaling detected by lsfg-vk", + } diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 7b7ca2b..0f0428b 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -37,21 +37,8 @@ class UninstallationResponse(BaseResponse): class InstallationCheckResponse(TypedDict): """Response for installation check""" installed: bool - lib_exists: bool - json_exists: bool - script_exists: bool - lib_path: str - json_path: str - script_path: str - error: Optional[str] - - -class DllDetectionResponse(TypedDict): - """Response for DLL detection""" - detected: bool - path: Optional[str] - source: Optional[str] - message: Optional[str] + lossless_scaling_installed: bool + lossless_scaling_status: str error: Optional[str] |
