summaryrefslogtreecommitdiff
path: root/py_modules/lsfg_vk
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-05 23:44:37 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-05 23:44:37 -0400
commite8e469f99078858dc953663cba6f3428e80b1c5d (patch)
tree6d0e6730bf439ec2cb5b3564c636ce8342466df1 /py_modules/lsfg_vk
parent77656ad88655effe0411808baf2fb90c629a24f8 (diff)
downloaddecky-lsfg-vk-e8e469f99078858dc953663cba6f3428e80b1c5d.tar.gz
decky-lsfg-vk-e8e469f99078858dc953663cba6f3428e80b1c5d.zip
refactor: delegate runtime checks to lsfg-vk
Diffstat (limited to 'py_modules/lsfg_vk')
-rw-r--r--py_modules/lsfg_vk/config_schema.py9
-rw-r--r--py_modules/lsfg_vk/configuration.py13
-rw-r--r--py_modules/lsfg_vk/constants.py16
-rw-r--r--py_modules/lsfg_vk/dll_detection.py223
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py80
-rw-r--r--py_modules/lsfg_vk/installation.py70
-rw-r--r--py_modules/lsfg_vk/plugin.py93
-rw-r--r--py_modules/lsfg_vk/runtime_service.py120
-rw-r--r--py_modules/lsfg_vk/types.py17
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]