From 630a8c7180650eb0a078014d8e869ace79d1e9e2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:25:48 -0400 Subject: chore: remove arm runtime support --- py_modules/lsfg_vk/configuration.py | 31 ++++++----------------- py_modules/lsfg_vk/constants.py | 3 --- py_modules/lsfg_vk/installation.py | 50 +++---------------------------------- 3 files changed, 11 insertions(+), 73 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index fce3738..de11b48 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -8,7 +8,6 @@ from typing import Dict, Any from .base_service import BaseService from .config_schema import ConfigurationManager, CONFIG_SCHEMA, ProfileData, DEFAULT_PROFILE_NAME from .config_schema_generated import ConfigurationData, get_script_generation_logic -from .constants import ARMADA_DEVICE_ENV, ARMADA_GAME_LAUNCH from .types import ConfigurationResponse, ProfilesResponse, ProfileResponse @@ -124,8 +123,10 @@ class ConfigurationService(BaseService): generate_script_lines = get_script_generation_logic() lines.extend(generate_script_lines(config)) - lines.append("export LSFG_PROCESS=decky-lsfg-vk") - lines.extend(self._generate_game_launch_lines()) + lines.extend([ + "export LSFG_PROCESS=decky-lsfg-vk", + 'exec "$@"' + ]) return "\n".join(lines) + "\n" @@ -153,29 +154,13 @@ class ConfigurationService(BaseService): generate_script_lines = get_script_generation_logic() lines.extend(generate_script_lines(merged_config)) - lines.append(f"export LSFG_PROCESS={current_profile}") - lines.extend(self._generate_game_launch_lines()) + lines.extend([ + f"export LSFG_PROCESS={current_profile}", + 'exec "$@"' + ]) return "\n".join(lines) + "\n" - @staticmethod - def _generate_game_launch_lines() -> list[str]: - """Generate a portable exec block with Armada's host wrapper.""" - device_env = ARMADA_DEVICE_ENV.as_posix() - game_launch = ARMADA_GAME_LAUNCH.as_posix() - return [ - f'armada_game_launch="{game_launch}"', - 'for argument in "$@"; do', - ' if [ "$argument" = "$armada_game_launch" ]; then', - ' exec "$@"', - " fi", - "done", - f'if [ -f "{device_env}" ] && [ -x "$armada_game_launch" ]; then', - ' exec "$armada_game_launch" "$@"', - "fi", - 'exec "$@"', - ] - def _get_profile_data(self) -> ProfileData: """Get current profile data from config file""" if not self.config_file_path.exists(): diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 023894f..fe2febb 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -14,7 +14,6 @@ CONFIG_FILENAME = "conf.toml" LIB_FILENAME = "liblsfg-vk.so" JSON_FILENAME = "VkLayer_LS_frame_generation.json" ZIP_FILENAME = "lsfg-vk_noui.zip" -ARM_LIB_FILENAME = "liblsfg-vk-arm64.so" 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" @@ -25,8 +24,6 @@ JSON_EXT = ".json" BIN_DIR = "bin" -ARMADA_DEVICE_ENV = Path("/usr/libexec/armada/device-env") -ARMADA_GAME_LAUNCH = Path("/usr/libexec/armada/armada-game-launch") STEAM_COMMON_PATH = Path("steamapps/common/Lossless Scaling") LOSSLESS_DLL_NAME = "Lossless.dll" diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index ce47268..4329d49 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -3,7 +3,6 @@ Installation service for lsfg-vk. """ import os -import platform import shutil import traceback import zipfile @@ -15,7 +14,7 @@ from typing import Dict, Any from .base_service import BaseService from .constants import ( LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME, BIN_DIR, - SO_EXT, JSON_EXT, ARM_LIB_FILENAME, ARMADA_DEVICE_ENV + SO_EXT, JSON_EXT ) from .config_schema import ConfigurationManager from .types import InstallationResponse, UninstallationResponse, InstallationCheckResponse @@ -49,13 +48,6 @@ class InstallationService(BaseService): self._extract_and_install_files(zip_path) - # If on ARM, overwrite the .so with the ARM version - if self._is_arm_architecture(): - self.log.info("Detected ARM architecture, using ARM binary") - arm_so_path = plugin_dir / BIN_DIR / ARM_LIB_FILENAME - self._copy_plugin_file(arm_so_path, self.lib_file) - self.log.info(f"Overwrote with ARM binary: {self.lib_file}") - self._create_config_file() self._create_lsfg_launch_script() @@ -72,42 +64,6 @@ class InstallationService(BaseService): self.log.error(error_msg) return self._error_response(InstallationResponse, str(e), message="") - def _is_arm_architecture(self) -> bool: - """Check if running on ARM architecture - - Returns: - True if running on ARM (aarch64), False otherwise - """ - if platform.machine().lower() in ('aarch64', 'arm64'): - return True - - # Decky runs through FEX on Armada, so Python reports x86_64 even - # though the host is AArch64. Armada exposes this native helper only - # on its ARM image, including inside Decky's FEX rootfs. - if ARMADA_DEVICE_ENV.is_file(): - self.log.info("Detected native AArch64 Armada host through device-env") - return True - - # Fall back to the native PID 1 ELF header. e_machine 183 is AArch64. - try: - with Path('/proc/1/exe').open('rb') as host_executable: - elf_header = host_executable.read(20) - if elf_header[:4] == b'\x7fELF' and elf_header[5] in (1, 2): - byte_order = 'little' if elf_header[5] == 1 else 'big' - if int.from_bytes(elf_header[18:20], byte_order) == 183: - self.log.info("Detected native AArch64 host through PID 1") - return True - except OSError as e: - self.log.debug(f"Could not inspect native host architecture: {e}") - - return False - - @staticmethod - def _copy_plugin_file(src_file: Path, dst_file: Path) -> None: - """Copy plugin content without preserving FEX-incompatible metadata.""" - shutil.copyfile(src_file, dst_file) - dst_file.chmod(0o644) - def _extract_and_install_files(self, zip_path: Path) -> None: """Extract zip file and install files to appropriate locations @@ -145,7 +101,7 @@ class InstallationService(BaseService): if file_path.suffix == JSON_EXT and file == JSON_FILENAME: self._copy_and_fix_json_file(src_file, dst_file) else: - self._copy_plugin_file(src_file, dst_file) + shutil.copy2(src_file, dst_file) self.log.info(f"Copied {file} to {dst_file}") @@ -175,7 +131,7 @@ class InstallationService(BaseService): except (json.JSONDecodeError, KeyError, OSError) as e: self.log.error(f"Error fixing JSON file {src_file}: {e}") # Fallback to simple copy if JSON modification fails - self._copy_plugin_file(src_file, dst_file) + shutil.copy2(src_file, dst_file) def _create_config_file(self) -> None: """Create or update the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path -- cgit v1.2.3 From 4fe21381b6c694d5eb615447c4827e4921593a52 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:30:42 -0400 Subject: feat: migrate native runtime to lsfg-vk v2 --- py_modules/lsfg_vk/base_service.py | 138 ++--- py_modules/lsfg_vk/config_schema.py | 784 +++++++++----------------- py_modules/lsfg_vk/config_schema_generated.py | 4 +- py_modules/lsfg_vk/configuration.py | 545 ++++++------------ py_modules/lsfg_vk/constants.py | 27 +- py_modules/lsfg_vk/installation.py | 555 +++++++----------- 6 files changed, 687 insertions(+), 1366 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py index 262e2b0..c4978c6 100644 --- a/py_modules/lsfg_vk/base_service.py +++ b/py_modules/lsfg_vk/base_service.py @@ -1,132 +1,66 @@ -""" -Base service class with common functionality. -""" - -import logging import os -import shutil +import tempfile from pathlib import Path -from typing import Any, Optional, TypeVar, Dict +from typing import Any, Dict, Optional, TypeVar import decky -from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME +from .constants import CONFIG_DIR, CONFIG_FILENAME, LOCAL_BIN, LOCAL_LIB, SCRIPT_NAME, VULKAN_LAYER_DIR -ResponseType = TypeVar('ResponseType', bound=Dict[str, Any]) +ResponseType = TypeVar("ResponseType", bound=Dict[str, Any]) class BaseService: - """Base service class with common functionality""" - def __init__(self, logger: Optional[Any] = None): - """Initialize base service - - Args: - logger: Logger instance, defaults to decky.logger if None - """ - if logger is None: - self.log = decky.logger - else: - self.log = logger - + self.log = decky.logger if logger is None else logger self.user_home = Path.home() + self.local_bin_dir = self.user_home / LOCAL_BIN self.local_lib_dir = self.user_home / LOCAL_LIB self.local_share_dir = self.user_home / VULKAN_LAYER_DIR self.lsfg_script_path = self.user_home / SCRIPT_NAME self.lsfg_launch_script_path = self.user_home / SCRIPT_NAME self.config_dir = self.user_home / CONFIG_DIR self.config_file_path = self.config_dir / CONFIG_FILENAME - + def _ensure_directories(self) -> None: - """Create necessary directories if they don't exist""" - self.local_lib_dir.mkdir(parents=True, exist_ok=True) - self.local_share_dir.mkdir(parents=True, exist_ok=True) - self.config_dir.mkdir(parents=True, exist_ok=True) - self.log.info(f"Ensured directories exist: {self.local_lib_dir}, {self.local_share_dir}, {self.config_dir}") - + for directory in (self.local_bin_dir, self.local_lib_dir, self.local_share_dir, self.config_dir): + directory.mkdir(parents=True, exist_ok=True) + def _remove_if_exists(self, path: Path) -> bool: - """Remove a file if it exists - - Args: - path: Path to the file to remove - - Returns: - True if file was removed, False if it didn't exist - - Raises: - OSError: If removal fails - """ - if path.exists(): - try: - path.unlink() - self.log.info(f"Removed {path}") - return True - except OSError as e: - self.log.error(f"Failed to remove {path}: {e}") - raise - else: - self.log.info(f"File not found: {path}") + if not path.exists() and not path.is_symlink(): return False - + path.unlink() + self.log.info(f"Removed {path}") + return True + def _write_file(self, path: Path, content: str, mode: int = 0o644) -> None: - """Write content to a file - - Args: - path: Target file path - content: Content to write - mode: File permissions (default: 0o644) - - Raises: - OSError: If write fails - """ + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None try: - with open(path, 'w', encoding='utf-8') as f: - f.write(content) - f.flush() - os.fsync(f.fileno()) - - path.chmod(mode) - self.log.info(f"Wrote to {path}") - - except (OSError, IOError, PermissionError) as e: - self.log.error(f"Failed to write to {path}: {e}") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(mode) + os.replace(temporary_path, path) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) raise def _success_response(self, response_type: type, message: str = "", **kwargs) -> Any: - """Create a standardized success response - - Args: - response_type: The TypedDict response type to create - message: Success message - **kwargs: Additional response fields - - Returns: - Success response dict - """ - response = { - "success": True, - "message": message, - "error": None - } + response = {"success": True, "message": message, "error": None} response.update(kwargs) return response - + def _error_response(self, response_type: type, error: str, message: str = "", **kwargs) -> Any: - """Create a standardized error response - - Args: - response_type: The TypedDict response type to create - error: Error description - message: Optional message - **kwargs: Additional response fields - - Returns: - Error response dict - """ - response = { - "success": False, - "message": message, - "error": error - } + response = {"success": False, "message": message, "error": error} response.update(kwargs) return response diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index f76b5f2..86e8be7 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,605 +1,357 @@ -""" -Centralized configuration schema for lsfg-vk. - -This module defines the complete configuration structure for lsfg-vk, managing TOML-based config files, including: -- Field definitions with types, defaults, and metadata -- TOML generation logic -- Validation rules -- Type definitions -""" - -import logging +import json import re +import shlex import sys -from typing import TypedDict, Dict, Any, Union, cast, List +import tomllib from dataclasses import dataclass -from enum import Enum from pathlib import Path +from typing import Any, Dict, TypedDict, Union, cast -# Import shared configuration constants sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_field_names, get_defaults, get_field_types - -# Import auto-generated configuration components -from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic +from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_defaults +from .config_schema_generated import ConfigurationData, get_script_parsing_logic @dataclass class ConfigField: - """Configuration field definition""" name: str field_type: ConfigFieldType default: Union[bool, int, float, str] description: str - - def get_toml_value(self, value: Union[bool, int, float, str]) -> Union[bool, int, float, str]: - """Get the value for TOML output""" - return value -# Use shared configuration schema as source of truth CONFIG_SCHEMA: Dict[str, ConfigField] = { - field_name: ConfigField( - name=field_def["name"], - field_type=ConfigFieldType(field_def["fieldType"]), - default=field_def["default"], - description=field_def["description"] + name: ConfigField( + name=definition["name"], + field_type=ConfigFieldType(definition["fieldType"]), + default=definition["default"], + description=definition["description"], ) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() + for name, definition in CONFIG_SCHEMA_DEF.items() } -# Override DLL default to empty (will be populated dynamically) -CONFIG_SCHEMA["dll"] = ConfigField( - name="dll", - field_type=ConfigFieldType.STRING, - default="", # Will be populated dynamically based on detection - description="specify where Lossless.dll is stored" -) - -# Get script-only fields dynamically from shared config SCRIPT_ONLY_FIELDS = { - field_name: ConfigField( - name=field_def["name"], - field_type=ConfigFieldType(field_def["fieldType"]), - default=field_def["default"], - description=field_def["description"] - ) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" + name + for name, definition in CONFIG_SCHEMA_DEF.items() + if definition["location"] == "script" } - -# Complete configuration schema (TOML + script-only fields) -COMPLETE_CONFIG_SCHEMA = {**CONFIG_SCHEMA, **SCRIPT_ONLY_FIELDS} - -# Constants for profile management DEFAULT_PROFILE_NAME = "decky-lsfg-vk" -GLOBAL_SECTION_FIELDS = {"dll", "no_fp16"} - -# Note: ConfigurationData is now imported from generated file -# No need to manually maintain the TypedDict anymore! class ProfileData(TypedDict): - """Profile data with current profile tracking""" current_profile: str - profiles: Dict[str, ConfigurationData] # profile_name -> config - global_config: Dict[str, Any] # Global settings (dll, no_fp16) + profiles: Dict[str, Dict[str, Any]] + global_config: Dict[str, Any] + + +def _toml_value(value: Any) -> str: + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, list): + return "[ " + ", ".join(_toml_value(item) for item in value) + " ]" + return str(value) class ConfigurationManager: - """Centralized configuration management""" - @staticmethod def get_defaults() -> ConfigurationData: - """Get default configuration values""" - # Use shared defaults and add script-only fields - shared_defaults = get_defaults() - - # Add script-only fields that aren't in the shared schema - script_defaults = { - field.name: field.default - for field in SCRIPT_ONLY_FIELDS.values() - } - - return cast(ConfigurationData, {**shared_defaults, **script_defaults}) - + return cast(ConfigurationData, dict(get_defaults())) + @staticmethod def get_defaults_with_dll_detection(dll_detection_service=None) -> ConfigurationData: - """Get default configuration values with DLL path detection - - Args: - dll_detection_service: Optional DLL detection service instance - - Returns: - ConfigurationData with detected DLL path if available - """ defaults = ConfigurationManager.get_defaults() - - # Try to detect DLL path if service provided - if dll_detection_service: - try: - dll_result = dll_detection_service.check_lossless_scaling_dll() - if dll_result.get("detected") and dll_result.get("path"): - defaults["dll"] = dll_result["path"] - except (OSError, IOError, KeyError, TypeError) as e: - # If detection fails, keep empty default - logging.getLogger(__name__).debug(f"DLL detection failed: {e}") - - # If DLL path is still empty, use a reasonable fallback - if not defaults["dll"]: - defaults["dll"] = "/home/deck/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - + 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]: - """Get ordered list of configuration field names""" - # Use shared field names and add script-only fields - shared_names = get_field_names() - script_names = list(SCRIPT_ONLY_FIELDS.keys()) - return shared_names + script_names - + return list(CONFIG_SCHEMA) + @staticmethod def get_field_types() -> Dict[str, ConfigFieldType]: - """Get field type mapping""" - # Use shared field types and add script-only field types - shared_types = {name: ConfigFieldType(type_str) for name, type_str in get_field_types().items()} - script_types = {field.name: field.field_type for field in SCRIPT_ONLY_FIELDS.values()} - return {**shared_types, **script_types} - + return {name: field.field_type for name, field in CONFIG_SCHEMA.items()} + @staticmethod def validate_config(config: Dict[str, Any]) -> ConfigurationData: - """Validate and convert configuration data""" - validated = {} - - for field_name, field_def in COMPLETE_CONFIG_SCHEMA.items(): - value = config.get(field_name, field_def.default) - - # Type validation and conversion - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated[field_name] = bool(value) - elif field_def.field_type == ConfigFieldType.INTEGER: - validated[field_name] = int(value) - elif field_def.field_type == ConfigFieldType.FLOAT: - validated[field_name] = float(value) - elif field_def.field_type == ConfigFieldType.STRING: - validated[field_name] = str(value) + validated: Dict[str, Any] = {} + for name, field in CONFIG_SCHEMA.items(): + value = config.get(name, field.default) + if field.field_type == ConfigFieldType.BOOLEAN: + value = value.lower() in {"true", "1", "yes", "on"} if isinstance(value, str) else bool(value) + elif field.field_type == ConfigFieldType.INTEGER: + value = int(value) + elif field.field_type == ConfigFieldType.FLOAT: + value = float(value) else: - validated[field_name] = value - + value = str(value) + validated[name] = value + + if validated["multiplier"] < 1: + raise ValueError("multiplier must be 1 or greater") + if not 0.25 <= validated["flow_scale"] <= 1.0: + raise ValueError("flow_scale must be between 0.25 and 1.0") + if validated["experimental_present_mode"] not in {"fifo", "mailbox"}: + raise ValueError("experimental_present_mode must be fifo or mailbox") return cast(ConfigurationData, validated) - + + @staticmethod + def _migrate_dll_path(value: Any) -> str: + path_value = str(value or "") + if not path_value: + return "" + path = Path(path_value) + if path.name.lower() != "lossless.dll": + return path_value + replacement = path.with_name("lsfg-vk.dll") + return str(replacement) if replacement.exists() else "" + + @staticmethod + def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]: + config: Dict[str, Any] = dict(ConfigurationManager.get_defaults()) + for field in ("multiplier", "flow_scale", "performance_mode"): + if field in profile: + config[field] = profile[field] + config["experimental_present_mode"] = "fifo" if bool(profile.get("override_present_mode", True)) else "mailbox" + config["dll"] = global_config.get("dll", "") + config["no_fp16"] = global_config.get("no_fp16", False) + for field in ("active_in", "pacing", "preserve_swapchain_image_count"): + if field in profile: + config[field] = profile[field] + return {**config, **ConfigurationManager.validate_config(config)} + @staticmethod def generate_toml_content(config: ConfigurationData) -> str: - """Generate TOML configuration file content for single profile (backward compatibility)""" - # For backward compatibility, create a single profile structure profile_data: ProfileData = { "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: config}, + "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, "global_config": { "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False) - } + "no_fp16": config.get("no_fp16", False), + }, } return ConfigurationManager.generate_toml_content_multi_profile(profile_data) - + @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: - """Generate TOML configuration file content with multiple profiles""" - lines = ["version = 1"] - lines.append("") - - # Add global section with global fields - lines.append("[global]") - - # Add current_profile field - lines.append(f"# Currently selected profile") - lines.append(f'current_profile = "{profile_data["current_profile"]}"') - lines.append("") - - # Add dll field if specified - dll_path = profile_data["global_config"].get("dll", "") - if dll_path: - lines.append(f"# specify where Lossless.dll is stored") - lines.append(f'dll = "{dll_path}"') - lines.append("") - - lines.append(f"# FP16 acceleration") - no_fp16 = bool(profile_data["global_config"].get("no_fp16", False)) - lines.append(f"no_fp16 = {str(no_fp16).lower()}") - lines.append("") - - # Add game sections for each profile - # Sort profiles to ensure consistent order (default profile first) - sorted_profiles = sorted(profile_data["profiles"].items(), - key=lambda x: (x[0] != DEFAULT_PROFILE_NAME, x[0])) - - for profile_name, config in sorted_profiles: - lines.append("[[game]]") - if profile_name == DEFAULT_PROFILE_NAME: - lines.append("# Plugin-managed game entry (default profile)") - else: - lines.append(f"# Profile: {profile_name}") - lines.append(f'exe = "{profile_name}"') - lines.append("") - - # Add all configuration fields to the game section (excluding global fields) - for field_name, field_def in CONFIG_SCHEMA.items(): - # Skip global fields - they go in global section - if field_name in GLOBAL_SECTION_FIELDS: - continue - - value = config.get(field_name, field_def.default) - - # Add field description comment - lines.append(f"# {field_def.description}") - - # Format value based on type - if isinstance(value, bool): - lines.append(f"{field_name} = {str(value).lower()}") - elif isinstance(value, str) and value: # Only add non-empty strings - lines.append(f'{field_name} = "{value}"') - elif isinstance(value, (int, float)): # Always include numbers, even if 0 or 1 - lines.append(f"{field_name} = {value}") - - lines.append("") # Empty line for readability - - return "\n".join(lines) - + global_config = profile_data["global_config"] + lines = ["version = 2", "", "[global]"] + dll = ConfigurationManager._migrate_dll_path(global_config.get("dll", "")) + if dll: + lines.append(f"dll = {_toml_value(dll)}") + lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}") + if global_config.get("log_level"): + lines.append(f"log_level = {_toml_value(global_config['log_level'])}") + if global_config.get("log_file"): + lines.append(f"log_file = {_toml_value(global_config['log_file'])}") + + profiles = sorted( + profile_data["profiles"].items(), + key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0]), + ) + for profile_name, raw_config in profiles: + config = ConfigurationManager.validate_config(raw_config) + lines.extend(["", "[[profile]]", f"name = {_toml_value(profile_name)}"]) + active_in = raw_config.get("active_in") + if active_in not in (None, "", []): + lines.append(f"active_in = {_toml_value(active_in)}") + lines.extend( + [ + f"multiplier = {config['multiplier']}", + f"flow_scale = {config['flow_scale']}", + f"performance_mode = {_toml_value(config['performance_mode'])}", + f"pacing = {_toml_value(raw_config.get('pacing', 'vsync'))}", + f"override_present_mode = {_toml_value(config['experimental_present_mode'] == 'fifo')}", + f"preserve_swapchain_image_count = {_toml_value(bool(raw_config.get('preserve_swapchain_image_count', False)))}", + ] + ) + return "\n".join(lines) + "\n" + @staticmethod - def parse_toml_content(content: str) -> ConfigurationData: - """Parse TOML content into configuration data for the currently selected profile (backward compatibility)""" - profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - current_profile = profile_data["current_profile"] - - # Merge global config with current profile config - current_config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults()) - - # Add global fields to the config - for field_name in GLOBAL_SECTION_FIELDS: - if field_name in profile_data["global_config"]: - current_config[field_name] = profile_data["global_config"][field_name] - - return current_config - + def _profile_data_from_v1(data: Dict[str, Any]) -> ProfileData: + old_global = dict(data.get("global", {})) + global_config: Dict[str, Any] = { + "dll": ConfigurationManager._migrate_dll_path(old_global.get("dll", "")), + "no_fp16": bool(old_global.get("no_fp16", False)), + } + profiles: Dict[str, Dict[str, Any]] = {} + for game in data.get("game", []): + profile_name = str(game.get("exe", DEFAULT_PROFILE_NAME)) + config: Dict[str, Any] = dict(ConfigurationManager.get_defaults()) + for field in ("multiplier", "flow_scale", "performance_mode", "experimental_present_mode"): + if field in game: + config[field] = game[field] + config["dll"] = global_config["dll"] + config["no_fp16"] = global_config["no_fp16"] + profiles[profile_name] = dict(ConfigurationManager.validate_config(config)) + + if not profiles: + profiles[DEFAULT_PROFILE_NAME] = dict(ConfigurationManager.get_defaults()) + + current_profile = str(old_global.get("current_profile", DEFAULT_PROFILE_NAME)) + if current_profile not in profiles: + current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=global_config, + ) + @staticmethod - def parse_toml_content_multi_profile(content: str) -> ProfileData: - """Parse TOML content into profile data structure""" - profiles: Dict[str, ConfigurationData] = {} - global_config: Dict[str, Any] = {} - current_profile = DEFAULT_PROFILE_NAME - + def is_legacy_v1(content: str) -> bool: try: - # Look for both [global] and [[game]] sections - lines = content.split('\n') - in_global_section = False - in_game_section = False - current_game_exe = None - current_game_config: Dict[str, Any] = {} - - for line in lines: - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('#'): - continue - - # Check for section headers - if line.startswith('[') and line.endswith(']'): - # Save previous game section if we were in one - if in_game_section and current_game_exe: - # Validate and store the profile config - validated_config = ConfigurationManager.get_defaults() - for key, value in current_game_config.items(): - if key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated_config[key] = value - elif field_def.field_type == ConfigFieldType.INTEGER: - validated_config[key] = int(value) if not isinstance(value, int) else value - elif field_def.field_type == ConfigFieldType.FLOAT: - validated_config[key] = float(value) if not isinstance(value, float) else value - elif field_def.field_type == ConfigFieldType.STRING: - validated_config[key] = str(value) - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - profiles[current_game_exe] = validated_config - current_game_config = {} - - # Set new section state - if line == '[global]': - in_global_section = True - in_game_section = False - elif line == '[[game]]': - in_global_section = False - in_game_section = True - current_game_exe = None - else: - in_global_section = False - in_game_section = False - continue - - # Parse key = value lines - if '=' in line: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip() - - # Remove quotes from string values - if value.startswith('"') and value.endswith('"'): - value = value[1:-1] - elif value.startswith("'") and value.endswith("'"): - value = value[1:-1] - - # Handle global section - if in_global_section: - if key == "current_profile": - current_profile = value - elif key == "dll": - global_config["dll"] = value - elif key == "no_fp16": - global_config["no_fp16"] = value.lower() in ('true', '1', 'yes', 'on') - - # Handle game section - elif in_game_section: - # Track the exe for this game section - if key == "exe": - current_game_exe = value - # Store config fields for current game - elif key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - current_game_config[key] = value.lower() in ('true', '1', 'yes', 'on') - elif field_def.field_type == ConfigFieldType.INTEGER: - current_game_config[key] = int(value) - elif field_def.field_type == ConfigFieldType.FLOAT: - current_game_config[key] = float(value) - elif field_def.field_type == ConfigFieldType.STRING: - current_game_config[key] = value - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - - # Handle final game section if we were in one - if in_game_section and current_game_exe: - validated_config = ConfigurationManager.get_defaults() - for key, value in current_game_config.items(): - if key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated_config[key] = value - elif field_def.field_type == ConfigFieldType.INTEGER: - validated_config[key] = int(value) if not isinstance(value, int) else value - elif field_def.field_type == ConfigFieldType.FLOAT: - validated_config[key] = float(value) if not isinstance(value, float) else value - elif field_def.field_type == ConfigFieldType.STRING: - validated_config[key] = str(value) - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - profiles[current_game_exe] = validated_config - - # Ensure we have at least the default profile - if not profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - # Ensure current_profile exists in profiles - if current_profile not in profiles: - current_profile = DEFAULT_PROFILE_NAME - if DEFAULT_PROFILE_NAME not in profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config - ) - - except (ValueError, KeyError, TypeError, AttributeError) as e: - # If parsing fails completely, return default profile structure - logging.getLogger(__name__).warning(f"Failed to parse TOML profiles, using defaults: {e}") - return ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: ConfigurationManager.get_defaults()}, - global_config={} - ) - + return tomllib.loads(content).get("version") == 1 + except tomllib.TOMLDecodeError: + return False + + @staticmethod + def parse_toml_content_multi_profile(content: str) -> ProfileData: + data = tomllib.loads(content) + version = data.get("version") + if version == 1: + return ConfigurationManager._profile_data_from_v1(data) + if version != 2: + raise ValueError("unsupported lsfg-vk configuration version") + + raw_global = dict(data.get("global", {})) + global_config: Dict[str, Any] = { + "dll": ConfigurationManager._migrate_dll_path(raw_global.get("dll", "")), + "no_fp16": not bool(raw_global.get("allow_fp16", True)), + } + for field in ("log_level", "log_file"): + if field in raw_global: + global_config[field] = raw_global[field] + + profiles: Dict[str, Dict[str, Any]] = {} + for profile in data.get("profile", []): + profile_name = str(profile.get("name", DEFAULT_PROFILE_NAME)) + profiles[profile_name] = ConfigurationManager._config_from_profile(profile, global_config) + + if not profiles: + default = dict(ConfigurationManager.get_defaults()) + default["dll"] = global_config["dll"] + default["no_fp16"] = global_config["no_fp16"] + profiles[DEFAULT_PROFILE_NAME] = default + + current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=global_config, + ) + + @staticmethod + def parse_toml_content(content: str) -> ConfigurationData: + profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) + return cast(ConfigurationData, profile_data["profiles"][profile_data["current_profile"]]) + @staticmethod def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]: - """Parse launch script content to extract environment variable values - - Args: - script_content: Content of the launch script file - - Returns: - Dict containing parsed script-only field values - """ - # Use auto-generated parsing logic - parse_script_values = get_script_parsing_logic() - return parse_script_values(script_content.split('\n')) - + return get_script_parsing_logic()(script_content.splitlines()) + + @staticmethod + def parse_profile_selection(script_content: str) -> str | None: + selected = None + for line in script_content.splitlines(): + try: + tokens = shlex.split(line) + except ValueError: + continue + if len(tokens) != 2 or tokens[0] != "export" or "=" not in tokens[1]: + continue + key, value = tokens[1].split("=", 1) + if key in {"LSFGVK_PROFILE", "LSFG_PROCESS"} and value: + selected = value + return selected + @staticmethod - def merge_config_with_script(toml_config: ConfigurationData, script_values: Dict[str, Union[bool, int, str]]) -> ConfigurationData: - """Merge TOML configuration with script environment variable values - - Args: - toml_config: Configuration loaded from TOML file - script_values: Environment variable values parsed from script - - Returns: - Complete configuration with script values overlaid on TOML config - """ - merged_config = dict(toml_config) - - # Update script-only fields with values from script - for field_name in SCRIPT_ONLY_FIELDS.keys(): - if field_name in script_values: - merged_config[field_name] = script_values[field_name] - - return cast(ConfigurationData, merged_config) + def merge_config_with_script( + toml_config: Dict[str, Any], + script_values: Dict[str, Union[bool, int, str]], + ) -> Dict[str, Any]: + merged = dict(toml_config) + for field in SCRIPT_ONLY_FIELDS: + if field in script_values: + merged[field] = script_values[field] + return merged @staticmethod def normalize_profile_name(profile_name: str) -> str: - """Normalize profile name by converting spaces to dashes and trimming - - This allows users to enter names with spaces, which are then safely - converted to dashes for storage and shell script compatibility. - - Args: - profile_name: The raw profile name from user input - - Returns: - Normalized profile name with spaces converted to dashes - """ - if not profile_name: - return profile_name - - # Trim whitespace and convert spaces to dashes - normalized = profile_name.strip().replace(' ', '-') - - # Collapse multiple consecutive dashes into one - while '--' in normalized: - normalized = normalized.replace('--', '-') - - # Remove leading/trailing dashes - normalized = normalized.strip('-') - - return normalized - + return re.sub(r"\s+", "-", profile_name.strip()).strip("-") + @staticmethod def validate_profile_name(profile_name: str) -> bool: - """Validate profile name for safety (after normalization)""" - if not profile_name: - return False - - # Normalize first - this converts spaces to dashes normalized = ConfigurationManager.normalize_profile_name(profile_name) - - if not normalized: - return False - - # Check for invalid characters that could cause issues in shell scripts or TOML - # Note: spaces are now allowed as input (they get converted to dashes) - invalid_chars = set('\t\n\r\'"\\/$|&;()<>{}[]`*?') - if any(char in invalid_chars for char in normalized): - return False - - # Check for reserved names - reserved_names = {'global', 'game', 'current_profile'} - if normalized.lower() in reserved_names: - return False - - return True - + invalid = '\t\n\r\'"\\/$|&;()<>{}[]' + "`" + '*?' + return ( + bool(normalized) + and not any(character in invalid for character in normalized) + and normalized.lower() not in {"global", "profile"} + ) + @staticmethod def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData: - """Create a new profile by copying from source profile or defaults""" if not ConfigurationManager.validate_profile_name(profile_name): raise ValueError(f"Invalid profile name: {profile_name}") - - # Normalize the profile name (converts spaces to dashes) - profile_name = ConfigurationManager.normalize_profile_name(profile_name) - - if profile_name in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' already exists") - - # Copy from source profile or use defaults - if source_profile and source_profile in profile_data["profiles"]: - new_config = dict(profile_data["profiles"][source_profile]) - else: - new_config = ConfigurationManager.get_defaults() - - # Create new profile data structure - new_profile_data = ProfileData( + normalized = ConfigurationManager.normalize_profile_name(profile_name) + if normalized in profile_data["profiles"]: + raise ValueError(f"Profile '{normalized}' already exists") + source = source_profile if source_profile in profile_data["profiles"] else profile_data["current_profile"] + profiles = dict(profile_data["profiles"]) + profiles[normalized] = dict(profiles[source]) + return ProfileData( current_profile=profile_data["current_profile"], - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) + profiles=profiles, + global_config=dict(profile_data["global_config"]), ) - new_profile_data["profiles"][profile_name] = new_config - - return new_profile_data - + @staticmethod def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - """Delete a profile (cannot delete default profile)""" if profile_name == DEFAULT_PROFILE_NAME: - raise ValueError(f"Cannot delete default profile '{DEFAULT_PROFILE_NAME}'") - + raise ValueError("Cannot delete the default profile") if profile_name not in profile_data["profiles"]: raise ValueError(f"Profile '{profile_name}' does not exist") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) + profiles = dict(profile_data["profiles"]) + del profiles[profile_name] + current_profile = profile_data["current_profile"] + if current_profile == profile_name: + current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=dict(profile_data["global_config"]), ) - - # Remove the profile - del new_profile_data["profiles"][profile_name] - - # If we deleted the current profile, switch to default - if new_profile_data["current_profile"] == profile_name: - new_profile_data["current_profile"] = DEFAULT_PROFILE_NAME - # Ensure default profile exists - if DEFAULT_PROFILE_NAME not in new_profile_data["profiles"]: - new_profile_data["profiles"][DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - return new_profile_data - + @staticmethod def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData: - """Rename a profile""" if old_name == DEFAULT_PROFILE_NAME: - raise ValueError(f"Cannot rename default profile '{DEFAULT_PROFILE_NAME}'") - - if not ConfigurationManager.validate_profile_name(new_name): - raise ValueError(f"Invalid profile name: {new_name}") - - # Normalize the new name (converts spaces to dashes) - new_name = ConfigurationManager.normalize_profile_name(new_name) - - if old_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{old_name}' does not exist") - - if new_name in profile_data["profiles"]: - raise ValueError(f"Profile '{new_name}' already exists") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles={}, - global_config=dict(profile_data["global_config"]) + raise ValueError("Cannot rename the default profile") + if old_name not in profile_data["profiles"] or not ConfigurationManager.validate_profile_name(new_name): + raise ValueError("Invalid profile rename") + normalized = ConfigurationManager.normalize_profile_name(new_name) + if normalized in profile_data["profiles"]: + raise ValueError(f"Profile '{normalized}' already exists") + profiles = { + normalized if name == old_name else name: value + for name, value in profile_data["profiles"].items() + } + current_profile = normalized if profile_data["current_profile"] == old_name else profile_data["current_profile"] + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=dict(profile_data["global_config"]), ) - - # Copy profiles with new name - for profile_name, config in profile_data["profiles"].items(): - if profile_name == old_name: - new_profile_data["profiles"][new_name] = dict(config) - else: - new_profile_data["profiles"][profile_name] = dict(config) - - # Update current_profile if necessary - if new_profile_data["current_profile"] == old_name: - new_profile_data["current_profile"] = new_name - - return new_profile_data - + @staticmethod def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - """Set the current active profile""" if profile_name not in profile_data["profiles"]: raise ValueError(f"Profile '{profile_name}' does not exist") - - # Create new profile data structure - new_profile_data = ProfileData( + return ProfileData( current_profile=profile_name, profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) + global_config=dict(profile_data["global_config"]), ) - - return new_profile_data diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py index b320a97..913609b 100644 --- a/py_modules/lsfg_vk/config_schema_generated.py +++ b/py_modules/lsfg_vk/config_schema_generated.py @@ -18,7 +18,6 @@ NO_FP16 = "no_fp16" MULTIPLIER = "multiplier" FLOW_SCALE = "flow_scale" PERFORMANCE_MODE = "performance_mode" -HDR_MODE = "hdr_mode" EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" DXVK_FRAME_RATE = "dxvk_frame_rate" ENABLE_WOW64 = "enable_wow64" @@ -37,7 +36,6 @@ class ConfigurationData(TypedDict): multiplier: int flow_scale: float performance_mode: bool - hdr_mode: bool experimental_present_mode: str dxvk_frame_rate: int enable_wow64: bool @@ -122,4 +120,4 @@ def get_script_generation_logic(): return generate_script_lines -ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] +ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index de11b48..9b4d536 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -1,431 +1,224 @@ -""" -Configuration service for TOML-based lsfg configuration management. -""" - -from pathlib import Path -from typing import Dict, Any +import shlex from .base_service import BaseService -from .config_schema import ConfigurationManager, CONFIG_SCHEMA, ProfileData, DEFAULT_PROFILE_NAME +from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData from .config_schema_generated import ConfigurationData, get_script_generation_logic -from .types import ConfigurationResponse, ProfilesResponse, ProfileResponse +from .types import ConfigurationResponse, ProfileResponse, ProfilesResponse class ConfigurationService(BaseService): - """Service for managing TOML-based lsfg configuration""" - def get_config(self) -> ConfigurationResponse: - """Read current TOML configuration merged with launch script environment variables - - Returns: - ConfigurationResponse with current configuration or error - """ try: - if not self.config_file_path.exists(): - from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - toml_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - else: - content = self.config_file_path.read_text(encoding='utf-8') - toml_config = ConfigurationManager.parse_toml_content(content) - - script_values = {} - if self.lsfg_script_path.exists(): - try: - script_content = self.lsfg_script_path.read_text(encoding='utf-8') - script_values = ConfigurationManager.parse_script_content(script_content) - self.log.info(f"Parsed script values: {script_values}") - except Exception as e: - self.log.warning(f"Failed to parse launch script: {str(e)}") - - config = ConfigurationManager.merge_config_with_script(toml_config, script_values) - + profile_data = self._get_profile_data() + current_profile = profile_data["current_profile"] + config = profile_data["profiles"].get(current_profile, dict(ConfigurationManager.get_defaults())) return self._success_response(ConfigurationResponse, config=config) - - except (OSError, IOError) as e: - error_msg = f"Error reading lsfg config: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) - except Exception as e: - error_msg = f"Error parsing config file: {str(e)}" - self.log.error(error_msg) - from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - return self._success_response(ConfigurationResponse, - f"Using default configuration due to parse error: {str(e)}", - config=config) - + except Exception as error: + self.log.error(f"Error reading lsfg config: {error}") + return self._error_response(ConfigurationResponse, str(error), config=None) + def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse: - """Update TOML configuration from configuration dictionary (eliminates parameter duplication) - - Args: - config: Complete configuration data dictionary - - Returns: - ConfigurationResponse with success status - """ try: profile_data = self._get_profile_data() - current_profile = profile_data["current_profile"] - - return self.update_profile_config(current_profile, config) - - except (OSError, IOError) as e: - error_msg = f"Error updating lsfg config: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) - except ValueError as e: - error_msg = f"Invalid configuration arguments: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) - + return self.update_profile_config(profile_data["current_profile"], config) + except Exception as error: + self.log.error(f"Error updating lsfg config: {error}") + return self._error_response(ConfigurationResponse, str(error), config=None) + def update_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse: - """Update the ~/lsfg launch script with current configuration - - Args: - config: Configuration data to apply to the script - - Returns: - ConfigurationResponse indicating success or failure - """ try: - script_content = self._generate_script_content(config) - - self._write_file(self.lsfg_script_path, script_content, 0o755) - - self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path}") - - return self._success_response(ConfigurationResponse, - "Launch script updated successfully", - config=config) - - except Exception as e: - error_msg = f"Error updating launch script: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) - - def _generate_script_content(self, config: ConfigurationData) -> str: - """Generate the content for the ~/lsfg launch script - - Args: - config: Configuration data to apply to the script - - Returns: - The complete script content as a string - """ - lines = [ - "#!/bin/bash", - "# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin", - "# This script sets up the environment for lsfg-vk to work with the plugin configuration", - ] - - generate_script_lines = get_script_generation_logic() - lines.extend(generate_script_lines(config)) - - lines.extend([ - "export LSFG_PROCESS=decky-lsfg-vk", - 'exec "$@"' - ]) - - return "\n".join(lines) + "\n" - + profile_data: ProfileData = { + "current_profile": DEFAULT_PROFILE_NAME, + "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, + "global_config": { + "dll": config.get("dll", ""), + "no_fp16": config.get("no_fp16", False), + }, + } + return self.update_lsfg_script_from_profile_data(profile_data) + except Exception as error: + return self._error_response(ConfigurationResponse, str(error), config=None) + def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: - """Generate the content for the ~/lsfg launch script with profile support - - Args: - profile_data: Profile data containing current profile and configurations - - Returns: - The complete script content as a string - """ current_profile = profile_data["current_profile"] - config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults()) - - merged_config = dict(config) - for field_name, value in profile_data["global_config"].items(): - merged_config[field_name] = value - - lines = [ - "#!/bin/bash", - f"# Current profile: {current_profile}", - ] - - generate_script_lines = get_script_generation_logic() - lines.extend(generate_script_lines(merged_config)) - - lines.extend([ - f"export LSFG_PROCESS={current_profile}", - 'exec "$@"' - ]) - + config = dict(profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults())) + config["dll"] = profile_data["global_config"].get("dll", config.get("dll", "")) + config["no_fp16"] = profile_data["global_config"].get("no_fp16", config.get("no_fp16", False)) + + lines = ["#!/bin/bash"] + lines.extend(get_script_generation_logic()(config)) + lines.extend( + [ + f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}", + f"export LSFGVK_PROFILE={shlex.quote(current_profile)}", + 'exec "$@"', + ] + ) return "\n".join(lines) + "\n" + def _generate_script_content(self, config: ConfigurationData) -> str: + profile_data: ProfileData = { + "current_profile": DEFAULT_PROFILE_NAME, + "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, + "global_config": { + "dll": config.get("dll", ""), + "no_fp16": config.get("no_fp16", False), + }, + } + return self._generate_script_content_for_profile(profile_data) + def _get_profile_data(self) -> ProfileData: - """Get current profile data from config file""" if not self.config_file_path.exists(): from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + + default = ConfigurationManager.get_defaults_with_dll_detection(DllDetectionService(self.log)) return ProfileData( current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: default_config}, + profiles={DEFAULT_PROFILE_NAME: dict(default)}, global_config={ - "dll": default_config.get("dll", ""), - "no_fp16": False - } + "dll": default.get("dll", ""), + "no_fp16": default.get("no_fp16", False), + }, + ) + + profile_data = ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + if self.lsfg_script_path.exists(): + script_content = self.lsfg_script_path.read_text(encoding="utf-8") + selected = ConfigurationManager.parse_profile_selection(script_content) + if selected in profile_data["profiles"]: + profile_data["current_profile"] = selected + current_profile = profile_data["current_profile"] + profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( + profile_data["profiles"][current_profile], + ConfigurationManager.parse_script_content(script_content), ) - - content = self.config_file_path.read_text(encoding='utf-8') - return ConfigurationManager.parse_toml_content_multi_profile(content) - + return profile_data + def _save_profile_data(self, profile_data: ProfileData) -> None: - """Save profile data to config file""" - toml_content = ConfigurationManager.generate_toml_content_multi_profile(profile_data) - - self.config_dir.mkdir(parents=True, exist_ok=True) - - self._write_file(self.config_file_path, toml_content, 0o644) - + self._write_file( + self.config_file_path, + ConfigurationManager.generate_toml_content_multi_profile(profile_data), + 0o644, + ) + def get_profiles(self) -> ProfilesResponse: - """Get list of all profiles and current profile - - Returns: - ProfilesResponse with profile list and current profile - """ try: profile_data = self._get_profile_data() - - return self._success_response(ProfilesResponse, - "Profiles retrieved successfully", - profiles=list(profile_data["profiles"].keys()), - current_profile=profile_data["current_profile"]) - - except Exception as e: - error_msg = f"Error getting profiles: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfilesResponse, str(e), - profiles=None, current_profile=None) - + return self._success_response( + ProfilesResponse, + "Profiles retrieved successfully", + profiles=list(profile_data["profiles"]), + current_profile=profile_data["current_profile"], + ) + except Exception as error: + return self._error_response( + ProfilesResponse, + str(error), + profiles=None, + current_profile=None, + ) + def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse: - """Create a new profile - - Args: - profile_name: Name for the new profile (spaces will be converted to dashes) - source_profile: Optional source profile to copy from (default: current profile) - - Returns: - ProfileResponse with success status and the normalized profile name - """ try: profile_data = self._get_profile_data() - - if not source_profile: - source_profile = profile_data["current_profile"] - - # Get the normalized name that will be used for storage - normalized_name = ConfigurationManager.normalize_profile_name(profile_name) - new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile) - self._save_profile_data(new_profile_data) - - self.log.info(f"Created profile '{normalized_name}' from '{source_profile}'") - - # Return the normalized name so frontend can use the actual stored name - return self._success_response(ProfileResponse, - f"Profile '{normalized_name}' created successfully", - profile_name=normalized_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error creating profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - + normalized = ConfigurationManager.normalize_profile_name(profile_name) + return self._success_response( + ProfileResponse, + f"Profile '{normalized}' created successfully", + profile_name=normalized, + ) + except Exception as error: + return self._error_response(ProfileResponse, str(error), profile_name=None) + def delete_profile(self, profile_name: str) -> ProfileResponse: - """Delete a profile - - Args: - profile_name: Name of the profile to delete - - Returns: - ProfileResponse with success status - """ try: - profile_data = self._get_profile_data() - - new_profile_data = ConfigurationManager.delete_profile(profile_data, profile_name) - - self._save_profile_data(new_profile_data) - - script_result = self.update_lsfg_script_from_profile_data(new_profile_data) + profile_data = ConfigurationManager.delete_profile(self._get_profile_data(), profile_name) + self._save_profile_data(profile_data) + script_result = self.update_lsfg_script_from_profile_data(profile_data) if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - self.log.info(f"Deleted profile '{profile_name}'") - - return self._success_response(ProfileResponse, - f"Profile '{profile_name}' deleted successfully", - profile_name=profile_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error deleting profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - + raise OSError(script_result["error"]) + return self._success_response( + ProfileResponse, + f"Profile '{profile_name}' deleted successfully", + profile_name=profile_name, + ) + except Exception as error: + return self._error_response(ProfileResponse, str(error), profile_name=None) + def rename_profile(self, old_name: str, new_name: str) -> ProfileResponse: - """Rename a profile - - Args: - old_name: Current profile name - new_name: New profile name (spaces will be converted to dashes) - - Returns: - ProfileResponse with success status and the normalized profile name - """ try: - profile_data = self._get_profile_data() - - # Get the normalized name that will be used for storage - normalized_name = ConfigurationManager.normalize_profile_name(new_name) - - new_profile_data = ConfigurationManager.rename_profile(profile_data, old_name, new_name) - - self._save_profile_data(new_profile_data) - - script_result = self.update_lsfg_script_from_profile_data(new_profile_data) + profile_data = ConfigurationManager.rename_profile(self._get_profile_data(), old_name, new_name) + self._save_profile_data(profile_data) + script_result = self.update_lsfg_script_from_profile_data(profile_data) if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - self.log.info(f"Renamed profile '{old_name}' to '{normalized_name}'") - - # Return the normalized name so frontend can use the actual stored name - return self._success_response(ProfileResponse, - f"Profile renamed from '{old_name}' to '{normalized_name}' successfully", - profile_name=normalized_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error renaming profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - + raise OSError(script_result["error"]) + normalized = ConfigurationManager.normalize_profile_name(new_name) + return self._success_response( + ProfileResponse, + f"Profile renamed to '{normalized}' successfully", + profile_name=normalized, + ) + except Exception as error: + return self._error_response(ProfileResponse, str(error), profile_name=None) + def set_current_profile(self, profile_name: str) -> ProfileResponse: - """Set the current active profile - - Args: - profile_name: Name of the profile to set as current - - Returns: - ProfileResponse with success status - """ try: - profile_data = self._get_profile_data() - - new_profile_data = ConfigurationManager.set_current_profile(profile_data, profile_name) - - self._save_profile_data(new_profile_data) - - script_result = self.update_lsfg_script_from_profile_data(new_profile_data) + profile_data = ConfigurationManager.set_current_profile(self._get_profile_data(), profile_name) + script_result = self.update_lsfg_script_from_profile_data(profile_data) if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - self.log.info(f"Set current profile to '{profile_name}'") - - return self._success_response(ProfileResponse, - f"Current profile set to '{profile_name}' successfully", - profile_name=profile_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error setting current profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - + raise OSError(script_result["error"]) + return self._success_response( + ProfileResponse, + f"Current profile set to '{profile_name}' successfully", + profile_name=profile_name, + ) + except Exception as error: + return self._error_response(ProfileResponse, str(error), profile_name=None) + def update_profile_config(self, profile_name: str, config: ConfigurationData) -> ConfigurationResponse: - """Update configuration for a specific profile - - Args: - profile_name: Name of the profile to update - config: Configuration data to apply - - Returns: - ConfigurationResponse with success status - """ try: profile_data = self._get_profile_data() - if profile_name not in profile_data["profiles"]: - return self._error_response(ConfigurationResponse, - f"Profile '{profile_name}' does not exist", - config=None) - - # Update the profile's config - profile_data["profiles"][profile_name] = config - - # Update global config fields if they're in the config - for field_name in ["dll", "no_fp16"]: - if field_name in config: - profile_data["global_config"][field_name] = config[field_name] - + raise ValueError(f"Profile '{profile_name}' does not exist") + + validated = ConfigurationManager.validate_config(config) + profile_data["profiles"][profile_name] = { + **profile_data["profiles"][profile_name], + **validated, + } + profile_data["global_config"]["dll"] = validated.get("dll", "") + profile_data["global_config"]["no_fp16"] = validated.get("no_fp16", False) self._save_profile_data(profile_data) - + if profile_name == profile_data["current_profile"]: script_result = self.update_lsfg_script_from_profile_data(profile_data) if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - field_values = ", ".join(f"{k}={repr(v)}" for k, v in config.items()) - self.log.info(f"Updated profile '{profile_name}' configuration: {field_values}") - - return self._success_response(ConfigurationResponse, - f"Profile '{profile_name}' configuration updated successfully", - config=config) - - except Exception as e: - error_msg = f"Error updating profile configuration: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) - + raise OSError(script_result["error"]) + + return self._success_response( + ConfigurationResponse, + f"Profile '{profile_name}' configuration updated successfully", + config=validated, + ) + except Exception as error: + return self._error_response(ConfigurationResponse, str(error), config=None) + def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> ConfigurationResponse: - """Update the ~/lsfg launch script from profile data - - Args: - profile_data: Profile data to apply to the script - - Returns: - ConfigurationResponse indicating success or failure - """ try: script_content = self._generate_script_content_for_profile(profile_data) - - # Write the script file self._write_file(self.lsfg_script_path, script_content, 0o755) - - self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path} for profile '{profile_data['current_profile']}'") - - # Get current profile config for response - current_config = profile_data["profiles"].get(profile_data["current_profile"], ConfigurationManager.get_defaults()) - - return self._success_response(ConfigurationResponse, - "Launch script updated successfully", - config=current_config) - - except Exception as e: - error_msg = f"Error updating launch script: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) + current_config = profile_data["profiles"].get( + profile_data["current_profile"], + dict(ConfigurationManager.get_defaults()), + ) + return self._success_response( + ConfigurationResponse, + "Launch script updated successfully", + config=current_config, + ) + except Exception as error: + return self._error_response(ConfigurationResponse, str(error), config=None) diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index fe2febb..f41b044 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -1,34 +1,27 @@ -""" -Constants for the lsfg-vk plugin. -""" - from pathlib import Path +LOCAL_BIN = ".local/bin" LOCAL_LIB = ".local/lib" -LOCAL_SHARE_BASE = ".local/share" VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d" CONFIG_DIR = ".config/lsfg-vk" SCRIPT_NAME = "lsfg" CONFIG_FILENAME = "conf.toml" -LIB_FILENAME = "liblsfg-vk.so" -JSON_FILENAME = "VkLayer_LS_frame_generation.json" -ZIP_FILENAME = "lsfg-vk_noui.zip" - -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" +ARCHIVE_FILENAME = "lsfg-vk-2.0.0.tar.xz" +LIB_FILENAME = "liblsfg-vk-layer.so" +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" -SO_EXT = ".so" -JSON_EXT = ".json" +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 = "Lossless.dll" +LOSSLESS_DLL_NAME = "lsfg-vk.dll" ENV_LSFG_DLL_PATH = "LSFG_DLL_PATH" ENV_XDG_DATA_HOME = "XDG_DATA_HOME" ENV_HOME = "HOME" - diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 4329d49..5e0a9c1 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -1,244 +1,202 @@ -""" -Installation service for lsfg-vk. -""" - import os import shutil -import traceback -import zipfile +import tarfile import tempfile -import json +import traceback from pathlib import Path -from typing import Dict, Any +from typing import Dict from .base_service import BaseService +from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData from .constants import ( - LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME, BIN_DIR, - SO_EXT, JSON_EXT + ARCHIVE_FILENAME, + BIN_DIR, + CLI_FILENAME, + JSON_FILENAME, + JSON_X86_FILENAME, + LEGACY_JSON_FILENAME, + LEGACY_LIB_FILENAME, + LIB_FILENAME, + LIB_X86_FILENAME, ) -from .config_schema import ConfigurationManager -from .types import InstallationResponse, UninstallationResponse, InstallationCheckResponse +from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse class InstallationService(BaseService): - """Service for handling lsfg-vk installation and uninstallation""" - def __init__(self, logger=None): super().__init__(logger) - 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 - + self.json_x86_file = self.local_share_dir / JSON_X86_FILENAME + self.cli_file = self.local_bin_dir / CLI_FILENAME + self.legacy_lib_file = self.local_lib_dir / LEGACY_LIB_FILENAME + self.legacy_json_file = self.local_share_dir / LEGACY_JSON_FILENAME + def install(self) -> InstallationResponse: - """Install lsfg-vk by extracting the zip file to ~/.local - - Returns: - InstallationResponse with success status and message/error - """ try: plugin_dir = Path(__file__).parent.parent.parent - zip_path = plugin_dir / BIN_DIR / ZIP_FILENAME - - if not zip_path.exists(): - error_msg = f"{ZIP_FILENAME} not found at {zip_path}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, error_msg, message="") - + archive_path = plugin_dir / BIN_DIR / ARCHIVE_FILENAME + if not archive_path.exists(): + raise FileNotFoundError(f"{ARCHIVE_FILENAME} not found at {archive_path}") + self._ensure_directories() - - self._extract_and_install_files(zip_path) - - self._create_config_file() - - self._create_lsfg_launch_script() - - self.log.info("lsfg-vk installed successfully") - return self._success_response(InstallationResponse, "lsfg-vk installed successfully") - - except (OSError, zipfile.BadZipFile, shutil.Error) as e: - error_msg = f"Error installing lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, str(e), message="") - except Exception as e: - error_msg = f"Unexpected error installing lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, str(e), message="") - - def _extract_and_install_files(self, zip_path: Path) -> None: - """Extract zip file and install files to appropriate locations - - Args: - zip_path: Path to the zip file to extract - - Raises: - zipfile.BadZipFile: If zip file is corrupted - OSError: If file operations fail - """ - # Destination mapping for file types - dest_map = { - SO_EXT: self.local_lib_dir, - JSON_EXT: self.local_share_dir + profile_data = self._prepare_config() + self._install_archive(archive_path) + self._write_file( + self.config_file_path, + ConfigurationManager.generate_toml_content_multi_profile(profile_data), + 0o644, + ) + self._create_lsfg_launch_script(profile_data) + self._remove_legacy_layer_files() + return self._success_response(InstallationResponse, "lsfg-vk 2.0.0 installed successfully") + except Exception as error: + self.log.error(f"Error installing lsfg-vk: {error}") + return self._error_response(InstallationResponse, str(error), message="") + + def _payload_destinations(self) -> Dict[str, tuple[Path, int]]: + return { + f"bin/{CLI_FILENAME}": (self.cli_file, 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), } - - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - zip_ref.extractall(temp_path) - - # Process extracted files - for root, dirs, files in os.walk(temp_path): - root_path = Path(root) - for file in files: - src_file = root_path / file - file_path = Path(file) - - # Check if we know where this file type should go - dst_dir = dest_map.get(file_path.suffix) - if dst_dir: - dst_file = dst_dir / file - - # Special handling for JSON files - need to modify library_path - if file_path.suffix == JSON_EXT and file == JSON_FILENAME: - self._copy_and_fix_json_file(src_file, dst_file) - else: - shutil.copy2(src_file, dst_file) - - self.log.info(f"Copied {file} to {dst_file}") - - def _copy_and_fix_json_file(self, src_file: Path, dst_file: Path) -> None: - """Copy JSON file and fix the library_path to use relative path - - Args: - src_file: Source JSON file path - dst_file: Destination JSON file path - """ - try: - # Read the JSON file - with open(src_file, 'r') as f: - json_data = json.load(f) - - # Fix the library_path from "liblsfg-vk.so" to "../../../lib/liblsfg-vk.so" - if 'layer' in json_data and 'library_path' in json_data['layer']: - current_path = json_data['layer']['library_path'] - if current_path == "liblsfg-vk.so": - json_data['layer']['library_path'] = "../../../lib/liblsfg-vk.so" - self.log.info(f"Fixed library_path from '{current_path}' to '../../../lib/liblsfg-vk.so'") - - # Write the modified JSON file - with open(dst_file, 'w') as f: - json.dump(json_data, f, indent=2) - - except (json.JSONDecodeError, KeyError, OSError) as e: - self.log.error(f"Error fixing JSON file {src_file}: {e}") - # Fallback to simple copy if JSON modification fails - shutil.copy2(src_file, dst_file) - - def _create_config_file(self) -> None: - """Create or update the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path - - If a config file already exists, preserve existing profiles and only update global settings like DLL path. - """ - # Import here to avoid circular imports - from .dll_detection import DllDetectionService - - # Try to detect DLL path - dll_service = DllDetectionService(self.log) - - # Check if config file already exists + + def _install_archive(self, archive_path: Path) -> None: + destinations = self._payload_destinations() + found = set() + with tarfile.open(archive_path, "r:xz") as archive: + members = { + member.name.removeprefix("./"): member + for member in archive.getmembers() + if member.isfile() + } + for source_path, (destination, mode) in destinations.items(): + member = members.get(source_path) + if member is None: + continue + source = archive.extractfile(member) + if source is None: + continue + destination.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=destination.parent, + prefix=f".{destination.name}.", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + with source: + shutil.copyfileobj(source, temporary_file) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(mode) + os.replace(temporary_path, destination) + found.add(source_path) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise + + missing = sorted(set(destinations) - found) + if missing: + raise OSError("Archive is missing required files: " + ", ".join(missing)) + + def _prepare_config(self) -> ProfileData: if self.config_file_path.exists(): - try: - # Read existing config to preserve user profiles - content = self.config_file_path.read_text(encoding='utf-8') - existing_profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - self.log.info(f"Found existing config file, preserving user profiles") - - # Create merged profile data that preserves user settings but adds any new fields - merged_profile_data = self._merge_config_with_defaults(existing_profile_data, dll_service) - - # Generate TOML content with merged profiles - toml_content = ConfigurationManager.generate_toml_content_multi_profile(merged_profile_data) - - except Exception as e: - self.log.warning(f"Failed to parse existing config file: {str(e)}, creating new one") - # Fall back to creating a new config file - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - toml_content = ConfigurationManager.generate_toml_content(config) + content = self.config_file_path.read_text(encoding="utf-8") + legacy = ConfigurationManager.is_legacy_v1(content) + profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) + if legacy: + backup_path = self.config_file_path.with_name(f"{self.config_file_path.name}.v1.bak") + if not backup_path.exists(): + self._write_file(backup_path, content, 0o644) else: - # No existing config file, create a new one with defaults - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - toml_content = ConfigurationManager.generate_toml_content(config) - self.log.info(f"Creating new config file") - - # Write config file - self._write_file(self.config_file_path, toml_content, 0o644) - self.log.info(f"Created config file at {self.config_file_path}") - - # Log detected DLL path if found - USE GENERATED CONSTANTS - from .config_schema_generated import DLL - try: - # Try to parse the written content to get the DLL path - final_content = self.config_file_path.read_text(encoding='utf-8') - final_config = ConfigurationManager.parse_toml_content(final_content) - if final_config.get(DLL): - self.log.info(f"Configured DLL path: {final_config[DLL]}") - except (OSError, IOError, ValueError, KeyError) as e: - # Don't fail installation if we can't log the DLL path - self.log.debug(f"Could not log DLL path: {e}") - - def _create_lsfg_launch_script(self) -> None: - """Create the ~/lsfg launch script for easier game setup""" - # Use the default configuration for the initial script - from .config_schema import ConfigurationManager - default_config = ConfigurationManager.get_defaults() - - # Create configuration service to generate the script + default = dict(ConfigurationManager.get_defaults()) + profile_data = ProfileData( + current_profile=DEFAULT_PROFILE_NAME, + profiles={DEFAULT_PROFILE_NAME: default}, + global_config={ + "dll": default.get("dll", ""), + "no_fp16": default.get("no_fp16", False), + }, + ) + + from .dll_detection import DllDetectionService + + 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}) + profile_data["profiles"][profile_name] = {**raw_profile, **validated} + + if self.lsfg_script_path.exists(): + script_content = self.lsfg_script_path.read_text(encoding="utf-8") + selected = ConfigurationManager.parse_profile_selection(script_content) + if selected in profile_data["profiles"]: + profile_data["current_profile"] = selected + current_profile = profile_data["current_profile"] + profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( + profile_data["profiles"][current_profile], + ConfigurationManager.parse_script_content(script_content), + ) + + if profile_data["current_profile"] not in profile_data["profiles"]: + profile_data["current_profile"] = ( + DEFAULT_PROFILE_NAME + if DEFAULT_PROFILE_NAME in profile_data["profiles"] + else next(iter(profile_data["profiles"])) + ) + + for profile in profile_data["profiles"].values(): + profile["dll"] = profile_data["global_config"].get("dll", "") + profile["no_fp16"] = profile_data["global_config"].get("no_fp16", False) + return profile_data + + def _create_lsfg_launch_script(self, profile_data: ProfileData) -> None: from .configuration import ConfigurationService - config_service = ConfigurationService(logger=self.log) - config_service.user_home = self.user_home - config_service.lsfg_script_path = self.lsfg_launch_script_path - - # Generate script content with default configuration - script_content = config_service._generate_script_content(default_config) - - # Write the script file - self._write_file(self.lsfg_launch_script_path, script_content, 0o755) - self.log.info(f"Created lsfg launch script at {self.lsfg_launch_script_path}") - + + configuration_service = ConfigurationService(logger=self.log) + configuration_service.user_home = self.user_home + configuration_service.config_dir = self.config_dir + configuration_service.config_file_path = self.config_file_path + configuration_service.lsfg_script_path = self.lsfg_launch_script_path + self._write_file( + self.lsfg_launch_script_path, + configuration_service._generate_script_content_for_profile(profile_data), + 0o755, + ) + + def _remove_legacy_layer_files(self) -> None: + for path in (self.legacy_lib_file, self.legacy_json_file): + self._remove_if_exists(path) + def get_launch_script_path(self) -> str: - """Get the path to the lsfg launch script - - Returns: - String path to the launch script file - """ return str(self.lsfg_launch_script_path) def check_installation(self) -> InstallationCheckResponse: - """Check if lsfg-vk is already installed - - Returns: - InstallationCheckResponse with installation status and file paths - """ try: - lib_exists = self.lib_file.exists() - json_exists = self.json_file.exists() - config_exists = self.config_file_path.exists() - - self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, config={config_exists}") - + 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() return { "installed": lib_exists and json_exists, "lib_exists": lib_exists, "json_exists": json_exists, - "script_exists": config_exists, # Keep script_exists for backward compatibility + "script_exists": script_exists, "lib_path": str(self.lib_file), "json_path": str(self.json_file), - "script_path": str(self.config_file_path), # Keep script_path for backward compatibility - "error": None + "script_path": str(self.lsfg_launch_script_path), + "error": None, } - - except Exception as e: - error_msg = f"Error checking lsfg-vk installation: {str(e)}" - self.log.error(error_msg) + except Exception as error: return { "installed": False, "lib_exists": False, @@ -246,154 +204,47 @@ class InstallationService(BaseService): "script_exists": False, "lib_path": str(self.lib_file), "json_path": str(self.json_file), - "script_path": str(self.config_file_path), - "error": str(e) + "script_path": str(self.lsfg_launch_script_path), + "error": str(error), } - + def uninstall(self) -> UninstallationResponse: - """Uninstall lsfg-vk by removing the installed files - - Note: The config file (conf.toml) is preserved to maintain user's custom profiles - - Returns: - UninstallationResponse with success status and removed files list - """ try: - removed_files = [] - # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles - files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path] - - for file_path in files_to_remove: - if self._remove_if_exists(file_path): - removed_files.append(str(file_path)) - - # Also try to remove the old script file if it exists (for backward compatibility) - if self._remove_if_exists(self.lsfg_script_path): - removed_files.append(str(self.lsfg_script_path)) - - # Don't remove config directory since we're preserving the config file - - if not removed_files: - return self._success_response(UninstallationResponse, - "No lsfg-vk files found to remove", - removed_files=None) - - self.log.info("lsfg-vk uninstalled successfully") - return self._success_response(UninstallationResponse, - f"lsfg-vk uninstalled successfully. Removed {len(removed_files)} files.", - removed_files=removed_files) - - except OSError as e: - error_msg = f"Error uninstalling lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(UninstallationResponse, str(e), - message="", removed_files=None) - + removed = [] + for path in ( + self.lib_file, + self.lib_x86_file, + self.json_file, + self.json_x86_file, + self.cli_file, + self.legacy_lib_file, + self.legacy_json_file, + self.lsfg_launch_script_path, + ): + if self._remove_if_exists(path): + removed.append(str(path)) + if not removed: + return self._success_response( + UninstallationResponse, + "No lsfg-vk files found to remove", + removed_files=None, + ) + return self._success_response( + UninstallationResponse, + f"lsfg-vk uninstalled successfully. Removed {len(removed)} files.", + removed_files=removed, + ) + except Exception as error: + return self._error_response( + UninstallationResponse, + str(error), + message="", + removed_files=None, + ) + def cleanup_on_uninstall(self) -> None: - """Clean up lsfg-vk files when the plugin is uninstalled - - Note: The config file (conf.toml) is preserved to maintain user's custom profiles - """ try: - self.log.info("Checking for lsfg-vk files to clean up:") - self.log.info(f" Library file: {self.lib_file}") - self.log.info(f" JSON file: {self.json_file}") - self.log.info(f" Config file: {self.config_file_path} (preserved)") - self.log.info(f" Launch script: {self.lsfg_launch_script_path}") - self.log.info(f" Old script file: {self.lsfg_script_path}") - - removed_files = [] - # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles - files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path, self.lsfg_script_path] - - for file_path in files_to_remove: - try: - if self._remove_if_exists(file_path): - removed_files.append(str(file_path)) - except OSError as e: - self.log.error(f"Failed to remove {file_path}: {e}") - - # Don't remove config directory since we're preserving the config file - - if removed_files: - self.log.info(f"Cleaned up {len(removed_files)} lsfg-vk files during plugin uninstall: {removed_files}") - else: - self.log.info("No lsfg-vk files found to clean up during plugin uninstall") - - except Exception as e: - self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {str(e)}") - self.log.error(f"Traceback: {traceback.format_exc()}") - - def _merge_config_with_defaults(self, existing_profile_data, dll_service): - """Merge existing user config with current schema defaults - - This ensures that: - 1. User's custom profiles and values are preserved - 2. Any new fields added to the schema get their default values - 3. Global settings like DLL path are updated as needed - - Args: - existing_profile_data: The user's existing ProfileData - dll_service: DLL detection service for updating DLL path - - Returns: - ProfileData with merged configuration - """ - from .config_schema import ProfileData - - # Get current schema defaults - default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - default_global_config = { - "dll": default_config.get("dll", ""), - "no_fp16": False - } - - # Start with existing data - merged_data: ProfileData = { - "current_profile": existing_profile_data.get("current_profile", "decky-lsfg-vk"), - "global_config": existing_profile_data.get("global_config", {}).copy(), - "profiles": {} - } - - # Merge global config: preserve user values, add missing fields, update DLL - for key, default_value in default_global_config.items(): - if key not in merged_data["global_config"]: - merged_data["global_config"][key] = default_value - self.log.info(f"Added missing global field '{key}' with default value: {default_value}") - - # Update DLL path if detected - dll_result = dll_service.check_lossless_scaling_dll() - if dll_result.get("detected") and dll_result.get("path"): - old_dll = merged_data["global_config"].get("dll") - merged_data["global_config"]["dll"] = dll_result["path"] - if old_dll != dll_result["path"]: - self.log.info(f"Updated DLL path from '{old_dll}' to: {dll_result['path']}") - - # Merge each profile: preserve user values, add missing fields - existing_profiles = existing_profile_data.get("profiles", {}) - - for profile_name, existing_profile_config in existing_profiles.items(): - merged_profile_config = existing_profile_config.copy() - - # Add any missing fields from current schema with default values - added_fields = [] - for key, default_value in default_config.items(): - if key not in merged_profile_config and key not in ["dll", "no_fp16"]: # Skip global fields - merged_profile_config[key] = default_value - added_fields.append(key) - - if added_fields: - self.log.info(f"Profile '{profile_name}': Added missing fields {added_fields}") - - merged_data["profiles"][profile_name] = merged_profile_config - - # If no profiles exist, create the default one - if not merged_data["profiles"]: - merged_data["profiles"]["decky-lsfg-vk"] = { - k: v for k, v in default_config.items() - if k not in ["dll", "no_fp16"] # Exclude global fields - } - merged_data["current_profile"] = "decky-lsfg-vk" - self.log.info("No existing profiles found, created default profile") - - return merged_data + self.uninstall() + except Exception as error: + self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {error}") + self.log.error(traceback.format_exc()) -- cgit v1.2.3 From e64e87e9eb9e3c183ad7807dffa356f47d14e825 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:33:30 -0400 Subject: fix: migrate flatpak integration to v2 --- py_modules/lsfg_vk/constants.py | 2 +- py_modules/lsfg_vk/flatpak_service.py | 662 ++++++++++++++-------------------- py_modules/lsfg_vk/plugin.py | 44 +-- 3 files changed, 279 insertions(+), 429 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index f41b044..7bcd0dc 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -22,6 +22,6 @@ BIN_DIR = "bin" STEAM_COMMON_PATH = Path("steamapps/common/Lossless Scaling") LOSSLESS_DLL_NAME = "lsfg-vk.dll" -ENV_LSFG_DLL_PATH = "LSFG_DLL_PATH" +ENV_LSFG_DLL_PATH = "LSFGVK_DLL_PATH" ENV_XDG_DATA_HOME = "XDG_DATA_HOME" ENV_HOME = "HOME" diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index c9be0ec..7aa3cda 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,459 +1,321 @@ -""" -Flatpak service for managing lsfg-vk Flatpak runtime extensions. -""" - -import subprocess import os +import shutil +import subprocess from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Any, Dict, List from .base_service import BaseService -from .constants import ( - FLATPAK_23_08_FILENAME, FLATPAK_24_08_FILENAME, FLATPAK_25_08_FILENAME, BIN_DIR, CONFIG_DIR -) +from .config_schema import ConfigurationManager +from .dll_detection import DllDetectionService from .types import BaseResponse -class FlatpakExtensionStatus(BaseResponse): - """Response for Flatpak extension status""" - def __init__(self, success: bool = False, message: str = "", error: str = "", - installed_23_08: bool = False, installed_24_08: bool = False, installed_25_08: bool = False): - super().__init__(success, message, error) - self.installed_23_08 = installed_23_08 - self.installed_24_08 = installed_24_08 - self.installed_25_08 = installed_25_08 - - -class FlatpakAppInfo(BaseResponse): - """Response for Flatpak app information""" - def __init__(self, success: bool = False, message: str = "", error: str = "", - apps: List[Dict[str, Any]] = None, total_apps: int = 0): - super().__init__(success, message, error) - self.apps = apps or [] - self.total_apps = total_apps - - -class FlatpakOverrideResponse(BaseResponse): - """Response for Flatpak override operations""" - def __init__(self, success: bool = False, message: str = "", error: str = "", - app_id: str = "", operation: str = ""): - super().__init__(success, message, error) - self.app_id = app_id - self.operation = operation - - class FlatpakService(BaseService): - """Service for handling Flatpak runtime extensions and app overrides""" + EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" + SUPPORTED_RUNTIMES = ("24.08", "25.08") def __init__(self, logger=None): super().__init__(logger) - self.extension_id_23_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/23.08" - self.extension_id_24_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08" - self.extension_id_25_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/25.08" self.flatpak_command = None - def _get_clean_env(self): - """Get a clean environment without PyInstaller's bundled libraries""" + def _get_clean_env(self) -> Dict[str, str]: env = os.environ.copy() - - if 'LD_LIBRARY_PATH' in env: - del env['LD_LIBRARY_PATH'] - - standard_paths = ['/usr/bin', '/usr/local/bin', '/bin'] - current_path = env.get('PATH', '') - - path_parts = current_path.split(':') if current_path else [] - for std_path in standard_paths: - if std_path not in path_parts: - path_parts.insert(0, std_path) - - env['PATH'] = ':'.join(path_parts) - + env.pop("LD_LIBRARY_PATH", None) + path_entries = [entry for entry in env.get("PATH", "").split(":") if entry] + for entry in ("/usr/bin", "/usr/local/bin", "/bin"): + if entry not in path_entries: + path_entries.insert(0, entry) + env["PATH"] = ":".join(path_entries) return env - def _run_flatpak_command(self, args: List[str], **kwargs): - """Run flatpak command with clean environment to avoid library conflicts""" - if self.flatpak_command is None: - raise FileNotFoundError("Flatpak command not available") - - env = self._get_clean_env() - - self.log.info(f"Running flatpak with PATH: {env.get('PATH')}") - self.log.info(f"LD_LIBRARY_PATH removed: {'LD_LIBRARY_PATH' not in env}") - - return subprocess.run([self.flatpak_command] + args, env=env, **kwargs) - def check_flatpak_available(self) -> bool: - """Check if flatpak command is available and store the working command""" - self.log.info(f"PATH: {os.environ.get('PATH', 'Not set')}") - self.log.info(f"HOME: {os.environ.get('HOME', 'Not set')}") - self.log.info(f"USER: {os.environ.get('USER', 'Not set')}") - - flatpak_paths = [ - "flatpak", - "/usr/bin/flatpak", - "/var/lib/flatpak/exports/bin/flatpak", - "/home/deck/.local/bin/flatpak" - ] - - for flatpak_path in flatpak_paths: - try: - result = subprocess.run([flatpak_path, "--version"], - capture_output=True, check=True, text=True, - env=self._get_clean_env()) - self.log.info(f"Flatpak found at {flatpak_path}: {result.stdout.strip()}") - self.flatpak_command = flatpak_path - return True - except (subprocess.CalledProcessError, FileNotFoundError): - self.log.debug(f"Flatpak not found at {flatpak_path}") - continue - - self.log.error("Flatpak command not found in any known locations") - self.flatpak_command = None - return False + env = self._get_clean_env() + self.flatpak_command = shutil.which("flatpak", path=env["PATH"]) + return self.flatpak_command is not None - def get_extension_status(self) -> FlatpakExtensionStatus: - """Check if lsfg-vk Flatpak extensions are installed""" + def _run_flatpak_command(self, args: List[str], **kwargs): + if self.flatpak_command is None and not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak command not available") + return subprocess.run( + [self.flatpak_command, *args], + env=self._get_clean_env(), + **kwargs, + ) + + @classmethod + def _extension_ref(cls, version: str) -> str: + return f"{cls.EXTENSION_ID}/x86_64/{version}" + + @classmethod + def _validate_runtime(cls, version: str) -> None: + if version not in cls.SUPPORTED_RUNTIMES: + raise ValueError("Unsupported Flatpak runtime") + + def get_extension_status(self) -> Dict[str, Any]: try: if not self.check_flatpak_available(): - error_msg = "Flatpak is not available on this system" - if self.flatpak_command is None: - error_msg += ". Command not found in PATH or common install locations." - self.log.error(error_msg) - return self._error_response(FlatpakExtensionStatus, - error_msg, - installed_23_08=False, installed_24_08=False, installed_25_08=False) + raise FileNotFoundError("Flatpak is not available on this system") result = self._run_flatpak_command( - ["list", "--runtime"], - capture_output=True, text=True, check=True + ["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() + } + return self._success_response( + BaseResponse, + "Flatpak runtime status retrieved", + 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_24_08=False, + installed_25_08=False, ) - installed_runtimes = result.stdout - - base_extension_name = "org.freedesktop.Platform.VulkanLayer.lsfgvk" - installed_23_08 = False - installed_24_08 = False - installed_25_08 = False - - for line in installed_runtimes.split('\n'): - if base_extension_name in line: - if "23.08" in line: - installed_23_08 = True - elif "24.08" in line: - installed_24_08 = True - elif "25.08" in line: - installed_25_08 = True - - status_msg = [] - if installed_23_08: - status_msg.append("23.08 runtime extension installed") - if installed_24_08: - status_msg.append("24.08 runtime extension installed") - if installed_25_08: - status_msg.append("25.08 runtime extension installed") - - if not status_msg: - status_msg.append("No lsfg-vk runtime extensions installed") - - return self._success_response(FlatpakExtensionStatus, - "; ".join(status_msg), - installed_23_08=installed_23_08, - installed_24_08=installed_24_08, - installed_25_08=installed_25_08) - - except subprocess.CalledProcessError as e: - error_msg = f"Error checking Flatpak extensions: {e.stderr if e.stderr else str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakExtensionStatus, error_msg, - installed_23_08=False, installed_24_08=False, installed_25_08=False) - - def install_extension(self, version: str) -> BaseResponse: - """Install a specific version of the lsfg-vk Flatpak extension""" + def install_extension(self, version: str) -> Dict[str, Any]: try: - if version not in ["23.08", "24.08", "25.08"]: - return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'") - + self._validate_runtime(version) if not self.check_flatpak_available(): - return self._error_response(BaseResponse, "Flatpak is not available on this system") - - plugin_dir = Path(__file__).parent.parent.parent - if version == "23.08": - filename = FLATPAK_23_08_FILENAME - elif version == "24.08": - filename = FLATPAK_24_08_FILENAME - else: - filename = FLATPAK_25_08_FILENAME - flatpak_path = plugin_dir / BIN_DIR / filename - - if not flatpak_path.exists(): - return self._error_response(BaseResponse, f"Flatpak file not found: {flatpak_path}") - + raise FileNotFoundError("Flatpak is not available on this system") result = self._run_flatpak_command( - ["install", "--user", "--noninteractive", str(flatpak_path)], - capture_output=True, text=True + [ + "install", + "--user", + "--noninteractive", + "--or-update", + "flathub", + f"{self.EXTENSION_ID}//{version}", + ], + capture_output=True, + text=True, ) - if result.returncode != 0: - error_msg = f"Failed to install Flatpak extension: {result.stderr}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) - - self.log.info(f"Successfully installed lsfg-vk Flatpak extension {version}") - return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension installed successfully") - - except Exception as e: - error_msg = f"Error installing Flatpak extension {version}: {str(e)}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) + raise OSError(result.stderr.strip() or "Flatpak installation failed") + return self._success_response( + BaseResponse, + f"lsfg-vk {version} runtime extension installed", + ) + except Exception as error: + return self._error_response(BaseResponse, str(error)) - def uninstall_extension(self, version: str) -> BaseResponse: - """Uninstall a specific version of the lsfg-vk Flatpak extension""" + def uninstall_extension(self, version: str) -> Dict[str, Any]: try: - if version not in ["23.08", "24.08", "25.08"]: - return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'") - + self._validate_runtime(version) if not self.check_flatpak_available(): - return self._error_response(BaseResponse, "Flatpak is not available on this system") - - if version == "23.08": - extension_id = self.extension_id_23_08 - elif version == "24.08": - extension_id = self.extension_id_24_08 - else: - extension_id = self.extension_id_25_08 - + raise FileNotFoundError("Flatpak is not available on this system") result = self._run_flatpak_command( - ["uninstall", "--user", "--noninteractive", extension_id], - capture_output=True, text=True + ["uninstall", "--user", "--noninteractive", self._extension_ref(version)], + capture_output=True, + text=True, ) - if result.returncode != 0: - error_msg = f"Failed to uninstall Flatpak extension: {result.stderr}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) - - self.log.info(f"Successfully uninstalled lsfg-vk Flatpak extension {version}") - return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension uninstalled successfully") + raise OSError(result.stderr.strip() or "Flatpak uninstall failed") + return self._success_response( + BaseResponse, + f"lsfg-vk {version} runtime extension uninstalled", + ) + except Exception as error: + return self._error_response(BaseResponse, str(error)) - except Exception as e: - error_msg = f"Error uninstalling Flatpak extension {version}: {str(e)}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) + 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 + + 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 "" + + 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_dll": str( + self.user_home + / ".local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" + ), + "legacy_script": str(self.lsfg_launch_script_path), + } - def get_flatpak_apps(self) -> FlatpakAppInfo: - """Get list of installed Flatpak apps and their lsfg-vk override status""" + def _check_app_override_status(self, app_id: str) -> Dict[str, bool]: + output = self._override_output(app_id) + paths = self._override_paths() + return { + "filesystem": ( + paths["config_dir"] in output + and paths["dll_dir"] in output + ), + "env": f"LSFGVK_CONFIG={paths['config_file']}" in output, + } + + def get_flatpak_apps(self) -> Dict[str, Any]: try: if not self.check_flatpak_available(): - error_msg = "Flatpak is not available on this system" - if self.flatpak_command is None: - error_msg += ". Command not found in PATH or common install locations." - return self._error_response(FlatpakAppInfo, - error_msg, - apps=[], total_apps=0) - + raise FileNotFoundError("Flatpak is not available on this system") result = self._run_flatpak_command( - ["list", "--app"], - capture_output=True, text=True, check=True + ["list", "--user", "--app", "--columns=name,application"], + capture_output=True, + text=True, + check=True, ) - apps = [] - for line in result.stdout.strip().split('\n'): - if not line.strip(): + 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"], + } + ) + return self._success_response( + BaseResponse, + f"Found {len(apps)} Flatpak applications", + apps=apps, + total_apps=len(apps), + ) + except Exception as error: + return self._error_response( + BaseResponse, + str(error), + apps=[], + total_apps=0, + ) - parts = line.split('\t') - if len(parts) >= 2: - app_name = parts[0].strip() - app_id = parts[1].strip() - - # Check override status - override_status = self._check_app_override_status(app_id) - - apps.append({ - "app_id": app_id, - "app_name": app_name, - "has_filesystem_override": override_status["filesystem"], - "has_env_override": override_status["env"] - }) - - return self._success_response(FlatpakAppInfo, - f"Found {len(apps)} Flatpak applications", - apps=apps, total_apps=len(apps)) - - except subprocess.CalledProcessError as e: - error_msg = f"Error getting Flatpak apps: {e.stderr if e.stderr else str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakAppInfo, error_msg, apps=[], total_apps=0) - - def _check_app_override_status(self, app_id: str) -> Dict[str, bool]: - """Check if an app has lsfg-vk overrides set""" + def set_app_override(self, app_id: str) -> Dict[str, Any]: try: + if not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak is not available on this system") + paths = self._override_paths() result = self._run_flatpak_command( - ["override", "--user", "--show", app_id], - capture_output=True, text=True + [ + "override", + "--user", + f"--filesystem={paths['config_dir']}:rw", + f"--filesystem={paths['dll_dir']}:ro", + f"--env=LSFGVK_CONFIG={paths['config_file']}", + f"--nofilesystem={paths['legacy_dll']}", + f"--nofilesystem={paths['legacy_script']}", + "--unset-env=LSFG_CONFIG", + app_id, + ], + capture_output=True, + text=True, ) - if result.returncode != 0: - return {"filesystem": False, "env": False} - - output = result.stdout - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" - - filesystem_section = "" - in_context = False - - for line in output.split('\n'): - line = line.strip() - if line == "[Context]": - in_context = True - elif line.startswith("[") and line != "[Context]": - in_context = False - elif in_context and line.startswith("filesystems="): - filesystem_section = line - break - - has_config_fs = config_path in filesystem_section - has_dll_fs = dll_path in filesystem_section - has_lsfg_fs = lsfg_path in filesystem_section - - filesystem_override = has_config_fs and has_dll_fs and has_lsfg_fs - - env_override = False - in_environment = False - - for line in output.split('\n'): - line = line.strip() - if line == "[Environment]": - in_environment = True - elif line.startswith("[") and line != "[Environment]": - in_environment = False - elif in_environment and line.startswith(f"LSFG_CONFIG={config_path}/conf.toml"): - env_override = True - break - - self.log.debug(f"Override status for {app_id}: filesystem={filesystem_override} ({has_config_fs}/{has_dll_fs}/{has_lsfg_fs}), env={env_override}") - - return {"filesystem": filesystem_override, "env": env_override} - - except Exception as e: - self.log.error(f"Error checking override status for {app_id}: {e}") - return {"filesystem": False, "env": False} + raise OSError(result.stderr.strip() or "Failed to set Flatpak overrides") + return self._success_response( + BaseResponse, + f"lsfg-vk overrides set for {app_id}", + app_id=app_id, + operation="set", + ) + except Exception as error: + return self._error_response( + BaseResponse, + str(error), + app_id=app_id, + operation="set", + ) - def set_app_override(self, app_id: str) -> FlatpakOverrideResponse: - """Set lsfg-vk overrides for a Flatpak app""" + def remove_app_override(self, app_id: str) -> Dict[str, Any]: try: if not self.check_flatpak_available(): - return self._error_response(FlatpakOverrideResponse, - "Flatpak is not available on this system", - app_id=app_id, operation="set") - - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" - - filesystem_overrides = [ - f"--filesystem={dll_path}", - f"--filesystem={config_path}:rw", - f"--filesystem={lsfg_path}:rw" - ] - - for override in filesystem_overrides: - result = self._run_flatpak_command( - ["override", "--user", override, app_id], - capture_output=True, text=True - ) - if result.returncode != 0: - error_msg = f"Failed to set filesystem override {override}: {result.stderr}" - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="set") - + raise FileNotFoundError("Flatpak is not available on this system") + paths = self._override_paths() result = self._run_flatpak_command( - ["override", "--user", f"--env=LSFG_CONFIG={config_path}/conf.toml", app_id], - capture_output=True, text=True + [ + "override", + "--user", + f"--nofilesystem={paths['config_dir']}", + f"--nofilesystem={paths['dll_dir']}", + f"--nofilesystem={paths['legacy_dll']}", + f"--nofilesystem={paths['legacy_script']}", + "--unset-env=LSFGVK_CONFIG", + "--unset-env=LSFG_CONFIG", + app_id, + ], + capture_output=True, + text=True, ) - if result.returncode != 0: - error_msg = f"Failed to set environment override: {result.stderr}" - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="set") - - self.log.info(f"Successfully set lsfg-vk overrides for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"lsfg-vk overrides set for {app_id}", - app_id=app_id, operation="set") - - except Exception as e: - error_msg = f"Error setting overrides for {app_id}: {str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="set") - - def remove_app_override(self, app_id: str) -> FlatpakOverrideResponse: - """Remove lsfg-vk overrides for a Flatpak app""" - try: - if not self.check_flatpak_available(): - return self._error_response(FlatpakOverrideResponse, - "Flatpak is not available on this system", - app_id=app_id, operation="remove") - - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" - - reset_result = self._run_flatpak_command( - ["override", "--user", "--reset", app_id], - capture_output=True, text=True + raise OSError(result.stderr.strip() or "Failed to remove Flatpak overrides") + return self._success_response( + BaseResponse, + f"lsfg-vk overrides removed for {app_id}", + app_id=app_id, + operation="remove", + ) + except Exception as error: + return self._error_response( + BaseResponse, + str(error), + app_id=app_id, + operation="remove", ) - - if reset_result.returncode == 0: - self.log.info(f"Successfully reset all overrides for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"All overrides reset for {app_id}", - app_id=app_id, operation="remove") - - self.log.debug(f"Reset failed, trying individual removal: {reset_result.stderr}") - - filesystem_overrides = [ - f"--nofilesystem={dll_path}", - f"--nofilesystem={config_path}", - f"--nofilesystem={lsfg_path}" - ] - - removal_errors = [] - - # Remove filesystem overrides - for override in filesystem_overrides: - result = self._run_flatpak_command( - ["override", "--user", override, app_id], - capture_output=True, text=True - ) - if result.returncode != 0: - removal_errors.append(f"{override}: {result.stderr}") + def migrate_v2(self) -> None: + 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._run_flatpak_command( - ["override", "--user", "--unset-env=LSFG_CONFIG", app_id], - capture_output=True, text=True + ["update", "--user", "--noninteractive", self._extension_ref(version)], + capture_output=True, + text=True, ) - if result.returncode != 0: - removal_errors.append(f"unset-env: {result.stderr}") - - if removal_errors: - self.log.warning(f"Some override removals had issues for {app_id}: {'; '.join(removal_errors)}") - - self.log.info(f"Completed override removal for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"lsfg-vk overrides removed for {app_id}", - app_id=app_id, operation="remove") - - except Exception as e: - error_msg = f"Error removing overrides for {app_id}: {str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="remove") \ No newline at end of file + self.log.warning(result.stderr.strip()) + + apps_result = self._run_flatpak_command( + ["list", "--user", "--app", "--columns=application"], + capture_output=True, + text=True, + ) + if apps_result.returncode != 0: + return + + for app_id in apps_result.stdout.splitlines(): + app_id = app_id.strip() + if not app_id: + continue + if "LSFG_CONFIG=" in self._override_output(app_id): + 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/plugin.py b/py_modules/lsfg_vk/plugin.py index cb59b4f..bfb5102 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -440,36 +440,19 @@ class Plugin: # Clean up lsfg-vk files when the plugin is uninstalled self.installation_service.cleanup_on_uninstall() - # Also clean up flatpak extensions if they are installed try: - decky.logger.info("Checking for flatpak extensions to uninstall") - extension_status = self.flatpak_service.get_extension_status() - - if extension_status.get("success"): - if extension_status.get("installed_23_08"): - decky.logger.info("Uninstalling lsfg-vk flatpak runtime 23.08") - result = self.flatpak_service.uninstall_extension("23.08") - if result.get("success"): - decky.logger.info("Successfully uninstalled flatpak runtime 23.08") - else: - decky.logger.warning(f"Failed to uninstall flatpak runtime 23.08: {result.get('error')}") - - if extension_status.get("installed_24_08"): - decky.logger.info("Uninstalling lsfg-vk flatpak runtime 24.08") - result = self.flatpak_service.uninstall_extension("24.08") - if result.get("success"): - decky.logger.info("Successfully uninstalled flatpak runtime 24.08") - else: - decky.logger.warning(f"Failed to uninstall flatpak runtime 24.08: {result.get('error')}") - - decky.logger.info("Flatpak extension cleanup completed") - else: - decky.logger.info(f"Could not check flatpak status for cleanup: {extension_status.get('error')}") - - except Exception as e: - decky.logger.error(f"Error during flatpak cleanup: {e}") - + for version, key in ( + ("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")) + except Exception as error: + decky.logger.error(f"Error during Flatpak cleanup: {error}") + decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed") async def _migration(self): @@ -492,4 +475,9 @@ class Plugin: os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"), os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk")) + 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") -- cgit v1.2.3 From fa9d621f51b794acaa0f18b7acf306fc71836b5a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:34:37 -0400 Subject: fix: migrate existing v1 installs automatically --- py_modules/lsfg_vk/installation.py | 12 ++++++++++++ py_modules/lsfg_vk/plugin.py | 14 ++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 5e0a9c1..ba2c4fe 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -178,6 +178,18 @@ class InstallationService(BaseService): for path in (self.legacy_lib_file, self.legacy_json_file): self._remove_if_exists(path) + def needs_v2_migration(self) -> bool: + legacy_layer = self.legacy_lib_file.exists() or self.legacy_json_file.exists() + legacy_config = False + if self.config_file_path.exists(): + try: + legacy_config = ConfigurationManager.is_legacy_v1( + self.config_file_path.read_text(encoding="utf-8") + ) + except OSError: + legacy_config = False + return legacy_layer or legacy_config + def get_launch_script_path(self) -> str: return str(self.lsfg_launch_script_path) diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index bfb5102..fc2d378 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -475,9 +475,15 @@ class Plugin: os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"), os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk")) - try: - self.flatpak_service.migrate_v2() - except Exception as error: - decky.logger.warning(f"Flatpak v2 migration skipped: {error}") + if self.installation_service.needs_v2_migration(): + result = self.installation_service.install() + 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}") decky.logger.info("decky-lsfg-vk plugin migrations completed") -- cgit v1.2.3 From 688c7c6e0e49deaedb3edc658dd9342453cfe4c1 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:51:25 -0400 Subject: fix: rebase flatpak runtimes onto flathub --- py_modules/lsfg_vk/flatpak_service.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 7aa3cda..627bbd8 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -295,13 +295,9 @@ class FlatpakService(BaseService): ): if not status.get(key): continue - result = self._run_flatpak_command( - ["update", "--user", "--noninteractive", self._extension_ref(version)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - self.log.warning(result.stderr.strip()) + 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"], -- cgit v1.2.3 From 9cd2149c45db1fa02edc7280c8d71d536c8ef44e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:54:10 -0400 Subject: fix: preserve migrated v2 dll paths --- py_modules/lsfg_vk/config_schema.py | 7 +++---- py_modules/lsfg_vk/installation.py | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 86e8be7..92a74a3 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -105,10 +105,9 @@ class ConfigurationManager: if not path_value: return "" path = Path(path_value) - if path.name.lower() != "lossless.dll": - return path_value - replacement = path.with_name("lsfg-vk.dll") - return str(replacement) if replacement.exists() else "" + if path.name.lower() in {"lossless.dll", "losslessscaling.dll"}: + return str(path.with_name("lsfg-vk.dll")) + return path_value @staticmethod def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]: diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index ba2c4fe..0566d6b 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -128,9 +128,10 @@ class InstallationService(BaseService): from .dll_detection import DllDetectionService - 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"] + 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()): -- cgit v1.2.3 From ed605b881998d76717073af4459fb54189edada8 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:57:36 -0400 Subject: chore: clean v2 migration code --- py_modules/lsfg_vk/plugin.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index fc2d378..a9eb6a6 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -6,7 +6,6 @@ Vulkan layer for frame generation on Steam Deck. """ import os -import subprocess import hashlib from typing import Dict, Any from pathlib import Path @@ -37,7 +36,7 @@ class Plugin: self.flatpak_service = FlatpakService() async def install_lsfg_vk(self) -> Dict[str, Any]: - """Install lsfg-vk by extracting the zip file to ~/.local + """Install the bundled lsfg-vk runtime to ~/.local Returns: InstallationResponse dict with success status and message/error @@ -362,7 +361,7 @@ class Plugin: """Install lsfg-vk Flatpak runtime extension Args: - version: Runtime version to install ("23.08" or "24.08") + version: Runtime version to install ("24.08" or "25.08") Returns: BaseResponse dict with success status and message/error @@ -373,7 +372,7 @@ class Plugin: """Uninstall lsfg-vk Flatpak runtime extension Args: - version: Runtime version to uninstall ("23.08" or "24.08") + version: Runtime version to uninstall ("24.08" or "25.08") Returns: BaseResponse dict with success status and message/error -- cgit v1.2.3 From e8e469f99078858dc953663cba6f3428e80b1c5d Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sat, 5 Sep 2026 23:44:37 -0400 Subject: refactor: delegate runtime checks to lsfg-vk --- py_modules/lsfg_vk/config_schema.py | 9 -- py_modules/lsfg_vk/configuration.py | 13 +- py_modules/lsfg_vk/constants.py | 16 ++- py_modules/lsfg_vk/dll_detection.py | 223 ---------------------------------- py_modules/lsfg_vk/flatpak_service.py | 80 +++++++----- py_modules/lsfg_vk/installation.py | 70 +++++++---- py_modules/lsfg_vk/plugin.py | 93 ++------------ py_modules/lsfg_vk/runtime_service.py | 120 ++++++++++++++++++ py_modules/lsfg_vk/types.py | 17 +-- 9 files changed, 245 insertions(+), 396 deletions(-) delete mode 100644 py_modules/lsfg_vk/dll_detection.py create mode 100644 py_modules/lsfg_vk/runtime_service.py (limited to 'py_modules/lsfg_vk') 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 @@ -59,15 +59,6 @@ class ConfigurationManager: def get_defaults() -> ConfigurationData: 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] -- cgit v1.2.3 From 89e64a7dbddb8f713dd2ca37131924e8bd8ab51f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sat, 5 Sep 2026 23:58:16 -0400 Subject: feat: select Lossless Scaling lsfg-vk branch --- py_modules/lsfg_vk/constants.py | 2 + py_modules/lsfg_vk/plugin.py | 8 ++ py_modules/lsfg_vk/steam_service.py | 273 ++++++++++++++++++++++++++++++++++++ py_modules/lsfg_vk/types.py | 17 +++ 4 files changed, 300 insertions(+) create mode 100644 py_modules/lsfg_vk/steam_service.py (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 1f78a59..19df278 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -18,6 +18,8 @@ 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" LEGACY_LIB_FILENAME = "liblsfg-vk.so" LEGACY_JSON_FILENAME = "VkLayer_LS_frame_generation.json" diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 6a8dfc5..1676566 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -16,6 +16,7 @@ from .configuration import ConfigurationService from .config_schema import ConfigurationManager from .flatpak_service import FlatpakService from .runtime_service import RuntimeService +from .steam_service import SteamService class Plugin: @@ -33,6 +34,7 @@ class Plugin: self.installation_service = InstallationService(runtime_service=self.runtime_service) self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() + self.steam_service = SteamService() async def install_lsfg_vk(self) -> Dict[str, Any]: """Install the bundled lsfg-vk runtime to ~/.local @@ -320,6 +322,12 @@ class Plugin: """ 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 select_lossless_scaling_branch(self) -> Dict[str, Any]: + return self.steam_service.select_branch() + async def set_flatpak_app_override(self, app_id: str) -> Dict[str, Any]: """Set lsfg-vk overrides for a Flatpak app diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py new file mode 100644 index 0000000..3278f3c --- /dev/null +++ b/py_modules/lsfg_vk/steam_service.py @@ -0,0 +1,273 @@ +import os +import re +import tempfile +from pathlib import Path +from typing import Dict, Optional, Tuple + +from .base_service import BaseService +from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH + + +class SteamService(BaseService): + DEFAULT_BRANCH = "public" + MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" + + def _steam_library_roots(self): + candidates = ( + self.user_home / ".local/share/Steam", + self.user_home / ".steam/steam", + self.user_home / ".steam/root", + self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ) + seen = set() + + for candidate in candidates: + yield from self._unique_existing_root(candidate, seen) + + library_file = candidate / "steamapps/libraryfolders.vdf" + try: + content = library_file.read_text(encoding="utf-8") + except OSError: + continue + + for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): + path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') + yield from self._unique_existing_root(Path(path), seen) + + @staticmethod + def _unique_existing_root(path: Path, seen: set[str]): + if not path.exists(): + return + try: + resolved = str(path.resolve()) + except OSError: + resolved = str(path) + if resolved in seen: + return + seen.add(resolved) + yield path + + def _manifest_path(self) -> Optional[Path]: + for library_root in self._steam_library_roots(): + manifest = library_root / "steamapps" / self.MANIFEST_FILENAME + if manifest.is_file(): + return manifest + return None + + @staticmethod + def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]: + section = re.search( + rf'(?m)^(?P[ \t]*)"{re.escape(section_name)}"[ \t\r\n]*\{{', + content, + ) + if section is None: + return None + + depth = 1 + in_string = False + escaped = False + for index in range(section.end(), len(content)): + character = content[index] + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + continue + + if character == '"': + in_string = True + elif character == "{": + depth += 1 + elif character == "}": + depth -= 1 + if depth == 0: + return section.end(), index, section.group("indent") + return None + + @classmethod + def _section_value(cls, content: str, section_name: str, key: str) -> Optional[str]: + bounds = cls._section_bounds(content, section_name) + if bounds is None: + return None + body_start, body_end, _ = bounds + pattern = re.compile( + r'(?m)^[ \t]*"(?P[^"]+)"[ \t]+"(?P(?:\\.|[^"\\])*)"' + ) + for match in pattern.finditer(content, body_start, body_end): + if match.group("key") == key: + return match.group("value") + return None + + @classmethod + def _set_section_value(cls, content: str, section_name: str, key: str, value: str) -> str: + bounds = cls._section_bounds(content, section_name) + if bounds is None: + if section_name != "UserConfig": + raise ValueError(f"Steam manifest is missing the {section_name} section") + app_state = cls._section_bounds(content, "AppState") + if app_state is None: + raise ValueError("Steam manifest is missing the AppState section") + _, app_state_end, app_state_indent = app_state + prefix = content[:app_state_end] + if not prefix.endswith(("\n", "\r")): + prefix += "\n" + entry_indent = app_state_indent + "\t" + section = ( + f'{entry_indent}"UserConfig"\n' + f'{entry_indent}{{\n' + f'{entry_indent}\t"{key}"\t"{value}"\n' + f'{entry_indent}}}\n' + ) + return prefix + section + content[app_state_end:] + + body_start, body_end, section_indent = bounds + pattern = re.compile( + rf'(?m)^[ \t]*"{re.escape(key)}"[ \t]+"(?P(?:\\.|[^"\\])*)"' + ) + match = pattern.search(content, body_start, body_end) + if match is not None: + return content[: match.start("value")] + value + content[match.end("value") :] + + prefix = content[:body_end] + if not prefix.endswith(("\n", "\r")): + prefix += "\n" + entry_indent = section_indent + "\t" + return prefix + f'{entry_indent}"{key}"\t"{value}"\n' + content[body_end:] + + @classmethod + def _branch_or_default(cls, branch: Optional[str]) -> str: + return branch or cls.DEFAULT_BRANCH + + def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]: + selected_branch = self._branch_or_default( + self._section_value(content, "UserConfig", "BetaKey") + ) + current_branch = self._branch_or_default( + self._section_value(content, "MountedConfig", "BetaKey") + or self._section_value(content, "UserConfig", "BetaKey") + ) + needs_switch = ( + selected_branch != STEAM_LOSSLESS_SCALING_BRANCH + or current_branch != STEAM_LOSSLESS_SCALING_BRANCH + ) + return { + "installed": True, + "manifest_path": str(manifest_path), + "selected_branch": selected_branch, + "current_branch": current_branch, + "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, + "needs_switch": needs_switch, + "restart_required": ( + selected_branch == STEAM_LOSSLESS_SCALING_BRANCH + and current_branch != STEAM_LOSSLESS_SCALING_BRANCH + ), + } + + def get_branch_status(self) -> Dict[str, object]: + try: + manifest_path = self._manifest_path() + if manifest_path is None: + return self._success_response( + dict, + "Lossless Scaling is not installed through Steam", + installed=False, + manifest_path=None, + selected_branch=None, + current_branch=None, + target_branch=STEAM_LOSSLESS_SCALING_BRANCH, + needs_switch=False, + restart_required=False, + ) + + content = manifest_path.read_text(encoding="utf-8") + fields = self._status_fields(manifest_path, content) + if not fields["needs_switch"]: + message = "Lossless Scaling is using the lsfg-vk Steam branch" + elif fields["restart_required"]: + message = "lsfg-vk is selected; restart Steam to finish the branch switch" + else: + message = "Lossless Scaling is not using the lsfg-vk Steam branch" + return self._success_response(dict, message, **fields) + except Exception as error: + return self._error_response( + dict, + str(error), + installed=False, + manifest_path=None, + selected_branch=None, + current_branch=None, + target_branch=STEAM_LOSSLESS_SCALING_BRANCH, + needs_switch=False, + restart_required=False, + ) + + def select_branch(self) -> Dict[str, object]: + try: + manifest_path = self._manifest_path() + if manifest_path is None: + raise FileNotFoundError("Lossless Scaling is not installed through Steam") + + content = manifest_path.read_text(encoding="utf-8") + fields = self._status_fields(manifest_path, content) + if not fields["needs_switch"]: + return self._success_response( + dict, + "Lossless Scaling is already using the lsfg-vk Steam branch", + changed=False, + **fields, + ) + + updated = self._set_section_value( + content, + "UserConfig", + "BetaKey", + STEAM_LOSSLESS_SCALING_BRANCH, + ) + if updated != content: + file_mode = manifest_path.stat().st_mode & 0o777 + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=manifest_path.parent, + prefix=f".{manifest_path.name}.", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(updated) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(file_mode) + os.replace(temporary_path, manifest_path) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise + + new_fields = dict(fields) + new_fields["selected_branch"] = STEAM_LOSSLESS_SCALING_BRANCH + new_fields["needs_switch"] = new_fields["current_branch"] != STEAM_LOSSLESS_SCALING_BRANCH + new_fields["restart_required"] = new_fields["needs_switch"] + return self._success_response( + dict, + "lsfg-vk selected for Lossless Scaling; restart Steam to download it", + changed=updated != content, + **new_fields, + ) + except Exception as error: + return self._error_response( + dict, + str(error), + changed=False, + installed=False, + manifest_path=None, + selected_branch=None, + current_branch=None, + target_branch=STEAM_LOSSLESS_SCALING_BRANCH, + needs_switch=False, + restart_required=False, + ) diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 0f0428b..96ce23b 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -42,6 +42,23 @@ class InstallationCheckResponse(TypedDict): error: Optional[str] +class SteamBranchStatusResponse(TypedDict): + success: bool + message: str + error: Optional[str] + installed: bool + manifest_path: Optional[str] + selected_branch: Optional[str] + current_branch: Optional[str] + target_branch: str + needs_switch: bool + restart_required: bool + + +class SteamBranchOperationResponse(SteamBranchStatusResponse): + changed: bool + + class ConfigurationResponse(BaseResponse): """Response for configuration operations""" config: Optional[ConfigurationData] -- cgit v1.2.3 From a9fd67d05d6819a839a60b581c1dc6eb2792a9be Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 00:16:19 -0400 Subject: refactor: make Steam branch integration read-only --- py_modules/lsfg_vk/plugin.py | 3 - py_modules/lsfg_vk/steam_service.py | 106 ------------------------------------ py_modules/lsfg_vk/types.py | 5 -- 3 files changed, 114 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 1676566..f764086 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -325,9 +325,6 @@ class Plugin: async def get_lossless_scaling_branch_status(self) -> Dict[str, Any]: return self.steam_service.get_branch_status() - async def select_lossless_scaling_branch(self) -> Dict[str, Any]: - return self.steam_service.select_branch() - async def set_flatpak_app_override(self, app_id: str) -> Dict[str, Any]: """Set lsfg-vk overrides for a Flatpak app diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 3278f3c..867a135 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -1,6 +1,4 @@ -import os import re -import tempfile from pathlib import Path from typing import Dict, Optional, Tuple @@ -101,42 +99,6 @@ class SteamService(BaseService): return match.group("value") return None - @classmethod - def _set_section_value(cls, content: str, section_name: str, key: str, value: str) -> str: - bounds = cls._section_bounds(content, section_name) - if bounds is None: - if section_name != "UserConfig": - raise ValueError(f"Steam manifest is missing the {section_name} section") - app_state = cls._section_bounds(content, "AppState") - if app_state is None: - raise ValueError("Steam manifest is missing the AppState section") - _, app_state_end, app_state_indent = app_state - prefix = content[:app_state_end] - if not prefix.endswith(("\n", "\r")): - prefix += "\n" - entry_indent = app_state_indent + "\t" - section = ( - f'{entry_indent}"UserConfig"\n' - f'{entry_indent}{{\n' - f'{entry_indent}\t"{key}"\t"{value}"\n' - f'{entry_indent}}}\n' - ) - return prefix + section + content[app_state_end:] - - body_start, body_end, section_indent = bounds - pattern = re.compile( - rf'(?m)^[ \t]*"{re.escape(key)}"[ \t]+"(?P(?:\\.|[^"\\])*)"' - ) - match = pattern.search(content, body_start, body_end) - if match is not None: - return content[: match.start("value")] + value + content[match.end("value") :] - - prefix = content[:body_end] - if not prefix.endswith(("\n", "\r")): - prefix += "\n" - entry_indent = section_indent + "\t" - return prefix + f'{entry_indent}"{key}"\t"{value}"\n' + content[body_end:] - @classmethod def _branch_or_default(cls, branch: Optional[str]) -> str: return branch or cls.DEFAULT_BRANCH @@ -203,71 +165,3 @@ class SteamService(BaseService): needs_switch=False, restart_required=False, ) - - def select_branch(self) -> Dict[str, object]: - try: - manifest_path = self._manifest_path() - if manifest_path is None: - raise FileNotFoundError("Lossless Scaling is not installed through Steam") - - content = manifest_path.read_text(encoding="utf-8") - fields = self._status_fields(manifest_path, content) - if not fields["needs_switch"]: - return self._success_response( - dict, - "Lossless Scaling is already using the lsfg-vk Steam branch", - changed=False, - **fields, - ) - - updated = self._set_section_value( - content, - "UserConfig", - "BetaKey", - STEAM_LOSSLESS_SCALING_BRANCH, - ) - if updated != content: - file_mode = manifest_path.stat().st_mode & 0o777 - temporary_path = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=manifest_path.parent, - prefix=f".{manifest_path.name}.", - delete=False, - ) as temporary_file: - temporary_path = Path(temporary_file.name) - temporary_file.write(updated) - temporary_file.flush() - os.fsync(temporary_file.fileno()) - temporary_path.chmod(file_mode) - os.replace(temporary_path, manifest_path) - except Exception: - if temporary_path is not None: - temporary_path.unlink(missing_ok=True) - raise - - new_fields = dict(fields) - new_fields["selected_branch"] = STEAM_LOSSLESS_SCALING_BRANCH - new_fields["needs_switch"] = new_fields["current_branch"] != STEAM_LOSSLESS_SCALING_BRANCH - new_fields["restart_required"] = new_fields["needs_switch"] - return self._success_response( - dict, - "lsfg-vk selected for Lossless Scaling; restart Steam to download it", - changed=updated != content, - **new_fields, - ) - except Exception as error: - return self._error_response( - dict, - str(error), - changed=False, - installed=False, - manifest_path=None, - selected_branch=None, - current_branch=None, - target_branch=STEAM_LOSSLESS_SCALING_BRANCH, - needs_switch=False, - restart_required=False, - ) diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 96ce23b..2c56a15 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -54,11 +54,6 @@ class SteamBranchStatusResponse(TypedDict): needs_switch: bool restart_required: bool - -class SteamBranchOperationResponse(SteamBranchStatusResponse): - changed: bool - - class ConfigurationResponse(BaseResponse): """Response for configuration operations""" config: Optional[ConfigurationData] -- cgit v1.2.3 From ad2b182777bfd0a5ceef6e654df75ff13eb8b503 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 15:08:45 -0400 Subject: refactor: offload configuration to lsfg-vk --- py_modules/lsfg_vk/config_schema.py | 376 +++++++------------------- py_modules/lsfg_vk/config_schema_generated.py | 123 --------- py_modules/lsfg_vk/configuration.py | 289 +++++++------------- py_modules/lsfg_vk/installation.py | 52 ++-- py_modules/lsfg_vk/plugin.py | 89 +++--- py_modules/lsfg_vk/runtime_service.py | 2 +- py_modules/lsfg_vk/steam_service.py | 30 ++ py_modules/lsfg_vk/types.py | 11 +- 8 files changed, 310 insertions(+), 662 deletions(-) delete mode 100644 py_modules/lsfg_vk/config_schema_generated.py (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index e39be54..4ae1f4c 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,49 +1,37 @@ +"""Small adapter for the upstream lsfg-vk v2 configuration format.""" + import json -import re -import shlex import sys import tomllib -from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, TypedDict, Union, cast +from typing import Any, Dict, TypedDict, cast sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_defaults -from .config_schema_generated import ConfigurationData, get_script_parsing_logic - - -@dataclass -class ConfigField: - name: str - field_type: ConfigFieldType - default: Union[bool, int, float, str] - description: str - -CONFIG_SCHEMA: Dict[str, ConfigField] = { - name: ConfigField( - name=definition["name"], - field_type=ConfigFieldType(definition["fieldType"]), - default=definition["default"], - description=definition["description"], - ) - for name, definition in CONFIG_SCHEMA_DEF.items() -} - -SCRIPT_ONLY_FIELDS = { - name - for name, definition in CONFIG_SCHEMA_DEF.items() - if definition["location"] == "script" -} -DEFAULT_PROFILE_NAME = "decky-lsfg-vk" +DEFAULT_PROFILE_NAME = "default" +ConfigurationData = Dict[str, Any] class ProfileData(TypedDict): + # Internal compatibility field; the public API no longer exposes a + # currently selected profile. current_profile: str profiles: Dict[str, Dict[str, Any]] global_config: Dict[str, Any] +PROFILE_DEFAULTS: Dict[str, Any] = { + "active_in": [], + "pacing_mode": "vsync", + "multiplier": 2, + "flow_scale": 1.0, + "performance_mode": False, + "override_present_mode": True, + "preserve_swapchain_image_count": False, +} +GLOBAL_DEFAULTS: Dict[str, Any] = {"dll": "", "no_fp16": False} + + def _toml_value(value: Any) -> str: if isinstance(value, bool): return str(value).lower() @@ -54,41 +42,52 @@ def _toml_value(value: Any) -> str: return str(value) +def _normalize_active_in(value: Any) -> list[str]: + if value in (None, ""): + return [] + if isinstance(value, str): + return [value] + if not isinstance(value, list): + raise ValueError("active_in must be a string or list of strings") + return [str(item) for item in value if str(item)] + + class ConfigurationManager: @staticmethod - def get_defaults() -> ConfigurationData: - return cast(ConfigurationData, dict(get_defaults())) + def get_defaults() -> Dict[str, Any]: + return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS} @staticmethod def get_field_names() -> list[str]: - return list(CONFIG_SCHEMA) + return list(ConfigurationManager.get_defaults()) @staticmethod - def get_field_types() -> Dict[str, ConfigFieldType]: - return {name: field.field_type for name, field in CONFIG_SCHEMA.items()} + def get_field_types() -> Dict[str, str]: + return { + "dll": "string", "no_fp16": "boolean", "active_in": "array", + "pacing_mode": "string", "multiplier": "integer", "flow_scale": "float", + "performance_mode": "boolean", "override_present_mode": "boolean", + "preserve_swapchain_image_count": "boolean", + } @staticmethod - def validate_config(config: Dict[str, Any]) -> ConfigurationData: - validated: Dict[str, Any] = {} - for name, field in CONFIG_SCHEMA.items(): - value = config.get(name, field.default) - if field.field_type == ConfigFieldType.BOOLEAN: - value = value.lower() in {"true", "1", "yes", "on"} if isinstance(value, str) else bool(value) - elif field.field_type == ConfigFieldType.INTEGER: - value = int(value) - elif field.field_type == ConfigFieldType.FLOAT: - value = float(value) - else: - value = str(value) - validated[name] = value - - if validated["multiplier"] < 1: + def validate_config(config: Dict[str, Any]) -> Dict[str, Any]: + result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS} + result.update({key: value for key, value in config.items() if key in result}) + result["active_in"] = _normalize_active_in(result.get("active_in")) + result["pacing_mode"] = str(result.get("pacing_mode", "vsync")).lower() + if result["pacing_mode"] != "vsync": + raise ValueError("pacing_mode must be vsync") + result["multiplier"] = int(result["multiplier"]) + if result["multiplier"] < 1: raise ValueError("multiplier must be 1 or greater") - if not 0.25 <= validated["flow_scale"] <= 1.0: + result["flow_scale"] = float(result["flow_scale"]) + if not 0.25 <= result["flow_scale"] <= 1.0: raise ValueError("flow_scale must be between 0.25 and 1.0") - if validated["experimental_present_mode"] not in {"fifo", "mailbox"}: - raise ValueError("experimental_present_mode must be fifo or mailbox") - return cast(ConfigurationData, validated) + for name in ("no_fp16", "performance_mode", "override_present_mode", "preserve_swapchain_image_count"): + result[name] = bool(result[name]) + result["dll"] = str(result.get("dll") or "") + return result @staticmethod def _migrate_dll_path(value: Any) -> str: @@ -102,246 +101,81 @@ class ConfigurationManager: @staticmethod def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]: - config: Dict[str, Any] = dict(ConfigurationManager.get_defaults()) - for field in ("multiplier", "flow_scale", "performance_mode"): - if field in profile: - config[field] = profile[field] - config["experimental_present_mode"] = "fifo" if bool(profile.get("override_present_mode", True)) else "mailbox" - config["dll"] = global_config.get("dll", "") - config["no_fp16"] = global_config.get("no_fp16", False) - for field in ("active_in", "pacing", "preserve_swapchain_image_count"): - if field in profile: - config[field] = profile[field] - return {**config, **ConfigurationManager.validate_config(config)} - - @staticmethod - def generate_toml_content(config: ConfigurationData) -> str: - profile_data: ProfileData = { + raw = dict(profile) + if "pacing_mode" not in raw and "pacing" in raw: + raw["pacing_mode"] = raw["pacing"] + if "override_present_mode" not in raw and "experimental_present_mode" in raw: + raw["override_present_mode"] = raw["experimental_present_mode"] == "fifo" + raw["dll"] = global_config.get("dll", "") + raw["no_fp16"] = global_config.get("no_fp16", False) + return ConfigurationManager.validate_config(raw) + + @staticmethod + def generate_toml_content(config: Dict[str, Any]) -> str: + data: ProfileData = { "current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False), - }, + "global_config": {"dll": config.get("dll", ""), "no_fp16": config.get("no_fp16", False)}, } - return ConfigurationManager.generate_toml_content_multi_profile(profile_data) + return ConfigurationManager.generate_toml_content_multi_profile(data) @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: - global_config = profile_data["global_config"] + global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})} lines = ["version = 2", "", "[global]"] - dll = ConfigurationManager._migrate_dll_path(global_config.get("dll", "")) + dll = ConfigurationManager._migrate_dll_path(global_config.get("dll")) if dll: lines.append(f"dll = {_toml_value(dll)}") lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}") - if global_config.get("log_level"): - lines.append(f"log_level = {_toml_value(global_config['log_level'])}") - if global_config.get("log_file"): - lines.append(f"log_file = {_toml_value(global_config['log_file'])}") - - profiles = sorted( - profile_data["profiles"].items(), - key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0]), - ) - for profile_name, raw_config in profiles: - config = ConfigurationManager.validate_config(raw_config) - lines.extend(["", "[[profile]]", f"name = {_toml_value(profile_name)}"]) - active_in = raw_config.get("active_in") - if active_in not in (None, "", []): - lines.append(f"active_in = {_toml_value(active_in)}") - lines.extend( - [ - f"multiplier = {config['multiplier']}", - f"flow_scale = {config['flow_scale']}", - f"performance_mode = {_toml_value(config['performance_mode'])}", - f"pacing = {_toml_value(raw_config.get('pacing', 'vsync'))}", - f"override_present_mode = {_toml_value(config['experimental_present_mode'] == 'fifo')}", - f"preserve_swapchain_image_count = {_toml_value(bool(raw_config.get('preserve_swapchain_image_count', False)))}", - ] - ) + profiles = sorted(profile_data["profiles"].items(), key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0])) + for name, raw in profiles: + config = ConfigurationManager.validate_config({**raw, **global_config}) + lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"]) + if config["active_in"]: + lines.append(f"active_in = {_toml_value(config['active_in'])}") + lines.extend([ + f"pacing_mode = {_toml_value(config['pacing_mode'])}", + f"multiplier = {config['multiplier']}", + f"flow_scale = {config['flow_scale']}", + f"performance_mode = {_toml_value(config['performance_mode'])}", + f"override_present_mode = {_toml_value(config['override_present_mode'])}", + f"preserve_swapchain_image_count = {_toml_value(config['preserve_swapchain_image_count'])}", + ]) return "\n".join(lines) + "\n" - @staticmethod - def _profile_data_from_v1(data: Dict[str, Any]) -> ProfileData: - old_global = dict(data.get("global", {})) - global_config: Dict[str, Any] = { - "dll": ConfigurationManager._migrate_dll_path(old_global.get("dll", "")), - "no_fp16": bool(old_global.get("no_fp16", False)), - } - profiles: Dict[str, Dict[str, Any]] = {} - for game in data.get("game", []): - profile_name = str(game.get("exe", DEFAULT_PROFILE_NAME)) - config: Dict[str, Any] = dict(ConfigurationManager.get_defaults()) - for field in ("multiplier", "flow_scale", "performance_mode", "experimental_present_mode"): - if field in game: - config[field] = game[field] - config["dll"] = global_config["dll"] - config["no_fp16"] = global_config["no_fp16"] - profiles[profile_name] = dict(ConfigurationManager.validate_config(config)) - - if not profiles: - profiles[DEFAULT_PROFILE_NAME] = dict(ConfigurationManager.get_defaults()) - - current_profile = str(old_global.get("current_profile", DEFAULT_PROFILE_NAME)) - if current_profile not in profiles: - current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config, - ) - - @staticmethod - def is_legacy_v1(content: str) -> bool: - try: - return tomllib.loads(content).get("version") == 1 - except tomllib.TOMLDecodeError: - return False - @staticmethod def parse_toml_content_multi_profile(content: str) -> ProfileData: data = tomllib.loads(content) version = data.get("version") - if version == 1: - return ConfigurationManager._profile_data_from_v1(data) - if version != 2: + if version not in (1, 2): raise ValueError("unsupported lsfg-vk configuration version") - raw_global = dict(data.get("global", {})) - global_config: Dict[str, Any] = { + global_config = { "dll": ConfigurationManager._migrate_dll_path(raw_global.get("dll", "")), "no_fp16": not bool(raw_global.get("allow_fp16", True)), } - for field in ("log_level", "log_file"): - if field in raw_global: - global_config[field] = raw_global[field] - profiles: Dict[str, Dict[str, Any]] = {} - for profile in data.get("profile", []): - profile_name = str(profile.get("name", DEFAULT_PROFILE_NAME)) - profiles[profile_name] = ConfigurationManager._config_from_profile(profile, global_config) - + source_profiles = data.get("game", []) if version == 1 else data.get("profile", []) + for profile in source_profiles: + name = str(profile.get("exe" if version == 1 else "name", DEFAULT_PROFILE_NAME)) + profiles[name] = ConfigurationManager._config_from_profile(profile, global_config) if not profiles: - default = dict(ConfigurationManager.get_defaults()) - default["dll"] = global_config["dll"] - default["no_fp16"] = global_config["no_fp16"] - profiles[DEFAULT_PROFILE_NAME] = default - - current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config, - ) - - @staticmethod - def parse_toml_content(content: str) -> ConfigurationData: - profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - return cast(ConfigurationData, profile_data["profiles"][profile_data["current_profile"]]) - - @staticmethod - def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]: - return get_script_parsing_logic()(script_content.splitlines()) - - @staticmethod - def parse_profile_selection(script_content: str) -> str | None: - selected = None - for line in script_content.splitlines(): - try: - tokens = shlex.split(line) - except ValueError: - continue - if len(tokens) != 2 or tokens[0] != "export" or "=" not in tokens[1]: - continue - key, value = tokens[1].split("=", 1) - if key in {"LSFGVK_PROFILE", "LSFG_PROCESS"} and value: - selected = value - return selected - - @staticmethod - def merge_config_with_script( - toml_config: Dict[str, Any], - script_values: Dict[str, Union[bool, int, str]], - ) -> Dict[str, Any]: - merged = dict(toml_config) - for field in SCRIPT_ONLY_FIELDS: - if field in script_values: - merged[field] = script_values[field] - return merged + profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.validate_config(global_config) + elif DEFAULT_PROFILE_NAME not in profiles: + source = profiles.get("decky-lsfg-vk", next(iter(profiles.values()))) + profiles[DEFAULT_PROFILE_NAME] = {**source, "active_in": []} + if profiles.get("decky-lsfg-vk", {}).get("active_in", []) == []: + profiles.pop("decky-lsfg-vk", None) + return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": profiles, "global_config": global_config} @staticmethod - def normalize_profile_name(profile_name: str) -> str: - return re.sub(r"\s+", "-", profile_name.strip()).strip("-") - - @staticmethod - def validate_profile_name(profile_name: str) -> bool: - normalized = ConfigurationManager.normalize_profile_name(profile_name) - invalid = '\t\n\r\'"\\/$|&;()<>{}[]' + "`" + '*?' - return ( - bool(normalized) - and not any(character in invalid for character in normalized) - and normalized.lower() not in {"global", "profile"} - ) - - @staticmethod - def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData: - if not ConfigurationManager.validate_profile_name(profile_name): - raise ValueError(f"Invalid profile name: {profile_name}") - normalized = ConfigurationManager.normalize_profile_name(profile_name) - if normalized in profile_data["profiles"]: - raise ValueError(f"Profile '{normalized}' already exists") - source = source_profile if source_profile in profile_data["profiles"] else profile_data["current_profile"] - profiles = dict(profile_data["profiles"]) - profiles[normalized] = dict(profiles[source]) - return ProfileData( - current_profile=profile_data["current_profile"], - profiles=profiles, - global_config=dict(profile_data["global_config"]), - ) - - @staticmethod - def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - if profile_name == DEFAULT_PROFILE_NAME: - raise ValueError("Cannot delete the default profile") - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - profiles = dict(profile_data["profiles"]) - del profiles[profile_name] - current_profile = profile_data["current_profile"] - if current_profile == profile_name: - current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=dict(profile_data["global_config"]), - ) - - @staticmethod - def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData: - if old_name == DEFAULT_PROFILE_NAME: - raise ValueError("Cannot rename the default profile") - if old_name not in profile_data["profiles"] or not ConfigurationManager.validate_profile_name(new_name): - raise ValueError("Invalid profile rename") - normalized = ConfigurationManager.normalize_profile_name(new_name) - if normalized in profile_data["profiles"]: - raise ValueError(f"Profile '{normalized}' already exists") - profiles = { - normalized if name == old_name else name: value - for name, value in profile_data["profiles"].items() - } - current_profile = normalized if profile_data["current_profile"] == old_name else profile_data["current_profile"] - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=dict(profile_data["global_config"]), - ) + def is_legacy_v1(content: str) -> bool: + try: + return tomllib.loads(content).get("version") == 1 + except tomllib.TOMLDecodeError: + return False @staticmethod - def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - return ProfileData( - current_profile=profile_name, - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]), - ) + def parse_toml_content(content: str) -> Dict[str, Any]: + data = ConfigurationManager.parse_toml_content_multi_profile(content) + return cast(Dict[str, Any], data["profiles"][DEFAULT_PROFILE_NAME]) diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py deleted file mode 100644 index 913609b..0000000 --- a/py_modules/lsfg_vk/config_schema_generated.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Auto-generated configuration schema components from shared_config.py -DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build -""" - -from typing import TypedDict, Dict, Any, Union -from enum import Enum -import sys -from pathlib import Path - -# Import shared configuration constants -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - -# Field name constants for type-safe access -DLL = "dll" -NO_FP16 = "no_fp16" -MULTIPLIER = "multiplier" -FLOW_SCALE = "flow_scale" -PERFORMANCE_MODE = "performance_mode" -EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" -DXVK_FRAME_RATE = "dxvk_frame_rate" -ENABLE_WOW64 = "enable_wow64" -DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" -MANGOHUD_WORKAROUND = "mangohud_workaround" -DISABLE_VKBASALT = "disable_vkbasalt" -FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt" -ENABLE_WSI = "enable_wsi" -ENABLE_ZINK = "enable_zink" - - -class ConfigurationData(TypedDict): - """Type-safe configuration data structure - AUTO-GENERATED""" - dll: str - no_fp16: bool - multiplier: int - flow_scale: float - performance_mode: bool - experimental_present_mode: str - dxvk_frame_rate: int - enable_wow64: bool - disable_steamdeck_mode: bool - mangohud_workaround: bool - disable_vkbasalt: bool - force_enable_vkbasalt: bool - enable_wsi: bool - enable_zink: bool - - -def get_script_parsing_logic(): - """Return the script parsing logic as a callable""" - def parse_script_values(lines): - script_values = {} - for line in lines: - line = line.strip() - if not line or line.startswith("#") or not line.startswith("export "): - continue - if "=" in line: - export_line = line[len("export "):] - key, value = export_line.split("=", 1) - key = key.strip() - value = value.strip() - - # Auto-generated parsing logic: - if key == "DXVK_FRAME_RATE": - try: - script_values["dxvk_frame_rate"] = int(value) - except ValueError: - pass - if key == "PROTON_USE_WOW64": - script_values["enable_wow64"] = value == "1" - if key == "SteamDeck": - script_values["disable_steamdeck_mode"] = value == "0" - if key == "MANGOHUD": - script_values["mangohud_workaround"] = value == "1" - if key == "DISABLE_VKBASALT": - script_values["disable_vkbasalt"] = value == "1" - if key == "ENABLE_VKBASALT": - script_values["force_enable_vkbasalt"] = value == "1" - if key == "ENABLE_GAMESCOPE_WSI": - script_values["enable_wsi"] = value != "0" - if key == "DXVK_HDR": - script_values["enable_wsi"] = value != "0" - if key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa": - script_values["enable_zink"] = True - if key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink": - script_values["enable_zink"] = True - if key == "GALLIUM_DRIVER" and value == "zink": - script_values["enable_zink"] = True - - return script_values - return parse_script_values - - -def get_script_generation_logic(): - """Return the script generation logic as a callable""" - def generate_script_lines(config): - lines = [] - dxvk_frame_rate = config.get("dxvk_frame_rate", 0) - if dxvk_frame_rate > 0: - lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}") - if config.get("enable_wow64", False): - lines.append("export PROTON_USE_WOW64=1") - if config.get("disable_steamdeck_mode", False): - lines.append("export SteamDeck=0") - if config.get("mangohud_workaround", False): - lines.append("export MANGOHUD=1") - if config.get("disable_vkbasalt", False): - lines.append("export DISABLE_VKBASALT=1") - if config.get("force_enable_vkbasalt", False): - lines.append("export ENABLE_VKBASALT=1") - if not config.get("enable_wsi", False): - lines.append("export ENABLE_GAMESCOPE_WSI=0") - lines.append("export DXVK_HDR=0") - if config.get("enable_zink", False): - lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa") - lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink") - lines.append("export GALLIUM_DRIVER=zink") - return lines - return generate_script_lines - - -ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index 12710cc..2626a66 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -1,229 +1,134 @@ +import re import shlex +from typing import Any, Dict 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): + """Controller-facing adapter over upstream lsfg-vk profiles.""" + 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: + def _default_data(self) -> ProfileData: + defaults = ConfigurationManager.validate_config({}) + return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: defaults}, "global_config": {"dll": "", "no_fp16": False}} + + def _get_profile_data(self) -> ProfileData: + if not self.config_file_path.exists(): + return self._default_data() + return ConfigurationManager.parse_toml_content_multi_profile(self.config_file_path.read_text(encoding="utf-8")) + + def _save_profile_data(self, data: ProfileData) -> None: + content = ConfigurationManager.generate_toml_content_multi_profile(data) + self.runtime_service.validate_config_content(content) + self._write_file(self.config_file_path, content, 0o644) + + @staticmethod + def _game_profile_name(appid: str) -> str: + if not re.fullmatch(r"[0-9]+", str(appid)): + raise ValueError("appid must be numeric") + return f"game-{appid}" + + @staticmethod + def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: + return ConfigurationManager.validate_config(config) + + def get_config(self) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - current_profile = profile_data["current_profile"] - config = profile_data["profiles"].get(current_profile, dict(ConfigurationManager.get_defaults())) - return self._success_response(ConfigurationResponse, config=config) + data = self._get_profile_data() + return self._success_response(dict, config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: self.log.error(f"Error reading lsfg config: {error}") - return self._error_response(ConfigurationResponse, str(error), config=None) + return self._error_response(dict, str(error), config=None) - def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse: + def get_game_configs(self) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - return self.update_profile_config(profile_data["current_profile"], config) + data = self._get_profile_data() + games = [] + for name, raw in data["profiles"].items(): + active_in = raw.get("active_in", []) + if len(active_in) != 1 or not str(active_in[0]).isdigit(): + continue + games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)}) + return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=games) except Exception as error: - self.log.error(f"Error updating lsfg config: {error}") - return self._error_response(ConfigurationResponse, str(error), config=None) + self.log.error(f"Error reading game configs: {error}") + return self._error_response(dict, str(error), default=None, games=[]) - def update_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse: + def get_game_config(self, appid: str) -> Dict[str, Any]: try: - profile_data: ProfileData = { - "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False), - }, - } - return self.update_lsfg_script_from_profile_data(profile_data) + data = self._get_profile_data() + name = self._game_profile_name(appid) + profile = data["profiles"].get(name) + if profile is None: + profile = next((value for value in data["profiles"].values() if str(appid) in value.get("active_in", [])), None) + return self._success_response(dict, appid=str(appid), exists=profile is not None, config=self._public_config(profile or data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: - return self._error_response(ConfigurationResponse, str(error), config=None) - - def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: - current_profile = profile_data["current_profile"] - config = dict(profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults())) - config["dll"] = profile_data["global_config"].get("dll", config.get("dll", "")) - config["no_fp16"] = profile_data["global_config"].get("no_fp16", config.get("no_fp16", False)) - - lines = ["#!/bin/bash"] - lines.extend(get_script_generation_logic()(config)) - lines.extend( - [ - f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}", - f"export LSFGVK_PROFILE={shlex.quote(current_profile)}", - 'exec "$@"', - ] - ) - return "\n".join(lines) + "\n" - - def _generate_script_content(self, config: ConfigurationData) -> str: - profile_data: ProfileData = { - "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False), - }, - } - return self._generate_script_content_for_profile(profile_data) + return self._error_response(dict, str(error), appid=str(appid), exists=False, config=None) - def _get_profile_data(self) -> ProfileData: - if not self.config_file_path.exists(): - default = ConfigurationManager.get_defaults() - return ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: dict(default)}, - global_config={ - "dll": default.get("dll", ""), - "no_fp16": default.get("no_fp16", False), - }, - ) - - profile_data = ConfigurationManager.parse_toml_content_multi_profile( - self.config_file_path.read_text(encoding="utf-8") - ) - if self.lsfg_script_path.exists(): - script_content = self.lsfg_script_path.read_text(encoding="utf-8") - selected = ConfigurationManager.parse_profile_selection(script_content) - if selected in profile_data["profiles"]: - profile_data["current_profile"] = selected - current_profile = profile_data["current_profile"] - profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( - profile_data["profiles"][current_profile], - ConfigurationManager.parse_script_content(script_content), - ) - 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, - content, - 0o644, - ) - - def get_profiles(self) -> ProfilesResponse: - try: - profile_data = self._get_profile_data() - return self._success_response( - ProfilesResponse, - "Profiles retrieved successfully", - profiles=list(profile_data["profiles"]), - current_profile=profile_data["current_profile"], - ) - except Exception as error: - return self._error_response( - ProfilesResponse, - str(error), - profiles=None, - current_profile=None, - ) - - def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse: + def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile) - self._save_profile_data(new_profile_data) - normalized = ConfigurationManager.normalize_profile_name(profile_name) - return self._success_response( - ProfileResponse, - f"Profile '{normalized}' created successfully", - profile_name=normalized, - ) + data = self._get_profile_data() + name = self._game_profile_name(appid) + validated = self._public_config(config) + validated["active_in"] = [str(appid)] + data["profiles"][name] = validated + self._save_profile_data(data) + return self._success_response(dict, appid=str(appid), config=validated) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), appid=str(appid), config=None) - def delete_profile(self, profile_name: str) -> ProfileResponse: + def reset_game_config(self, appid: str) -> Dict[str, Any]: try: - profile_data = ConfigurationManager.delete_profile(self._get_profile_data(), profile_name) - self._save_profile_data(profile_data) - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - return self._success_response( - ProfileResponse, - f"Profile '{profile_name}' deleted successfully", - profile_name=profile_name, - ) + data = self._get_profile_data() + name = self._game_profile_name(appid) + data["profiles"].pop(name, None) + for profile_name, profile in list(data["profiles"].items()): + if profile_name != DEFAULT_PROFILE_NAME and str(appid) in profile.get("active_in", []): + data["profiles"].pop(profile_name) + self._save_profile_data(data) + return self._success_response(dict, appid=str(appid), config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), appid=str(appid), config=None) - def rename_profile(self, old_name: str, new_name: str) -> ProfileResponse: + def reset_all_game_configs(self) -> Dict[str, Any]: try: - profile_data = ConfigurationManager.rename_profile(self._get_profile_data(), old_name, new_name) - self._save_profile_data(profile_data) - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - normalized = ConfigurationManager.normalize_profile_name(new_name) - return self._success_response( - ProfileResponse, - f"Profile renamed to '{normalized}' successfully", - profile_name=normalized, - ) + data = self._get_profile_data() + data["profiles"] = {DEFAULT_PROFILE_NAME: data["profiles"][DEFAULT_PROFILE_NAME]} + self._save_profile_data(data) + return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=[]) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), default=None, games=[]) - def set_current_profile(self, profile_name: str) -> ProfileResponse: + def update_config_from_dict(self, config: Dict[str, Any]) -> Dict[str, Any]: try: - profile_data = ConfigurationManager.set_current_profile(self._get_profile_data(), profile_name) - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - return self._success_response( - ProfileResponse, - f"Current profile set to '{profile_name}' successfully", - profile_name=profile_name, - ) + data = self._get_profile_data() + validated = self._public_config(config) + validated["active_in"] = [] + data["profiles"][DEFAULT_PROFILE_NAME] = validated + data["global_config"] = {"dll": validated.get("dll", ""), "no_fp16": validated.get("no_fp16", False)} + self._save_profile_data(data) + return self._success_response(dict, config=validated) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), config=None) - def update_profile_config(self, profile_name: str, config: ConfigurationData) -> ConfigurationResponse: - try: - profile_data = self._get_profile_data() - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - - validated = ConfigurationManager.validate_config(config) - profile_data["profiles"][profile_name] = { - **profile_data["profiles"][profile_name], - **validated, - } - profile_data["global_config"]["dll"] = validated.get("dll", "") - profile_data["global_config"]["no_fp16"] = validated.get("no_fp16", False) - self._save_profile_data(profile_data) - - if profile_name == profile_data["current_profile"]: - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - - return self._success_response( - ConfigurationResponse, - f"Profile '{profile_name}' configuration updated successfully", - config=validated, - ) - except Exception as error: - return self._error_response(ConfigurationResponse, str(error), config=None) + def update_lsfg_script(self, config: Dict[str, Any]) -> Dict[str, Any]: + return self.update_config_from_dict(config) + + def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: + return "#!/bin/bash\n" f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}\n" 'exec "$@"\n' + + def _generate_script_content(self, config: Dict[str, Any]) -> str: + return self._generate_script_content_for_profile(self._default_data()) - def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> ConfigurationResponse: + def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> Dict[str, Any]: try: - script_content = self._generate_script_content_for_profile(profile_data) - self._write_file(self.lsfg_script_path, script_content, 0o755) - current_config = profile_data["profiles"].get( - profile_data["current_profile"], - dict(ConfigurationManager.get_defaults()), - ) - return self._success_response( - ConfigurationResponse, - "Launch script updated successfully", - config=current_config, - ) + self._write_file(self.lsfg_script_path, self._generate_script_content_for_profile(profile_data), 0o755) + return self._success_response(dict) except Exception as error: - return self._error_response(ConfigurationResponse, str(error), config=None) + return self._error_response(dict, str(error)) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index e979ae5..f7bfdaf 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -24,13 +24,20 @@ from .constants import ( UI_ICON_FILENAME, ) from .runtime_service import RuntimeService +from .steam_service import SteamService from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse class InstallationService(BaseService): - def __init__(self, logger=None, runtime_service: RuntimeService = None): + def __init__( + self, + logger=None, + runtime_service: RuntimeService = None, + steam_service: SteamService = None, + ): super().__init__(logger) self.runtime_service = runtime_service or RuntimeService(logger=self.log) + self.steam_service = steam_service or SteamService(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 @@ -143,33 +150,25 @@ class InstallationService(BaseService): }, ) + self._resolve_dll_path(profile_data) defaults = dict(ConfigurationManager.get_defaults()) for profile_name, raw_profile in list(profile_data["profiles"].items()): - validated = ConfigurationManager.validate_config({**defaults, **raw_profile}) - profile_data["profiles"][profile_name] = {**raw_profile, **validated} - - if self.lsfg_script_path.exists(): - script_content = self.lsfg_script_path.read_text(encoding="utf-8") - selected = ConfigurationManager.parse_profile_selection(script_content) - if selected in profile_data["profiles"]: - profile_data["current_profile"] = selected - current_profile = profile_data["current_profile"] - profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( - profile_data["profiles"][current_profile], - ConfigurationManager.parse_script_content(script_content), + profile_data["profiles"][profile_name] = ConfigurationManager.validate_config( + {**defaults, **raw_profile, **profile_data["global_config"]} ) + profile_data["current_profile"] = DEFAULT_PROFILE_NAME + return profile_data - if profile_data["current_profile"] not in profile_data["profiles"]: - profile_data["current_profile"] = ( - DEFAULT_PROFILE_NAME - if DEFAULT_PROFILE_NAME in profile_data["profiles"] - else next(iter(profile_data["profiles"])) - ) + def _resolve_dll_path(self, profile_data: ProfileData) -> bool: + current_path = str(profile_data["global_config"].get("dll") or "") + if current_path and Path(current_path).is_file(): + return False - for profile in profile_data["profiles"].values(): - profile["dll"] = profile_data["global_config"].get("dll", "") - profile["no_fp16"] = profile_data["global_config"].get("no_fp16", False) - return profile_data + dll_path = self.steam_service.find_lsfg_vk_dll() + if dll_path and current_path != dll_path: + profile_data["global_config"]["dll"] = dll_path + return True + return False def _create_lsfg_launch_script(self, profile_data: ProfileData) -> None: from .configuration import ConfigurationService @@ -202,6 +201,13 @@ class InstallationService(BaseService): if legacy_layer or legacy_config: return True try: + if self.config_file_path.exists(): + data = ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + configured = str(data["global_config"].get("dll") or "") + if (not configured or not Path(configured).is_file()) and self.steam_service.find_lsfg_vk_dll(): + return True return not self.runtime_service.is_healthy() except Exception: return True diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index f764086..3b9a97f 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -31,10 +31,13 @@ class Plugin: def __init__(self): """Initialize the plugin with all necessary services""" self.runtime_service = RuntimeService() - self.installation_service = InstallationService(runtime_service=self.runtime_service) + self.steam_service = SteamService() + self.installation_service = InstallationService( + runtime_service=self.runtime_service, + steam_service=self.steam_service, + ) self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() - self.steam_service = SteamService() async def install_lsfg_vk(self) -> Dict[str, Any]: """Install the bundled lsfg-vk runtime to ~/.local @@ -74,33 +77,11 @@ class Plugin: Returns: Dict with field names, types, defaults, and profile information """ - try: - profiles_response = self.configuration_service.get_profiles() - - schema_data = { - "field_names": ConfigurationManager.get_field_names(), - "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()}, - "defaults": ConfigurationManager.get_defaults() - } - - if profiles_response.get("success"): - schema_data["profiles"] = profiles_response.get("profiles", []) - schema_data["current_profile"] = profiles_response.get("current_profile") - else: - schema_data["profiles"] = ["decky-lsfg-vk"] - schema_data["current_profile"] = "decky-lsfg-vk" - - return schema_data - - except (ValueError, KeyError, AttributeError) as e: - self.configuration_service.log.warning(f"Failed to get full schema, using fallback: {e}") - return { - "field_names": ConfigurationManager.get_field_names(), - "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()}, - "defaults": ConfigurationManager.get_defaults(), - "profiles": ["decky-lsfg-vk"], - "current_profile": "decky-lsfg-vk" - } + return { + "field_names": ConfigurationManager.get_field_names(), + "field_types": ConfigurationManager.get_field_types(), + "defaults": ConfigurationManager.get_defaults(), + } async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """Update lsfg TOML configuration using object-based API (single source of truth) @@ -111,19 +92,35 @@ class Plugin: Returns: ConfigurationResponse dict with success status """ - validated_config = ConfigurationManager.validate_config(config) - - return self.configuration_service.update_config_from_dict(validated_config) + return self.configuration_service.update_config_from_dict(config) + + async def get_game_configs(self) -> Dict[str, Any]: + return self.configuration_service.get_game_configs() + + async def get_installed_games(self) -> Dict[str, Any]: + return self.steam_service.get_installed_games() - async def get_profiles(self) -> Dict[str, Any]: + async def get_game_config(self, appid: str) -> Dict[str, Any]: + return self.configuration_service.get_game_config(appid) + + async def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]: + return self.configuration_service.update_game_config(appid, config) + + async def reset_game_config(self, appid: str) -> Dict[str, Any]: + return self.configuration_service.reset_game_config(appid) + + async def reset_all_game_configs(self) -> Dict[str, Any]: + return self.configuration_service.reset_all_game_configs() + + async def _legacy_get_profiles(self) -> Dict[str, Any]: """Get list of all profiles and current profile Returns: ProfilesResponse dict with profile list and current profile """ - return self.configuration_service.get_profiles() + return self.configuration_service.get_game_configs() - async def create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]: + async def _legacy_create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]: """Create a new profile Args: @@ -133,9 +130,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.create_profile(profile_name, source_profile) + return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - async def delete_profile(self, profile_name: str) -> Dict[str, Any]: + async def _legacy_delete_profile(self, profile_name: str) -> Dict[str, Any]: """Delete a profile Args: @@ -144,9 +141,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.delete_profile(profile_name) + return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - async def rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]: + async def _legacy_rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]: """Rename a profile Args: @@ -156,9 +153,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.rename_profile(old_name, new_name) + return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - async def set_current_profile(self, profile_name: str) -> Dict[str, Any]: + async def _legacy_set_current_profile(self, profile_name: str) -> Dict[str, Any]: """Set the current active profile Args: @@ -167,9 +164,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.set_current_profile(profile_name) + return {"success": False, "error": "There is no globally selected profile"} - async def update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]: + async def _legacy_update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]: """Update configuration for a specific profile Args: @@ -179,9 +176,7 @@ class Plugin: Returns: ConfigurationResponse dict with success status """ - validated_config = ConfigurationManager.validate_config(config) - - return self.configuration_service.update_profile_config(profile_name, validated_config) + return {"success": False, "error": "Use update_game_config with a Steam AppID"} async def get_launch_option(self) -> Dict[str, Any]: """Get the launch option that users need to set for their games @@ -192,7 +187,7 @@ class Plugin: return { "launch_option": "~/lsfg %command%", "instructions": "Add this to your game's launch options in Steam Properties", - "explanation": "The lsfg script is created during installation and sets up the environment for the plugin" + "explanation": "The lsfg script points games at the upstream configuration; profiles are selected by Steam AppID" } async def get_config_file_content(self) -> Dict[str, Any]: diff --git a/py_modules/lsfg_vk/runtime_service.py b/py_modules/lsfg_vk/runtime_service.py index a61220e..8785522 100644 --- a/py_modules/lsfg_vk/runtime_service.py +++ b/py_modules/lsfg_vk/runtime_service.py @@ -24,7 +24,7 @@ class RuntimeService(BaseService): HOME=str(self.user_home), XDG_CONFIG_HOME=str(self.user_home / ".config"), ) - for name in ("LSFGVK_CONFIG", "LSFGVK_PROFILE", "LSFGVK_ENV"): + for name in ("LSFGVK_CONFIG", "LSFGVK_ENV"): environment.pop(name, None) return environment diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 867a135..58e0a20 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -128,6 +128,16 @@ class SteamService(BaseService): ), } + def find_lsfg_vk_dll(self) -> Optional[str]: + """Find the branch-specific upstream DLL in any Steam library.""" + if self.get_branch_status().get("needs_switch"): + return None + for library_root in self._steam_library_roots(): + dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll" + if dll_path.is_file(): + return str(dll_path) + return None + def get_branch_status(self) -> Dict[str, object]: try: manifest_path = self._manifest_path() @@ -165,3 +175,23 @@ class SteamService(BaseService): needs_switch=False, restart_required=False, ) + + def get_installed_games(self) -> Dict[str, object]: + """Return installed Steam app IDs and names for the Game Mode selector.""" + try: + games = {} + for library_root in self._steam_library_roots(): + for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"): + match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) + if not match: + continue + try: + content = manifest.read_text(encoding="utf-8") + except OSError: + continue + appid = match.group(1) + name = self._section_value(content, "AppState", "name") or f"App {appid}" + games[appid] = name + return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())]) + except Exception as error: + return self._error_response(dict, str(error), games=[]) diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 2c56a15..ef6bab7 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -68,15 +68,16 @@ class ProfileConfig(TypedDict): class ProfilesResponse(BaseResponse): - """Response for profile operations""" - profiles: Optional[List[str]] - current_profile: Optional[str] + """Response for per-game upstream profiles""" + default: Optional[ConfigurationData] + games: Optional[List[Dict[str, Any]]] message: Optional[str] error: Optional[str] class ProfileResponse(BaseResponse): - """Response for single profile operations""" - profile_name: Optional[str] + """Response for a per-game upstream profile""" + appid: Optional[str] + config: Optional[ConfigurationData] message: Optional[str] error: Optional[str] -- cgit v1.2.3 From 8cf9d66b05bae5d58e72b0cb7d2b83ef13a86141 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 15:23:26 -0400 Subject: fix: filter Steam compatibility tools from game selector --- py_modules/lsfg_vk/steam_service.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 58e0a20..9b69806 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -9,6 +9,35 @@ from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRA class SteamService(BaseService): DEFAULT_BRANCH = "public" MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" + # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG. + GAME_SELECTOR_EXCLUDED_APPIDS = { + "858280", # Proton 3.7 + "961940", # Proton 3.16 + "1054830", # Proton 4.2 + "1113280", # Proton 4.11 + "1245040", # Proton 5.0 + "1420170", # Proton 5.13 + "1493710", # Proton Experimental + "1580130", # Proton 6.3 + "1887720", # Proton 7 + "2180100", # Proton Hotfix + "228980", # Steamworks Common Redistributables + "2348590", # Proton 8 + "2805730", # Proton 9 + "3029110", # Lepton + "3127680", # fex + "3658110", # Proton 10 + "4183110", # Steam Linux Runtime 4.0 + "4185400", # Steam Linux Runtime 4.0 for arm64 + "4427310", # Proton Experimental (ARM64) + "4628710", # Proton 11 / Proton Next + "4628740", # Proton 11 (ARM64) + "4690330", # Legacy Steam Runtime + "993090", # Lossless Scaling + "1070560", # Steam Linux Runtime 1.0 + "1391110", # Steam Linux Runtime 2.0 + "1628350", # Steam Linux Runtime 3.0 + } def _steam_library_roots(self): candidates = ( @@ -190,6 +219,8 @@ class SteamService(BaseService): except OSError: continue appid = match.group(1) + if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS: + continue name = self._section_value(content, "AppState", "name") or f"App {appid}" games[appid] = name return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())]) -- cgit v1.2.3 From c9be32287ad5b72fcd86b10fa726d09dbd97d7fd Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 17:09:54 -0400 Subject: refactor: organize plugin into native tabs --- py_modules/lsfg_vk/base_service.py | 6 +-- py_modules/lsfg_vk/configuration.py | 64 ++++++++++++++--------------- py_modules/lsfg_vk/flatpak_service.py | 21 ++++++++-- py_modules/lsfg_vk/installation.py | 23 +---------- py_modules/lsfg_vk/plugin.py | 48 +--------------------- py_modules/lsfg_vk/steam_service.py | 77 +++++++++++++++++++++++++++++++++-- 6 files changed, 129 insertions(+), 110 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py index c4978c6..036dfc6 100644 --- a/py_modules/lsfg_vk/base_service.py +++ b/py_modules/lsfg_vk/base_service.py @@ -13,12 +13,12 @@ ResponseType = TypeVar("ResponseType", bound=Dict[str, Any]) class BaseService: def __init__(self, logger: Optional[Any] = None): self.log = decky.logger if logger is None else logger - self.user_home = Path.home() + decky_user_home = getattr(decky, "DECKY_USER_HOME", None) + self.user_home = Path(decky_user_home) if decky_user_home else Path.home() self.local_bin_dir = self.user_home / LOCAL_BIN self.local_lib_dir = self.user_home / LOCAL_LIB self.local_share_dir = self.user_home / VULKAN_LAYER_DIR - self.lsfg_script_path = self.user_home / SCRIPT_NAME - self.lsfg_launch_script_path = self.user_home / SCRIPT_NAME + self.legacy_script_path = self.user_home / SCRIPT_NAME self.config_dir = self.user_home / CONFIG_DIR self.config_file_path = self.config_dir / CONFIG_FILENAME diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index 2626a66..a4ee2cc 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -1,5 +1,4 @@ import re -import shlex from typing import Any, Dict from .base_service import BaseService @@ -29,10 +28,27 @@ class ConfigurationService(BaseService): self._write_file(self.config_file_path, content, 0o644) @staticmethod - def _game_profile_name(appid: str) -> str: - if not re.fullmatch(r"[0-9]+", str(appid)): - raise ValueError("appid must be numeric") - return f"game-{appid}" + def _profile_name(data: ProfileData, appid: str, game_name: str) -> str: + name = str(game_name).strip() + if not name: + raise ValueError("game name is required") + if name == DEFAULT_PROFILE_NAME: + name = f"{name} ({appid})" + existing = data["profiles"].get(name) + if existing is not None and str(appid) not in existing.get("active_in", []): + name = f"{name} ({appid})" + return name + + @staticmethod + def _profile_for_appid(data: ProfileData, appid: str): + return next( + ( + (name, profile) + for name, profile in data["profiles"].items() + if str(appid) in profile.get("active_in", []) + ), + (None, None), + ) @staticmethod def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: @@ -52,7 +68,7 @@ class ConfigurationService(BaseService): games = [] for name, raw in data["profiles"].items(): active_in = raw.get("active_in", []) - if len(active_in) != 1 or not str(active_in[0]).isdigit(): + if len(active_in) != 1 or not re.fullmatch(r"-?[0-9]+", str(active_in[0])): continue games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)}) return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=games) @@ -63,20 +79,20 @@ class ConfigurationService(BaseService): def get_game_config(self, appid: str) -> Dict[str, Any]: try: data = self._get_profile_data() - name = self._game_profile_name(appid) - profile = data["profiles"].get(name) - if profile is None: - profile = next((value for value in data["profiles"].values() if str(appid) in value.get("active_in", [])), None) + _, profile = self._profile_for_appid(data, appid) return self._success_response(dict, appid=str(appid), exists=profile is not None, config=self._public_config(profile or data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: return self._error_response(dict, str(error), appid=str(appid), exists=False, config=None) - def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]: + def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]: try: data = self._get_profile_data() - name = self._game_profile_name(appid) + old_name, _ = self._profile_for_appid(data, appid) + name = self._profile_name(data, appid, game_name) validated = self._public_config(config) validated["active_in"] = [str(appid)] + if old_name and old_name != name: + data["profiles"].pop(old_name, None) data["profiles"][name] = validated self._save_profile_data(data) return self._success_response(dict, appid=str(appid), config=validated) @@ -86,11 +102,9 @@ class ConfigurationService(BaseService): def reset_game_config(self, appid: str) -> Dict[str, Any]: try: data = self._get_profile_data() - name = self._game_profile_name(appid) - data["profiles"].pop(name, None) - for profile_name, profile in list(data["profiles"].items()): - if profile_name != DEFAULT_PROFILE_NAME and str(appid) in profile.get("active_in", []): - data["profiles"].pop(profile_name) + name, _ = self._profile_for_appid(data, appid) + if name: + data["profiles"].pop(name, None) self._save_profile_data(data) return self._success_response(dict, appid=str(appid), config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: @@ -116,19 +130,3 @@ class ConfigurationService(BaseService): return self._success_response(dict, config=validated) except Exception as error: return self._error_response(dict, str(error), config=None) - - def update_lsfg_script(self, config: Dict[str, Any]) -> Dict[str, Any]: - return self.update_config_from_dict(config) - - def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: - return "#!/bin/bash\n" f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}\n" 'exec "$@"\n' - - def _generate_script_content(self, config: Dict[str, Any]) -> str: - return self._generate_script_content_for_profile(self._default_data()) - - def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> Dict[str, Any]: - try: - self._write_file(self.lsfg_script_path, self._generate_script_content_for_profile(profile_data), 0o755) - return self._success_response(dict) - except Exception as error: - return self._error_response(dict, str(error)) diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 302efc7..6f8a596 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,4 +1,5 @@ import os +import pwd import shutil import subprocess from pathlib import Path @@ -26,6 +27,7 @@ class FlatpakService(BaseService): def _get_clean_env(self) -> Dict[str, str]: env = os.environ.copy() env.pop("LD_LIBRARY_PATH", None) + env["HOME"] = str(self.user_home) path_entries = [entry for entry in env.get("PATH", "").split(":") if entry] for entry in ("/usr/bin", "/usr/local/bin", "/bin"): if entry not in path_entries: @@ -33,6 +35,12 @@ class FlatpakService(BaseService): env["PATH"] = ":".join(path_entries) return env + def _flatpak_user(self) -> pwd.struct_passwd: + try: + return pwd.getpwuid(self.user_home.stat().st_uid) + except (KeyError, OSError) as error: + raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error + def check_flatpak_available(self) -> bool: env = self._get_clean_env() self.flatpak_command = shutil.which("flatpak", path=env["PATH"]) @@ -41,8 +49,15 @@ class FlatpakService(BaseService): def _run_flatpak_command(self, args: List[str], **kwargs): if self.flatpak_command is None and not self.check_flatpak_available(): raise FileNotFoundError("Flatpak command not available") + command = [self.flatpak_command, *args] + target_user = self._flatpak_user() + if os.geteuid() != target_user.pw_uid: + runuser = shutil.which("runuser", path=self._get_clean_env()["PATH"]) + if runuser is None: + raise FileNotFoundError("runuser command not available") + command = [runuser, "--user", target_user.pw_name, "--", *command] return subprocess.run( - [self.flatpak_command, *args], + command, env=self._get_clean_env(), **kwargs, ) @@ -181,7 +196,7 @@ class FlatpakService(BaseService): self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" ), - "legacy_script": str(self.lsfg_launch_script_path), + "legacy_script": str(self.legacy_script_path), } def _check_app_override_status(self, app_id: str) -> Dict[str, bool]: @@ -200,7 +215,7 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") result = self._run_flatpak_command( - ["list", "--user", "--app", "--columns=name,application"], + ["list", "--app", "--columns=name,application"], capture_output=True, text=True, check=True, diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index f7bfdaf..0a728c7 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -63,7 +63,6 @@ class InstallationService(BaseService): config_content, 0o644, ) - self._create_lsfg_launch_script(profile_data) self._remove_legacy_layer_files() return self._success_response(InstallationResponse, "lsfg-vk 2.0.0 installed successfully") except Exception as error: @@ -170,20 +169,6 @@ class InstallationService(BaseService): return True return False - def _create_lsfg_launch_script(self, profile_data: ProfileData) -> None: - from .configuration import ConfigurationService - - configuration_service = ConfigurationService(logger=self.log) - configuration_service.user_home = self.user_home - configuration_service.config_dir = self.config_dir - configuration_service.config_file_path = self.config_file_path - configuration_service.lsfg_script_path = self.lsfg_launch_script_path - self._write_file( - self.lsfg_launch_script_path, - configuration_service._generate_script_content_for_profile(profile_data), - 0o755, - ) - def _remove_legacy_layer_files(self) -> None: for path in (self.legacy_lib_file, self.legacy_json_file): self._remove_if_exists(path) @@ -212,15 +197,11 @@ class InstallationService(BaseService): except Exception: return True - def get_launch_script_path(self) -> str: - return str(self.lsfg_launch_script_path) - def check_installation(self) -> InstallationCheckResponse: try: - script_exists = self.lsfg_launch_script_path.exists() installation_error = None try: - installed = script_exists and self.runtime_service.is_healthy() + installed = self.runtime_service.is_healthy() except Exception as error: installed = False installation_error = str(error) @@ -254,7 +235,7 @@ class InstallationService(BaseService): 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, + self.legacy_script_path, ): if self._remove_if_exists(path): removed.append(str(path)) diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 3b9a97f..8c788a8 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -103,8 +103,8 @@ class Plugin: async def get_game_config(self, appid: str) -> Dict[str, Any]: return self.configuration_service.get_game_config(appid) - async def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]: - return self.configuration_service.update_game_config(appid, config) + 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) async def reset_game_config(self, appid: str) -> Dict[str, Any]: return self.configuration_service.reset_game_config(appid) @@ -178,18 +178,6 @@ class Plugin: """ return {"success": False, "error": "Use update_game_config with a Steam AppID"} - async def get_launch_option(self) -> Dict[str, Any]: - """Get the launch option that users need to set for their games - - Returns: - Dict containing the launch option string and instructions - """ - return { - "launch_option": "~/lsfg %command%", - "instructions": "Add this to your game's launch options in Steam Properties", - "explanation": "The lsfg script points games at the upstream configuration; profiles are selected by Steam AppID" - } - async def get_config_file_content(self) -> Dict[str, Any]: """Get the current config file content @@ -221,38 +209,6 @@ class Plugin: "error": f"Error reading config file: {str(e)}" } - async def get_launch_script_content(self) -> Dict[str, Any]: - """Get the content of the launch script file - - Returns: - FileContentResponse dict with file content or error information - """ - try: - script_path = self.installation_service.get_launch_script_path() - - if not os.path.exists(script_path): - return { - "success": False, - "error": f"Launch script not found at {script_path}", - "path": str(script_path) - } - - with open(script_path, 'r') as file: - content = file.read() - - return { - "success": True, - "content": content, - "path": str(script_path) - } - - except Exception as e: - decky.logger.error(f"Error reading launch script: {e}") - return { - "success": False, - "error": str(e) - } - async def check_fgmod_directory(self) -> Dict[str, Any]: """Check if the fgmod directory exists in the home directory diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 9b69806..54ac359 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -39,7 +39,7 @@ class SteamService(BaseService): "1628350", # Steam Linux Runtime 3.0 } - def _steam_library_roots(self): + def _steam_roots(self): candidates = ( self.user_home / ".local/share/Steam", self.user_home / ".steam/steam", @@ -51,6 +51,11 @@ class SteamService(BaseService): for candidate in candidates: yield from self._unique_existing_root(candidate, seen) + def _steam_library_roots(self): + seen = set() + for candidate in self._steam_roots(): + yield from self._unique_existing_root(candidate, seen) + library_file = candidate / "steamapps/libraryfolders.vdf" try: content = library_file.read_text(encoding="utf-8") @@ -61,6 +66,65 @@ class SteamService(BaseService): path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') yield from self._unique_existing_root(Path(path), seen) + @staticmethod + def _read_shortcuts(data: bytes) -> Dict[str, object]: + def read_string(offset: int) -> Tuple[str, int]: + end = data.index(b"\0", offset) + return data[offset:end].decode("utf-8", errors="replace"), end + 1 + + def read_object(offset: int = 0) -> Tuple[Dict[str, object], int]: + values = {} + while offset < len(data): + value_type, offset = data[offset], offset + 1 + if value_type == 8: + return values, offset + key, offset = read_string(offset) + if value_type == 0: + value, offset = read_object(offset) + elif value_type == 1: + value, offset = read_string(offset) + elif value_type == 2: + if offset + 4 > len(data): + raise ValueError("truncated binary VDF integer") + value = int.from_bytes(data[offset:offset + 4], "little", signed=True) + offset += 4 + else: + raise ValueError(f"unsupported binary VDF type {value_type}") + values[key] = value + raise ValueError("unterminated binary VDF object") + + values, offset = read_object() + if offset != len(data): + raise ValueError("trailing binary VDF data") + return values + + @staticmethod + def _shortcut_game(shortcut: object) -> Optional[Dict[str, object]]: + if not isinstance(shortcut, dict): + return None + appid = shortcut.get("appid") + name = 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), "name": name, "nonSteam": True} + + def _shortcut_games(self): + games = {} + for steam_root in self._steam_roots(): + for shortcuts_file in sorted((steam_root / "userdata").glob("*/config/shortcuts.vdf")): + try: + root = self._read_shortcuts(shortcuts_file.read_bytes()) + except (OSError, ValueError): + continue + shortcuts = root.get("shortcuts", {}) + if not isinstance(shortcuts, dict): + continue + for shortcut in shortcuts.values(): + game = self._shortcut_game(shortcut) + if game and game["appid"] not in self.GAME_SELECTOR_EXCLUDED_APPIDS: + games.setdefault(game["appid"], game) + return list(games.values()) + @staticmethod def _unique_existing_root(path: Path, seen: set[str]): if not path.exists(): @@ -208,7 +272,7 @@ class SteamService(BaseService): def get_installed_games(self) -> Dict[str, object]: """Return installed Steam app IDs and names for the Game Mode selector.""" try: - games = {} + games: Dict[str, Dict[str, object]] = {} for library_root in self._steam_library_roots(): for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"): match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) @@ -222,7 +286,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] = name - return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())]) + games[appid] = {"appid": appid, "name": name, "nonSteam": False} + for game in self._shortcut_games(): + games.setdefault(str(game["appid"]), game) + return self._success_response( + dict, + games=sorted(games.values(), key=lambda game: str(game["name"]).lower()), + ) except Exception as error: return self._error_response(dict, str(error), games=[]) -- cgit v1.2.3 From 7bc5f685186b1ff03ce0f7c99e7bec411beabaeb Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 22:04:54 -0400 Subject: feat: make ui suck less, frfr --- py_modules/lsfg_vk/config_schema.py | 52 +++------------ py_modules/lsfg_vk/configuration.py | 56 +++++----------- py_modules/lsfg_vk/installation.py | 6 +- py_modules/lsfg_vk/plugin.py | 128 ------------------------------------ py_modules/lsfg_vk/types.py | 31 +-------- 5 files changed, 28 insertions(+), 245 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 4ae1f4c..675ab61 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -4,18 +4,14 @@ import json import sys import tomllib from pathlib import Path -from typing import Any, Dict, TypedDict, cast +from typing import Any, Dict, TypedDict sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -DEFAULT_PROFILE_NAME = "default" ConfigurationData = Dict[str, Any] class ProfileData(TypedDict): - # Internal compatibility field; the public API no longer exposes a - # currently selected profile. - current_profile: str profiles: Dict[str, Dict[str, Any]] global_config: Dict[str, Any] @@ -57,19 +53,6 @@ class ConfigurationManager: def get_defaults() -> Dict[str, Any]: return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS} - @staticmethod - def get_field_names() -> list[str]: - return list(ConfigurationManager.get_defaults()) - - @staticmethod - def get_field_types() -> Dict[str, str]: - return { - "dll": "string", "no_fp16": "boolean", "active_in": "array", - "pacing_mode": "string", "multiplier": "integer", "flow_scale": "float", - "performance_mode": "boolean", "override_present_mode": "boolean", - "preserve_swapchain_image_count": "boolean", - } - @staticmethod def validate_config(config: Dict[str, Any]) -> Dict[str, Any]: result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS} @@ -110,15 +93,6 @@ class ConfigurationManager: raw["no_fp16"] = global_config.get("no_fp16", False) return ConfigurationManager.validate_config(raw) - @staticmethod - def generate_toml_content(config: Dict[str, Any]) -> str: - data: ProfileData = { - "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": {"dll": config.get("dll", ""), "no_fp16": config.get("no_fp16", False)}, - } - return ConfigurationManager.generate_toml_content_multi_profile(data) - @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})} @@ -127,7 +101,9 @@ class ConfigurationManager: if dll: lines.append(f"dll = {_toml_value(dll)}") lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}") - profiles = sorted(profile_data["profiles"].items(), key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0])) + profiles = sorted(profile_data["profiles"].items()) + if not profiles: + profiles = [("", {})] for name, raw in profiles: config = ConfigurationManager.validate_config({**raw, **global_config}) lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"]) @@ -157,16 +133,11 @@ class ConfigurationManager: profiles: Dict[str, Dict[str, Any]] = {} source_profiles = data.get("game", []) if version == 1 else data.get("profile", []) for profile in source_profiles: - name = str(profile.get("exe" if version == 1 else "name", DEFAULT_PROFILE_NAME)) - profiles[name] = ConfigurationManager._config_from_profile(profile, global_config) - if not profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.validate_config(global_config) - elif DEFAULT_PROFILE_NAME not in profiles: - source = profiles.get("decky-lsfg-vk", next(iter(profiles.values()))) - profiles[DEFAULT_PROFILE_NAME] = {**source, "active_in": []} - if profiles.get("decky-lsfg-vk", {}).get("active_in", []) == []: - profiles.pop("decky-lsfg-vk", None) - return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": profiles, "global_config": global_config} + name = str(profile.get("exe" if version == 1 else "name", "")) + config = ConfigurationManager._config_from_profile(profile, global_config) + if config["active_in"]: + profiles[name] = config + return {"profiles": profiles, "global_config": global_config} @staticmethod def is_legacy_v1(content: str) -> bool: @@ -174,8 +145,3 @@ class ConfigurationManager: return tomllib.loads(content).get("version") == 1 except tomllib.TOMLDecodeError: return False - - @staticmethod - def parse_toml_content(content: str) -> Dict[str, Any]: - data = ConfigurationManager.parse_toml_content_multi_profile(content) - return cast(Dict[str, Any], data["profiles"][DEFAULT_PROFILE_NAME]) diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index a4ee2cc..bec3828 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -2,7 +2,7 @@ import re from typing import Any, Dict from .base_service import BaseService -from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData +from .config_schema import ConfigurationManager, ProfileData from .runtime_service import RuntimeService @@ -14,8 +14,7 @@ class ConfigurationService(BaseService): self.runtime_service = runtime_service or RuntimeService(logger=self.log) def _default_data(self) -> ProfileData: - defaults = ConfigurationManager.validate_config({}) - return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: defaults}, "global_config": {"dll": "", "no_fp16": False}} + return {"profiles": {}, "global_config": {"dll": "", "no_fp16": False}} def _get_profile_data(self) -> ProfileData: if not self.config_file_path.exists(): @@ -32,8 +31,6 @@ class ConfigurationService(BaseService): name = str(game_name).strip() if not name: raise ValueError("game name is required") - if name == DEFAULT_PROFILE_NAME: - name = f"{name} ({appid})" existing = data["profiles"].get(name) if existing is not None and str(appid) not in existing.get("active_in", []): name = f"{name} ({appid})" @@ -54,14 +51,6 @@ class ConfigurationService(BaseService): def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: return ConfigurationManager.validate_config(config) - def get_config(self) -> Dict[str, Any]: - try: - data = self._get_profile_data() - return self._success_response(dict, config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) - except Exception as error: - self.log.error(f"Error reading lsfg config: {error}") - return self._error_response(dict, str(error), config=None) - def get_game_configs(self) -> Dict[str, Any]: try: data = self._get_profile_data() @@ -71,26 +60,25 @@ class ConfigurationService(BaseService): if len(active_in) != 1 or not re.fullmatch(r"-?[0-9]+", str(active_in[0])): continue games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)}) - return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=games) + return self._success_response(dict, global_config=dict(data["global_config"]), games=games) except Exception as error: self.log.error(f"Error reading game configs: {error}") - return self._error_response(dict, str(error), default=None, games=[]) - - def get_game_config(self, appid: str) -> Dict[str, Any]: - try: - data = self._get_profile_data() - _, profile = self._profile_for_appid(data, appid) - return self._success_response(dict, appid=str(appid), exists=profile is not None, config=self._public_config(profile or data["profiles"][DEFAULT_PROFILE_NAME])) - except Exception as error: - return self._error_response(dict, str(error), appid=str(appid), exists=False, config=None) + return self._error_response(dict, str(error), games=[]) 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) - validated = self._public_config(config) + merged_config = {**data["global_config"], **config} + 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 @@ -106,27 +94,15 @@ class ConfigurationService(BaseService): if name: data["profiles"].pop(name, None) self._save_profile_data(data) - return self._success_response(dict, appid=str(appid), config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) + return self._success_response(dict, appid=str(appid), exists=False) except Exception as error: return self._error_response(dict, str(error), appid=str(appid), config=None) def reset_all_game_configs(self) -> Dict[str, Any]: try: data = self._get_profile_data() - data["profiles"] = {DEFAULT_PROFILE_NAME: data["profiles"][DEFAULT_PROFILE_NAME]} - self._save_profile_data(data) - return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=[]) - except Exception as error: - return self._error_response(dict, str(error), default=None, games=[]) - - def update_config_from_dict(self, config: Dict[str, Any]) -> Dict[str, Any]: - try: - data = self._get_profile_data() - validated = self._public_config(config) - validated["active_in"] = [] - data["profiles"][DEFAULT_PROFILE_NAME] = validated - data["global_config"] = {"dll": validated.get("dll", ""), "no_fp16": validated.get("no_fp16", False)} + data["profiles"] = {} self._save_profile_data(data) - return self._success_response(dict, config=validated) + return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) except Exception as error: - return self._error_response(dict, str(error), config=None) + return self._error_response(dict, str(error), games=[]) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 0a728c7..09bd3a3 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Dict from .base_service import BaseService -from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData +from .config_schema import ConfigurationManager, ProfileData from .constants import ( ARCHIVE_FILENAME, BIN_DIR, @@ -141,8 +141,7 @@ class InstallationService(BaseService): else: default = dict(ConfigurationManager.get_defaults()) profile_data = ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: default}, + profiles={}, global_config={ "dll": default.get("dll", ""), "no_fp16": default.get("no_fp16", False), @@ -155,7 +154,6 @@ class InstallationService(BaseService): profile_data["profiles"][profile_name] = ConfigurationManager.validate_config( {**defaults, **raw_profile, **profile_data["global_config"]} ) - profile_data["current_profile"] = DEFAULT_PROFILE_NAME return profile_data def _resolve_dll_path(self, profile_data: ProfileData) -> bool: diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 8c788a8..6977b9c 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -7,13 +7,11 @@ Vulkan layer for frame generation on Steam Deck. import os from typing import Dict, Any -from pathlib import Path import decky from .installation import InstallationService from .configuration import ConfigurationService -from .config_schema import ConfigurationManager from .flatpak_service import FlatpakService from .runtime_service import RuntimeService from .steam_service import SteamService @@ -63,46 +61,12 @@ class Plugin: """ return self.installation_service.uninstall() - async def get_lsfg_config(self) -> Dict[str, Any]: - """Read current lsfg script configuration - - Returns: - ConfigurationResponse dict with current configuration or error - """ - return self.configuration_service.get_config() - - async def get_config_schema(self) -> Dict[str, Any]: - """Get configuration schema information for frontend - - Returns: - Dict with field names, types, defaults, and profile information - """ - return { - "field_names": ConfigurationManager.get_field_names(), - "field_types": ConfigurationManager.get_field_types(), - "defaults": ConfigurationManager.get_defaults(), - } - - async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - """Update lsfg TOML configuration using object-based API (single source of truth) - - Args: - config: Configuration data dictionary containing all settings - - Returns: - ConfigurationResponse dict with success status - """ - return self.configuration_service.update_config_from_dict(config) - async def get_game_configs(self) -> Dict[str, Any]: return self.configuration_service.get_game_configs() async def get_installed_games(self) -> Dict[str, Any]: return self.steam_service.get_installed_games() - async def get_game_config(self, appid: str) -> Dict[str, Any]: - return self.configuration_service.get_game_config(appid) - 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) @@ -112,72 +76,6 @@ class Plugin: async def reset_all_game_configs(self) -> Dict[str, Any]: return self.configuration_service.reset_all_game_configs() - async def _legacy_get_profiles(self) -> Dict[str, Any]: - """Get list of all profiles and current profile - - Returns: - ProfilesResponse dict with profile list and current profile - """ - return self.configuration_service.get_game_configs() - - async def _legacy_create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]: - """Create a new profile - - Args: - profile_name: Name for the new profile - source_profile: Optional source profile to copy from (default: current profile) - - Returns: - ProfileResponse dict with success status - """ - return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - - async def _legacy_delete_profile(self, profile_name: str) -> Dict[str, Any]: - """Delete a profile - - Args: - profile_name: Name of the profile to delete - - Returns: - ProfileResponse dict with success status - """ - return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - - async def _legacy_rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]: - """Rename a profile - - Args: - old_name: Current profile name - new_name: New profile name - - Returns: - ProfileResponse dict with success status - """ - return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - - async def _legacy_set_current_profile(self, profile_name: str) -> Dict[str, Any]: - """Set the current active profile - - Args: - profile_name: Name of the profile to set as current - - Returns: - ProfileResponse dict with success status - """ - return {"success": False, "error": "There is no globally selected profile"} - - async def _legacy_update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]: - """Update configuration for a specific profile - - Args: - profile_name: Name of the profile to update - config: Configuration data dictionary containing settings - - Returns: - ConfigurationResponse dict with success status - """ - return {"success": False, "error": "Use update_game_config with a Steam AppID"} - async def get_config_file_content(self) -> Dict[str, Any]: """Get the current config file content @@ -209,32 +107,6 @@ class Plugin: "error": f"Error reading config file: {str(e)}" } - async def check_fgmod_directory(self) -> Dict[str, Any]: - """Check if the fgmod directory exists in the home directory - - Returns: - Dict with exists status and directory path - """ - try: - home_path = Path(decky.DECKY_USER_HOME) - fgmod_path = home_path / "fgmod" - - exists = fgmod_path.exists() and fgmod_path.is_dir() - - return { - "success": True, - "exists": exists, - "path": str(fgmod_path) - } - - except Exception as e: - decky.logger.error(f"Error checking fgmod directory: {e}") - return { - "success": False, - "exists": False, - "error": str(e) - } - async def check_flatpak_extension_status(self) -> Dict[str, Any]: """Check status of lsfg-vk Flatpak runtime extensions diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index ef6bab7..7b85708 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -2,8 +2,7 @@ Type definitions for the lsfg-vk plugin responses. """ -from typing import TypedDict, Optional, List, Dict, Any -from .config_schema import ConfigurationData +from typing import TypedDict, Optional, List class BaseResponse(TypedDict): @@ -53,31 +52,3 @@ class SteamBranchStatusResponse(TypedDict): target_branch: str needs_switch: bool restart_required: bool - -class ConfigurationResponse(BaseResponse): - """Response for configuration operations""" - config: Optional[ConfigurationData] - message: Optional[str] - error: Optional[str] - - -class ProfileConfig(TypedDict): - """Configuration for a single profile""" - exe: str - config: ConfigurationData - - -class ProfilesResponse(BaseResponse): - """Response for per-game upstream profiles""" - default: Optional[ConfigurationData] - games: Optional[List[Dict[str, Any]]] - message: Optional[str] - error: Optional[str] - - -class ProfileResponse(BaseResponse): - """Response for a per-game upstream profile""" - appid: Optional[str] - config: Optional[ConfigurationData] - message: Optional[str] - error: Optional[str] -- cgit v1.2.3 From 991edaeb6b39ee9a7628f036ac2f1c57cf1caea1 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 23:40:23 -0400 Subject: fix: use unsigned non-steam app IDs --- py_modules/lsfg_vk/steam_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 54ac359..8c50cfd 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -106,7 +106,7 @@ class SteamService(BaseService): name = 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), "name": name, "nonSteam": True} + return {"appid": str(appid & 0xffffffff), "name": name, "nonSteam": True} def _shortcut_games(self): games = {} -- cgit v1.2.3 From 22db1125238e54b29538102727c36284e122e703 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 16:02:26 -0400 Subject: feat: refine game discovery and profile UX --- py_modules/lsfg_vk/config_schema.py | 4 ++-- py_modules/lsfg_vk/steam_service.py | 26 +++++++++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 675ab61..ce109f3 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -20,7 +20,7 @@ PROFILE_DEFAULTS: Dict[str, Any] = { "active_in": [], "pacing_mode": "vsync", "multiplier": 2, - "flow_scale": 1.0, + "flow_scale": 0.8, "performance_mode": False, "override_present_mode": True, "preserve_swapchain_image_count": False, @@ -78,7 +78,7 @@ class ConfigurationManager: if not path_value: return "" path = Path(path_value) - if path.name.lower() in {"lossless.dll", "losslessscaling.dll"}: + if path.name.lower() in {"lossless.dll"}: return str(path.with_name("lsfg-vk.dll")) return path_value diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 8c50cfd..f46a091 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -56,15 +56,18 @@ class SteamService(BaseService): for candidate in self._steam_roots(): yield from self._unique_existing_root(candidate, seen) - library_file = candidate / "steamapps/libraryfolders.vdf" - try: - content = library_file.read_text(encoding="utf-8") - except OSError: - continue + for library_file in ( + candidate / "steamapps/libraryfolders.vdf", + candidate / "config/libraryfolders.vdf", + ): + try: + content = library_file.read_text(encoding="utf-8") + except OSError: + continue - for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): - path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') - yield from self._unique_existing_root(Path(path), seen) + for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): + path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') + yield from self._unique_existing_root(Path(path), seen) @staticmethod def _read_shortcuts(data: bytes) -> Dict[str, object]: @@ -88,6 +91,11 @@ class SteamService(BaseService): raise ValueError("truncated binary VDF integer") value = int.from_bytes(data[offset:offset + 4], "little", signed=True) offset += 4 + elif value_type == 7: + if offset + 8 > len(data): + raise ValueError("truncated binary VDF 64-bit integer") + value = int.from_bytes(data[offset:offset + 8], "little", signed=True) + offset += 8 else: raise ValueError(f"unsupported binary VDF type {value_type}") values[key] = value @@ -254,7 +262,7 @@ class SteamService(BaseService): elif fields["restart_required"]: message = "lsfg-vk is selected; restart Steam to finish the branch switch" else: - message = "Lossless Scaling is not using the lsfg-vk Steam branch" + message = "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" return self._success_response(dict, message, **fields) except Exception as error: return self._error_response( -- cgit v1.2.3 From e21eee83fc58fee3481aa0e17feb7cd9a8d8750e Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 20:16:09 -0400 Subject: Support lowercase Steam shortcut names and collapsible profile details --- py_modules/lsfg_vk/steam_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index f46a091..9a6570f 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -111,7 +111,7 @@ class SteamService(BaseService): if not isinstance(shortcut, dict): return None appid = shortcut.get("appid") - name = shortcut.get("AppName") + 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} -- cgit v1.2.3 From 54acc36f16e1336772354f979a618cce65061f82 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 20:57:26 -0400 Subject: refactor: make runtime installation explicit --- py_modules/lsfg_vk/flatpak_service.py | 29 ----------------------------- py_modules/lsfg_vk/installation.py | 24 ------------------------ py_modules/lsfg_vk/plugin.py | 10 ---------- 3 files changed, 63 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 6f8a596..6aebf11 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -323,32 +323,3 @@ class FlatpakService(BaseService): app_id=app_id, operation="remove", ) - - def migrate_v2(self) -> None: - if not self.check_flatpak_available(): - return - - apps_result = self._run_flatpak_command( - ["list", "--user", "--app", "--columns=application"], - capture_output=True, - text=True, - ) - if apps_result.returncode != 0: - return - - for app_id in apps_result.stdout.splitlines(): - app_id = app_id.strip() - if not app_id: - continue - 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 09bd3a3..b583cc7 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -171,30 +171,6 @@ class InstallationService(BaseService): for path in (self.legacy_lib_file, self.legacy_json_file): self._remove_if_exists(path) - def needs_v2_migration(self) -> bool: - legacy_layer = self.legacy_lib_file.exists() or self.legacy_json_file.exists() - legacy_config = False - if self.config_file_path.exists(): - try: - legacy_config = ConfigurationManager.is_legacy_v1( - self.config_file_path.read_text(encoding="utf-8") - ) - except OSError: - legacy_config = False - if legacy_layer or legacy_config: - return True - try: - if self.config_file_path.exists(): - data = ConfigurationManager.parse_toml_content_multi_profile( - self.config_file_path.read_text(encoding="utf-8") - ) - configured = str(data["global_config"].get("dll") or "") - if (not configured or not Path(configured).is_file()) and self.steam_service.find_lsfg_vk_dll(): - return True - return not self.runtime_service.is_healthy() - except Exception: - return True - def check_installation(self) -> InstallationCheckResponse: try: installation_error = None diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 6977b9c..0472e42 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -236,14 +236,4 @@ class Plugin: os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"), os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk")) - if self.installation_service.needs_v2_migration(): - result = self.installation_service.install() - if not result.get("success"): - decky.logger.warning(f"Native v2 migration failed: {result.get('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") -- cgit v1.2.3 From 790132668c4421c68c32bdc8fc9792b0d6028f97 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 22:18:13 -0400 Subject: I really dont want to but here you go little guy --- py_modules/lsfg_vk/flatpak_service.py | 134 ++++++++++++++++++++++++++-------- 1 file changed, 105 insertions(+), 29 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 6aebf11..0e89d3c 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,5 +1,6 @@ import os import pwd +import re import shutil import subprocess from pathlib import Path @@ -19,6 +20,10 @@ from .types import BaseResponse class FlatpakService(BaseService): EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") + COMPATIBILITY_ENV = ( + ("ENABLE_GAMESCOPE_WSI", "0"), + ("DXVK_HDR", "0"), + ) def __init__(self, logger=None): super().__init__(logger) @@ -170,7 +175,9 @@ class FlatpakService(BaseService): capture_output=True, text=True, ) - return result.stdout if result.returncode == 0 else "" + 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(): @@ -199,15 +206,99 @@ class FlatpakService(BaseService): "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": ( - paths["config_dir"] in output - and paths["dll_dir"] in output + "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, + ) ), - "env": f"LSFGVK_CONFIG={paths['config_file']}" in output, } def get_flatpak_apps(self) -> Dict[str, Any]: @@ -253,6 +344,7 @@ class FlatpakService(BaseService): 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", @@ -260,12 +352,7 @@ 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", + *(f"--env={name}={value}" for name, value in self.COMPATIBILITY_ENV), app_id, ], capture_output=True, @@ -273,6 +360,9 @@ class FlatpakService(BaseService): ) 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") return self._success_response( BaseResponse, f"lsfg-vk overrides set for {app_id}", @@ -292,24 +382,10 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") paths = self._override_paths() - result = self._run_flatpak_command( - [ - "override", - "--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", - "--unset-env=LSFG_CONFIG", - app_id, - ], - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise OSError(result.stderr.strip() or "Failed to remove Flatpak overrides") + 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}", -- cgit v1.2.3 From 1b932fd69c3dba925e0cbf027e05508b2daf5e8c Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Wed, 9 Sep 2026 01:01:42 -0400 Subject: add back launcher script and correct pathing --- py_modules/lsfg_vk/constants.py | 1 + py_modules/lsfg_vk/installation.py | 1 - py_modules/lsfg_vk/plugin.py | 25 +- py_modules/lsfg_vk/wrapper_service.py | 428 ++++++++++++++++++++++++++++++++++ 4 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 py_modules/lsfg_vk/wrapper_service.py (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 19df278..960d230 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -5,6 +5,7 @@ VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d" CONFIG_DIR = ".config/lsfg-vk" SCRIPT_NAME = "lsfg" +WRAPPER_FILENAME = ".lsfg" CONFIG_FILENAME = "conf.toml" ARCHIVE_FILENAME = "lsfg-vk-2.0.0.tar.xz" LIB_FILENAME = "liblsfg-vk-layer.so" diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index b583cc7..8a3094d 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -209,7 +209,6 @@ 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, ): if self._remove_if_exists(path): removed.append(str(path)) diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 0472e42..f80c635 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -6,7 +6,7 @@ Vulkan layer for frame generation on Steam Deck. """ import os -from typing import Dict, Any +from typing import Any, Dict, Optional import decky @@ -15,6 +15,7 @@ from .configuration import ConfigurationService from .flatpak_service import FlatpakService from .runtime_service import RuntimeService from .steam_service import SteamService +from .wrapper_service import WrapperService class Plugin: @@ -36,6 +37,7 @@ class Plugin: ) self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() + self.wrapper_service = WrapperService() async def install_lsfg_vk(self) -> Dict[str, Any]: """Install the bundled lsfg-vk runtime to ~/.local @@ -76,6 +78,21 @@ class Plugin: async def reset_all_game_configs(self) -> Dict[str, Any]: return self.configuration_service.reset_all_game_configs() + async def get_workaround_state(self, appid: str) -> Dict[str, Any]: + return self.wrapper_service.get(appid) + + async def set_workaround_state( + self, + appid: str, + state: Dict[str, Any], + shortcut_exe: Optional[str] = None, + command_token_added: bool = False, + ) -> Dict[str, Any]: + return self.wrapper_service.set(appid, state, shortcut_exe, command_token_added) + + async def remove_workaround_state(self, appid: str) -> Dict[str, Any]: + return self.wrapper_service.remove(appid) + async def get_config_file_content(self) -> Dict[str, Any]: """Get the current config file content @@ -177,6 +194,9 @@ class Plugin: This method is called by Decky Loader when the plugin is loaded. Any initialization code should go here. """ + repair = self.wrapper_service.repair() + if not repair.get("success"): + decky.logger.error(f"Could not repair lsfg workaround wrapper: {repair.get('error')}") decky.logger.info("decky-lsfg-vk plugin loaded") async def _unload(self): @@ -198,6 +218,9 @@ class Plugin: decky.logger.info("decky-lsfg-vk plugin being uninstalled") # Clean up lsfg-vk files when the plugin is uninstalled + # Launch integrations are removed with their profiles. Keep the + # generated pass-through wrapper if it is still referenced elsewhere; + # InstallationService only removes files owned by the runtime bundle. self.installation_service.cleanup_on_uninstall() try: diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py new file mode 100644 index 0000000..a3cba6e --- /dev/null +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -0,0 +1,428 @@ +"""Own the small per-AppID workaround dispatcher used by Steam launches.""" + +from __future__ import annotations + +import json +import re +import shlex +import threading +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from .base_service import BaseService +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" + WRAPPER_TOKEN = "~/.lsfg" + STATE_FIELDS = ( + "dxvkFrameRate", + "disableGamescopeWsi", + "disableHdr", + "disableSteamdeckMode", + "disableVkbasalt", + "enableZink", + ) + BOOLEAN_FIELDS = STATE_FIELDS[1:] + MANAGED_ENV_KEYS = ( + "ENABLE_GAMESCOPE_WSI", + "DISABLE_GAMESCOPE_WSI", + "DXVK_HDR", + "SteamDeck", + "DISABLE_VKBASALT", + "ENABLE_VKBASALT", + "MESA_LOADER_DRIVER_OVERRIDE", + "__GLX_VENDOR_LIBRARY_NAME", + "GALLIUM_DRIVER", + "DXVK_FRAME_RATE", + ) + + def __init__(self, logger=None): + super().__init__(logger) + self.sidecar_path = self.config_dir / "workarounds.json" + self.wrapper_path = self.user_home / WRAPPER_FILENAME + self._lock = threading.RLock() + + @classmethod + def default_state(cls) -> Dict[str, Any]: + return { + "dxvkFrameRate": 0, + "disableGamescopeWsi": True, + "disableHdr": True, + "disableSteamdeckMode": False, + "disableVkbasalt": False, + "enableZink": False, + } + + @staticmethod + def _valid_appid(appid: Any) -> str: + value = str(appid) + if not re.fullmatch(r"[1-9][0-9]*", value): + raise ValueError("Invalid Steam App ID") + return value + + @classmethod + def _validate_state(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("Workaround state must be an object") + missing = [field for field in cls.STATE_FIELDS if field not in raw] + if missing: + raise ValueError("Workaround state is missing: " + ", ".join(missing)) + state = {field: raw[field] for field in cls.STATE_FIELDS} + frame_rate = state["dxvkFrameRate"] + if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60: + raise ValueError("Base FPS Cap must be an integer from 0 to 60") + for field in cls.BOOLEAN_FIELDS: + if type(state[field]) is not bool: + raise ValueError(f"{field} must be a boolean") + return state + + @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), + } + if type(entry["command_token_added"]) is not bool: + raise ValueError("command_token_added must be a boolean") + if "shortcut_exe" in raw and raw["shortcut_exe"] is not None: + shortcut_exe = raw["shortcut_exe"] + if ( + not isinstance(shortcut_exe, str) + or not shortcut_exe.startswith("/") + or "\x00" in shortcut_exe + or not shortcut_exe.strip() + ): + raise ValueError("shortcut_exe must be an absolute executable path") + entry["shortcut_exe"] = shortcut_exe + return entry + + @classmethod + def _validate_document(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict) or raw.get("version") != cls.FORMAT_VERSION: + raise ValueError("Unsupported lsfg-vk workaround state version") + apps = raw.get("apps") + if not isinstance(apps, dict): + raise ValueError("Workaround state apps must be an object") + validated_apps: Dict[str, Any] = {} + for appid, entry in apps.items(): + normalized = cls._valid_appid(appid) + if normalized != str(appid): + raise ValueError("Workaround AppIDs must not contain leading zeroes") + validated_apps[normalized] = cls._validate_entry(entry) + return {"version": cls.FORMAT_VERSION, "apps": validated_apps} + + def _empty_document(self) -> Dict[str, Any]: + return {"version": self.FORMAT_VERSION, "apps": {}} + + def _read_document(self) -> Tuple[Dict[str, Any], bool, Optional[str]]: + if not self.sidecar_path.exists(): + return self._empty_document(), False, None + if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file(): + raise RuntimeError("Workaround state path is not a regular file") + try: + raw = json.loads(self.sidecar_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"Could not read workaround state: {error}") from error + document = self._validate_document(raw) + return document, True, self.sidecar_path.read_text(encoding="utf-8") + + def _wrapper_marker(self) -> bool: + if self.wrapper_path.is_symlink() or not self.wrapper_path.exists(): + return False + if not self.wrapper_path.is_file(): + raise RuntimeError("lsfg wrapper path is not a regular file") + try: + 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 + + def _assert_wrapper_owned_or_absent(self) -> bool: + if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink(): + return False + if self.wrapper_path.is_symlink() or not self._wrapper_marker(): + raise RuntimeError( + f"Refusing to replace unowned wrapper at {self.wrapper_path}" + ) + return True + + @staticmethod + def _shell(value: str) -> str: + return shlex.quote(value) + + @classmethod + def _state_lines(cls, state: Dict[str, Any], shortcut_exe: Optional[str]) -> list[str]: + lines = [" unset " + " ".join(cls.MANAGED_ENV_KEYS)] + if state["disableGamescopeWsi"]: + lines.extend([" ENABLE_GAMESCOPE_WSI=0", " export ENABLE_GAMESCOPE_WSI"]) + if state["disableHdr"]: + lines.extend([" DXVK_HDR=0", " export DXVK_HDR"]) + if state["disableSteamdeckMode"]: + lines.extend([" SteamDeck=0", " export SteamDeck"]) + if state["disableVkbasalt"]: + lines.extend([" DISABLE_VKBASALT=1", " export DISABLE_VKBASALT"]) + if state["enableZink"]: + lines.extend([ + " __GLX_VENDOR_LIBRARY_NAME=mesa", + " export __GLX_VENDOR_LIBRARY_NAME", + " MESA_LOADER_DRIVER_OVERRIDE=zink", + " export MESA_LOADER_DRIVER_OVERRIDE", + " GALLIUM_DRIVER=zink", + " export GALLIUM_DRIVER", + ]) + frame_rate = state["dxvkFrameRate"] + if frame_rate > 0: + lines.extend([ + ' if [ -n "${DXVK_CONFIG+x}" ]; then', + ' if [ -n "${DXVK_CONFIG}" ]; then', + f' DXVK_CONFIG="${{DXVK_CONFIG}}; dxvk.maxFrameRate = {frame_rate}"', + " else", + f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"', + " fi", + " else", + f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"', + " fi", + " export DXVK_CONFIG", + ]) + lines.append(f" shortcut_exe={cls._shell(shortcut_exe or '')}") + return lines + + @classmethod + def _flatpak_args(cls, state: Dict[str, Any]) -> list[str]: + args = [ + '"--env=SteamAppId=$appid"', + '"--unset-env=DISABLE_GAMESCOPE_WSI"', + '"--unset-env=ENABLE_GAMESCOPE_WSI"' if not state["disableGamescopeWsi"] else + '"--env=ENABLE_GAMESCOPE_WSI=0"', + '"--unset-env=DXVK_HDR"' if not state["disableHdr"] else + '"--env=DXVK_HDR=0"', + '"--unset-env=SteamDeck"' if not state["disableSteamdeckMode"] else + '"--env=SteamDeck=0"', + '"--unset-env=DISABLE_VKBASALT" "--unset-env=ENABLE_VKBASALT"', + ] + if state["disableVkbasalt"]: + args.append('"--env=DISABLE_VKBASALT=1"') + args.extend([ + '"--unset-env=MESA_LOADER_DRIVER_OVERRIDE" "--unset-env=__GLX_VENDOR_LIBRARY_NAME" "--unset-env=GALLIUM_DRIVER"', + ]) + if state["enableZink"]: + args.extend([ + '"--env=__GLX_VENDOR_LIBRARY_NAME=mesa"', + '"--env=MESA_LOADER_DRIVER_OVERRIDE=zink"', + '"--env=GALLIUM_DRIVER=zink"', + ]) + args.extend([ + '"--unset-env=DXVK_FRAME_RATE"', + ]) + static_args = " ".join(args) + return [ + ' if [ -n "${DXVK_CONFIG+x}" ]; then', + f' set -- "$flatpak_command" {static_args} "--env=DXVK_CONFIG=$DXVK_CONFIG" "$@"', + " else", + f' set -- "$flatpak_command" {static_args} "$@"', + " fi", + ] + + @classmethod + def _render_wrapper(cls, document: Dict[str, Any]) -> str: + lines = [ + "#!/bin/sh", + cls.MARKER, + "# Generated by Decky LSFG-VK; edits will be rejected on the next update.", + "", + "appid=", + 'case "${SteamAppId-}" in', + " ''|*[!0-9]*) ;;", + ' *) appid="${SteamAppId}" ;;', + "esac", + 'if [ -z "$appid" ]; then', + ' case "${SteamGameId-}" in', + " ''|*[!0-9]*) ;;", + ' *) appid="${SteamGameId}" ;;', + " esac", + "fi", + 'if [ -z "$appid" ]; then', + ' case "${STEAM_COMPAT_APP_ID-}" in', + " ''|*[!0-9]*) ;;", + ' *) appid="${STEAM_COMPAT_APP_ID}" ;;', + " esac", + "fi", + "shortcut_exe=", + 'case "$appid" in', + ] + 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.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"): + continue + lines.append(f" {appid})") + lines.extend(cls._flatpak_args(entry["state"])) + lines.append(" ;;") + lines.extend([ + " esac", + " fi", + ' exec "$shortcut_exe" "$@"', + "fi", + 'exec "$@"', + "", + ]) + return "\n".join(lines) + + def _write_document(self, document: Dict[str, Any]) -> None: + content = json.dumps(document, indent=2, sort_keys=True) + "\n" + self._write_file(self.sidecar_path, content, 0o644) + + def _write_pair(self, document: Dict[str, Any]) -> None: + old_sidecar_exists = self.sidecar_path.exists() + old_sidecar = self.sidecar_path.read_text(encoding="utf-8") if old_sidecar_exists else None + old_wrapper_exists = self.wrapper_path.exists() or self.wrapper_path.is_symlink() + old_wrapper = self.wrapper_path.read_text(encoding="utf-8") if old_wrapper_exists and not self.wrapper_path.is_symlink() else None + try: + self._write_document(document) + self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755) + except Exception: + try: + if old_sidecar_exists and old_sidecar is not None: + self._write_file(self.sidecar_path, old_sidecar, 0o644) + elif self.sidecar_path.exists(): + self.sidecar_path.unlink() + if old_wrapper_exists and old_wrapper is not None: + self._write_file(self.wrapper_path, old_wrapper, 0o755) + elif not old_wrapper_exists and self.wrapper_path.exists(): + self.wrapper_path.unlink() + except Exception as rollback_error: + self.log.error(f"Could not roll back workaround wrapper update: {rollback_error}") + raise + + def _response(self, document: Dict[str, Any], appid: str = "") -> Dict[str, Any]: + entry = document["apps"].get(appid) + return { + "success": True, + "message": "", + "error": None, + "appid": appid or None, + "state": dict(entry["state"]) if entry else None, + "wrapper_path": self.WRAPPER_TOKEN, + "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, + } + + def get(self, appid: str) -> Dict[str, Any]: + try: + normalized = self._valid_appid(appid) + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + return self._response(document, normalized) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "appid": str(appid), + "state": None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + } + + def set( + self, + appid: str, + state: Dict[str, Any], + shortcut_exe: Optional[str] = None, + command_token_added: 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") + with self._lock: + self._assert_wrapper_owned_or_absent() + document, _, _ = self._read_document() + previous_entry = document["apps"].get(normalized) + entry: Dict[str, Any] = { + "state": validated_state, + "command_token_added": bool(command_token_added), + } + if shortcut_exe is not None: + entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe}) + elif previous_entry and "shortcut_exe" in previous_entry: + entry["shortcut_exe"] = previous_entry["shortcut_exe"] + document["apps"][normalized] = entry + self._write_pair(document) + return self._response(document, normalized) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "appid": str(appid), + "state": None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + } + + def remove(self, appid: str) -> Dict[str, Any]: + try: + normalized = self._valid_appid(appid) + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + if normalized not in document["apps"]: + return self._response(document, normalized) + document["apps"].pop(normalized, None) + self._write_pair(document) + return self._response(document, normalized) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "appid": str(appid), + "state": None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + } + + def repair(self) -> Dict[str, Any]: + """Regenerate a missing owned wrapper without importing old global state.""" + try: + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + if not document["apps"]: + return self._response(document) + self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755) + return self._response(document) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + } -- cgit v1.2.3 From cc1e6f47dd9838b066822162a607d2859c043aff Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Wed, 9 Sep 2026 19:16:30 -0400 Subject: refactor: unify flatpak targets with steam profiles --- py_modules/lsfg_vk/flatpak_service.py | 724 ++++++++++++++++++++-------------- py_modules/lsfg_vk/plugin.py | 109 ++--- py_modules/lsfg_vk/steam_service.py | 86 +++- py_modules/lsfg_vk/wrapper_service.py | 139 ++++++- 4 files changed, 683 insertions(+), 375 deletions(-) (limited to 'py_modules/lsfg_vk') 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", @@ -81,6 +83,28 @@ class WrapperService(BaseService): raise ValueError(f"{field} must be a boolean") 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): @@ -88,6 +112,10 @@ class WrapperService(BaseService): 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}) -- cgit v1.2.3 From fb4d053213bdbda271a54b517a11a89c4780f80a Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Wed, 9 Sep 2026 20:45:20 -0400 Subject: fix: restore profile and Flatpak controls --- py_modules/lsfg_vk/flatpak_service.py | 56 ++++++++++++++++++++++++++++++----- py_modules/lsfg_vk/plugin.py | 3 ++ 2 files changed, 51 insertions(+), 8 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 071b29c..c0a9c0c 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -299,11 +299,6 @@ class FlatpakService(BaseService): version = 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" - ) with self._lock: owned, uncertain = self._read_owned_branches() if uncertain: @@ -318,7 +313,14 @@ class FlatpakService(BaseService): f"lsfg-vk {version} runtime extension is already installed", runtime_branch=version, installed=True, + enabled=True, owned_by_plugin=version in owned, + preserved=version not in owned, + ) + 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( [ @@ -346,7 +348,9 @@ class FlatpakService(BaseService): f"lsfg-vk {version} runtime extension installed", runtime_branch=version, installed=True, + enabled=True, owned_by_plugin=True, + preserved=False, ) except Exception as error: return self._error_response( @@ -354,7 +358,9 @@ class FlatpakService(BaseService): str(error), runtime_branch=version, installed=False, + enabled=False, owned_by_plugin=False, + preserved=False, ) def ensure_extension(self, version: str) -> Dict[str, Any]: @@ -424,15 +430,29 @@ class FlatpakService(BaseService): raise RuntimeError( "Flatpak ownership metadata is uncertain; refusing to uninstall" ) + installed = self._installed_extension_branches() if version not in owned: + if version in installed: + return self._success_response( + dict, + f"Preserved Flatpak extension {version}; it is not plugin-owned", + runtime_branch=version, + removed=False, + installed=True, + enabled=True, + owned_by_plugin=False, + preserved=True, + ) return self._success_response( dict, - f"Preserved Flatpak extension {version}; it is not plugin-owned", + f"Flatpak extension {version} is already not installed", runtime_branch=version, removed=False, - preserved=True, + installed=False, + enabled=False, + owned_by_plugin=False, + preserved=False, ) - installed = self._installed_extension_branches() if version in installed: result = self._run_flatpak_command( [ @@ -458,6 +478,9 @@ class FlatpakService(BaseService): f"Plugin-owned lsfg-vk {version} runtime extension removed", runtime_branch=version, removed=True, + installed=False, + enabled=False, + owned_by_plugin=False, preserved=False, ) except Exception as error: @@ -466,8 +489,25 @@ class FlatpakService(BaseService): str(error), runtime_branch=version, removed=False, + installed=False, + enabled=False, + owned_by_plugin=False, + preserved=False, + ) + + def set_extension_enabled(self, version: str, enabled: bool) -> Dict[str, Any]: + """Set one runtime branch to the requested state, safely and idempotently.""" + if type(enabled) is not bool: + return self._error_response( + dict, + "enabled must be a boolean", + runtime_branch=version, + installed=False, + enabled=False, + owned_by_plugin=False, preserved=False, ) + return self.install_extension(version) if enabled else self.uninstall_extension(version) def remove_plugin_owned_extensions(self) -> Dict[str, Any]: """Uninstall only branches recorded as installed by this plugin.""" diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index cb2d3df..76a4250 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -160,6 +160,9 @@ class Plugin: 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 set_flatpak_extension_enabled(self, version: str, enabled: bool) -> Dict[str, Any]: + return self.flatpak_service.set_extension_enabled(version, enabled) + async def remove_plugin_owned_flatpak_extensions(self) -> Dict[str, Any]: return self.flatpak_service.remove_plugin_owned_extensions() -- cgit v1.2.3 From bc75bb93d5aa9aa176262a138f47d3a1d53afbcb Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 00:25:58 -0400 Subject: fix: simplify Flatpak runtime toggles --- py_modules/lsfg_vk/flatpak_service.py | 76 +++++++---------------------------- py_modules/lsfg_vk/plugin.py | 3 -- 2 files changed, 14 insertions(+), 65 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index c0a9c0c..11f1a82 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -202,11 +202,8 @@ class FlatpakService(BaseService): extension_id=self.EXTENSION_ID, supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=[], - owned_branches=[], - ownership_uncertain=False, ) installed = self._installed_extension_branches() - owned, uncertain = self._read_owned_branches() return self._success_response( dict, "Flatpak runtime extension status retrieved", @@ -214,8 +211,6 @@ class FlatpakService(BaseService): 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( @@ -225,8 +220,6 @@ class FlatpakService(BaseService): extension_id=self.EXTENSION_ID, supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=[], - owned_branches=[], - ownership_uncertain=False, ) def get_flatpak_support_status(self) -> Dict[str, Any]: @@ -294,18 +287,12 @@ class FlatpakService(BaseService): ) def install_extension(self, version: str) -> Dict[str, Any]: - """Install one missing branch and record ownership only after readback.""" + """Install one branch, treating an already-installed branch as success.""" try: version = self._validate_runtime(version) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") 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( @@ -314,8 +301,6 @@ class FlatpakService(BaseService): runtime_branch=version, installed=True, enabled=True, - owned_by_plugin=version in owned, - preserved=version not in owned, ) bundle_path = self._bundled_extension_path(version) if not bundle_path.is_file(): @@ -341,16 +326,16 @@ class FlatpakService(BaseService): f"Flatpak install completed but {self._extension_ref(version)} " "was not visible afterwards" ) - owned.add(version) - self._write_owned_branches(owned) + owned, uncertain = self._read_owned_branches() + if not uncertain: + 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, enabled=True, - owned_by_plugin=True, - preserved=False, ) except Exception as error: return self._error_response( @@ -359,8 +344,6 @@ class FlatpakService(BaseService): runtime_branch=version, installed=False, enabled=False, - owned_by_plugin=False, - preserved=False, ) def ensure_extension(self, version: str) -> Dict[str, Any]: @@ -384,7 +367,6 @@ class FlatpakService(BaseService): f"lsfg-vk {version} runtime extension is ready", runtime_branch=version, installed=True, - owned_by_plugin=version in status.get("owned_branches", []), ) return self.install_extension(version) @@ -419,41 +401,15 @@ class FlatpakService(BaseService): ) def uninstall_extension(self, version: str) -> Dict[str, Any]: - """Uninstall only when explicitly requested for a plugin-owned branch.""" + """Uninstall one branch, treating an already-absent branch as success.""" try: version = self._validate_runtime(version) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - owned, uncertain = self._read_owned_branches() - if uncertain: - raise RuntimeError( - "Flatpak ownership metadata is uncertain; refusing to uninstall" - ) installed = self._installed_extension_branches() - if version not in owned: - if version in installed: - return self._success_response( - dict, - f"Preserved Flatpak extension {version}; it is not plugin-owned", - runtime_branch=version, - removed=False, - installed=True, - enabled=True, - owned_by_plugin=False, - preserved=True, - ) - return self._success_response( - dict, - f"Flatpak extension {version} is already not installed", - runtime_branch=version, - removed=False, - installed=False, - enabled=False, - owned_by_plugin=False, - preserved=False, - ) - if version in installed: + was_installed = version in installed + if was_installed: result = self._run_flatpak_command( [ "uninstall", @@ -471,17 +427,17 @@ class FlatpakService(BaseService): f"Flatpak uninstall completed but {self._extension_ref(version)} " "is still installed" ) - owned.remove(version) - self._write_owned_branches(owned) + owned, uncertain = self._read_owned_branches() + if not uncertain and version in owned: + owned.remove(version) + self._write_owned_branches(owned) return self._success_response( dict, - f"Plugin-owned lsfg-vk {version} runtime extension removed", + f"lsfg-vk {version} runtime extension uninstalled", runtime_branch=version, - removed=True, + removed=was_installed, installed=False, enabled=False, - owned_by_plugin=False, - preserved=False, ) except Exception as error: return self._error_response( @@ -491,8 +447,6 @@ class FlatpakService(BaseService): removed=False, installed=False, enabled=False, - owned_by_plugin=False, - preserved=False, ) def set_extension_enabled(self, version: str, enabled: bool) -> Dict[str, Any]: @@ -504,8 +458,6 @@ class FlatpakService(BaseService): runtime_branch=version, installed=False, enabled=False, - owned_by_plugin=False, - preserved=False, ) return self.install_extension(version) if enabled else self.uninstall_extension(version) diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 76a4250..c576cc2 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -162,9 +162,6 @@ class Plugin: async def set_flatpak_extension_enabled(self, version: str, enabled: bool) -> Dict[str, Any]: return self.flatpak_service.set_extension_enabled(version, enabled) - - async def remove_plugin_owned_flatpak_extensions(self) -> Dict[str, Any]: - return self.flatpak_service.remove_plugin_owned_extensions() async def _main(self): """ -- cgit v1.2.3 From 28ebc17785a8cca52f289b74261d1f310fd35904 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 07:08:19 -0400 Subject: fix: detect wrapped Flatpak shortcuts --- py_modules/lsfg_vk/steam_service.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index b3bdb69..50d722b 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -4,10 +4,15 @@ from pathlib import Path from typing import Dict, Optional, Tuple from .base_service import BaseService -from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH +from .constants import ( + STEAM_LOSSLESS_SCALING_APP_ID, + STEAM_LOSSLESS_SCALING_BRANCH, + WRAPPER_FILENAME, +) _FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$") +_WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" def _split_command(value: Optional[str]) -> Optional[list[str]]: @@ -19,17 +24,27 @@ def _split_command(value: Optional[str]) -> Optional[list[str]]: return None +def _is_managed_wrapper(value: str) -> bool: + """Recognize the wrapper Target while keeping arbitrary launchers as host games.""" + if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}: + return True + path = Path(value) + return path.is_absolute() and path.name == WRAPPER_FILENAME + + 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.""" + """Classify direct Flatpak invocations, including the managed wrapper Target.""" 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": + direct_flatpak = executable_tokens[0] == "/usr/bin/flatpak" + managed_wrapper = len(executable_tokens) == 1 and _is_managed_wrapper(executable_tokens[0]) + if not direct_flatpak and not managed_wrapper: return {"kind": "host"} arguments = [*executable_tokens[1:], *option_tokens] -- cgit v1.2.3 From 450d3e5e6612d467a00bb937538fded640c66ecb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:24:05 -0400 Subject: refactor: simplify migration implementation --- py_modules/lsfg_vk/config_schema.py | 73 ++---- py_modules/lsfg_vk/flatpak_service.py | 460 +++++++++++----------------------- py_modules/lsfg_vk/installation.py | 79 +++--- py_modules/lsfg_vk/plugin.py | 164 ++++-------- py_modules/lsfg_vk/steam_service.py | 285 ++++++++------------- py_modules/lsfg_vk/types.py | 29 +-- 6 files changed, 355 insertions(+), 735 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index ce109f3..3816d88 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,13 +1,9 @@ """Small adapter for the upstream lsfg-vk v2 configuration format.""" import json -import sys import tomllib -from pathlib import Path from typing import Any, Dict, TypedDict -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - ConfigurationData = Dict[str, Any] @@ -57,8 +53,8 @@ class ConfigurationManager: def validate_config(config: Dict[str, Any]) -> Dict[str, Any]: result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS} result.update({key: value for key, value in config.items() if key in result}) - result["active_in"] = _normalize_active_in(result.get("active_in")) - result["pacing_mode"] = str(result.get("pacing_mode", "vsync")).lower() + result["active_in"] = _normalize_active_in(result["active_in"]) + result["pacing_mode"] = str(result["pacing_mode"]).lower() if result["pacing_mode"] != "vsync": raise ValueError("pacing_mode must be vsync") result["multiplier"] = int(result["multiplier"]) @@ -67,43 +63,24 @@ class ConfigurationManager: result["flow_scale"] = float(result["flow_scale"]) if not 0.25 <= result["flow_scale"] <= 1.0: raise ValueError("flow_scale must be between 0.25 and 1.0") - for name in ("no_fp16", "performance_mode", "override_present_mode", "preserve_swapchain_image_count"): + for name in ( + "no_fp16", + "performance_mode", + "override_present_mode", + "preserve_swapchain_image_count", + ): result[name] = bool(result[name]) - result["dll"] = str(result.get("dll") or "") + result["dll"] = str(result["dll"] or "") return result - @staticmethod - def _migrate_dll_path(value: Any) -> str: - path_value = str(value or "") - if not path_value: - return "" - path = Path(path_value) - if path.name.lower() in {"lossless.dll"}: - return str(path.with_name("lsfg-vk.dll")) - return path_value - - @staticmethod - def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]: - raw = dict(profile) - if "pacing_mode" not in raw and "pacing" in raw: - raw["pacing_mode"] = raw["pacing"] - if "override_present_mode" not in raw and "experimental_present_mode" in raw: - raw["override_present_mode"] = raw["experimental_present_mode"] == "fifo" - raw["dll"] = global_config.get("dll", "") - raw["no_fp16"] = global_config.get("no_fp16", False) - return ConfigurationManager.validate_config(raw) - @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})} lines = ["version = 2", "", "[global]"] - dll = ConfigurationManager._migrate_dll_path(global_config.get("dll")) - if dll: - lines.append(f"dll = {_toml_value(dll)}") - lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}") - profiles = sorted(profile_data["profiles"].items()) - if not profiles: - profiles = [("", {})] + if global_config["dll"]: + lines.append(f"dll = {_toml_value(global_config['dll'])}") + lines.append(f"allow_fp16 = {_toml_value(not bool(global_config['no_fp16']))}") + profiles = sorted(profile_data["profiles"].items()) or [("", {})] for name, raw in profiles: config = ConfigurationManager.validate_config({**raw, **global_config}) lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"]) @@ -122,26 +99,20 @@ class ConfigurationManager: @staticmethod def parse_toml_content_multi_profile(content: str) -> ProfileData: data = tomllib.loads(content) - version = data.get("version") - if version not in (1, 2): + if data.get("version") != 2: raise ValueError("unsupported lsfg-vk configuration version") - raw_global = dict(data.get("global", {})) + raw_global = data.get("global", {}) global_config = { - "dll": ConfigurationManager._migrate_dll_path(raw_global.get("dll", "")), + "dll": str(raw_global.get("dll", "") or ""), "no_fp16": not bool(raw_global.get("allow_fp16", True)), } profiles: Dict[str, Dict[str, Any]] = {} - source_profiles = data.get("game", []) if version == 1 else data.get("profile", []) - for profile in source_profiles: - name = str(profile.get("exe" if version == 1 else "name", "")) - config = ConfigurationManager._config_from_profile(profile, global_config) + for profile in data.get("profile", []): + name = str(profile.get("name", "")) + config = ConfigurationManager.validate_config({ + **profile, + **global_config, + }) if config["active_in"]: profiles[name] = config return {"profiles": profiles, "global_config": global_config} - - @staticmethod - def is_legacy_v1(content: str) -> bool: - try: - return tomllib.loads(content).get("version") == 1 - except tomllib.TOMLDecodeError: - return False diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 11f1a82..d2241ab 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,4 +1,4 @@ -"""Flatpak runtime-extension infrastructure for unified game targets.""" +"""Flatpak runtime support for classified Steam targets.""" from __future__ import annotations @@ -10,7 +10,7 @@ import shutil import subprocess import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Dict, Optional, Set from .base_service import BaseService from .constants import ( @@ -22,14 +22,6 @@ from .constants import ( 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") OWNERSHIP_FILENAME = "flatpak_extensions.json" @@ -37,7 +29,6 @@ class FlatpakService(BaseService): 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) @@ -48,39 +39,37 @@ class FlatpakService(BaseService): def ownership_path(self) -> Path: return self.config_dir / self.OWNERSHIP_FILENAME - def _get_clean_env(self) -> Dict[str, str]: + def _clean_env(self) -> Dict[str, str]: env = os.environ.copy() env.pop("LD_LIBRARY_PATH", None) env["HOME"] = str(self.user_home) - path_entries = [entry for entry in env.get("PATH", "").split(":") if entry] + 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_entries: - path_entries.insert(0, entry) - env["PATH"] = ":".join(path_entries) + if entry not in path: + path.insert(0, entry) + env["PATH"] = ":".join(path) return env - def _flatpak_user(self) -> pwd.struct_passwd: - try: - return pwd.getpwuid(self.user_home.stat().st_uid) - except (KeyError, OSError) as error: - raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error - def check_flatpak_available(self) -> bool: - env = self._get_clean_env() + env = self._clean_env() self.flatpak_command = shutil.which("flatpak", path=env["PATH"]) return self.flatpak_command is not None - def _run_flatpak_command(self, args: List[str], **kwargs): + def _run_flatpak_command(self, args, **kwargs): if self.flatpak_command is None and not self.check_flatpak_available(): raise FileNotFoundError("Flatpak command not available") + env = self._clean_env() command = [self.flatpak_command, *args] - target_user = self._flatpak_user() - if os.geteuid() != target_user.pw_uid: - runuser = shutil.which("runuser", path=self._get_clean_env()["PATH"]) + try: + user = pwd.getpwuid(self.user_home.stat().st_uid) + except (KeyError, OSError) as error: + raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error + if os.geteuid() != user.pw_uid: + runuser = shutil.which("runuser", path=env["PATH"]) 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) + command = [runuser, "--user", user.pw_name, "--", *command] + return subprocess.run(command, env=env, **kwargs) @classmethod def _validate_app_id(cls, app_id: str) -> str: @@ -89,45 +78,32 @@ class FlatpakService(BaseService): return app_id @classmethod - def _validate_runtime(cls, version: str) -> str: - if version not in cls.SUPPORTED_RUNTIMES: + def _validate_runtime(cls, branch: str) -> str: + if branch not in cls.SUPPORTED_RUNTIMES: raise ValueError( - f"Unsupported Flatpak runtime branch {version}; " - f"supported branches are {', '.join(cls.SUPPORTED_RUNTIMES)}" + f"Unsupported Flatpak runtime branch {branch}; 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)}" + return branch @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("/") + parts = runtime_ref.strip().split("/") if isinstance(runtime_ref, str) else [] 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) + return cls._validate_runtime(parts[2]) @classmethod - def _bundle_filename(cls, version: str) -> str: - return { + 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, - }[cls._validate_runtime(version)] - - def _bundled_extension_path(self, version: str) -> Path: - return ( - Path(__file__).resolve().parent.parent.parent - / BIN_DIR - / self._bundle_filename(version) - ) + }[self._validate_runtime(branch)] + return Path(__file__).resolve().parent.parent.parent / BIN_DIR / filename def _installed_extension_branches(self) -> Set[str]: result = self._run_flatpak_command( @@ -136,78 +112,54 @@ class FlatpakService(BaseService): text=True, check=True, ) - installed: Set[str] = set() + installed = 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) + fields = line.split("\t") if "\t" in line else line.split() + if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64": + installed.add(fields[2]) return installed - def _read_owned_branches(self) -> Tuple[Set[str], bool]: - """Read ownership without guessing when metadata is damaged.""" + def _owned_branches(self) -> Set[str]: 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 + if not path.exists() and not path.is_symlink(): + return set() + if path.is_symlink() or not path.is_file(): + raise RuntimeError("Flatpak ownership metadata is not a regular file") 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) - } - if len(normalized) != len(branches): - raise ValueError("ownership metadata contains invalid branches") - return normalized, False + data = json.loads(path.read_text(encoding="utf-8")) + branches = data.get("plugin_owned_branches") + if data.get("version") != self.OWNERSHIP_VERSION or not isinstance(branches, list): + raise ValueError("invalid ownership metadata") + owned = {self._validate_runtime(branch) for branch in branches} + if len(owned) != len(branches): + raise ValueError("invalid ownership metadata") + return owned except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: - self.log.warning(f"Could not trust Flatpak ownership metadata: {error}") - return set(), True + raise RuntimeError(f"Could not trust Flatpak ownership metadata: {error}") from error 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() + self.ownership_path.unlink(missing_ok=True) return - document = { - "version": self.OWNERSHIP_VERSION, - "plugin_owned_branches": sorted(branches), - } - self._write_file(self.ownership_path, json.dumps(document, indent=2) + "\n") + self._write_file( + self.ownership_path, + json.dumps( + { + "version": self.OWNERSHIP_VERSION, + "plugin_owned_branches": sorted(branches), + }, + indent=2, + ) + "\n", + ) - def get_extension_status(self) -> Dict[str, Any]: - """Return global extension inventory for Setup diagnostics.""" + def get_extension_status(self): try: - if not self.check_flatpak_available(): - return self._success_response( - dict, - "Flatpak is not available", - available=False, - extension_id=self.EXTENSION_ID, - supported_branches=list(self.SUPPORTED_RUNTIMES), - installed_branches=[], - ) - installed = self._installed_extension_branches() + available = self.check_flatpak_available() + installed = self._installed_extension_branches() if available else set() return self._success_response( dict, - "Flatpak runtime extension status retrieved", - available=True, + "Flatpak runtime extension status retrieved" if available else "Flatpak is not available", + available=available, extension_id=self.EXTENSION_ID, supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=sorted(installed), @@ -216,16 +168,15 @@ class FlatpakService(BaseService): return self._error_response( dict, str(error), - available=self.check_flatpak_available(), + available=False, extension_id=self.EXTENSION_ID, supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=[], ) - def get_flatpak_support_status(self) -> Dict[str, Any]: - return self.get_extension_status() + get_flatpak_support_status = get_extension_status - def _resolve_runtime(self, app_id: str) -> Dict[str, Any]: + def _resolve_runtime(self, app_id: str): self._validate_app_id(app_id) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") @@ -236,27 +187,21 @@ class FlatpakService(BaseService): ) if result.returncode != 0: 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} + runtime = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + return runtime, self.runtime_branch_from_ref(runtime) - def resolve_app_support(self, app_id: str) -> Dict[str, Any]: - """Resolve the exact runtime branch required by one Flatpak app.""" + def resolve_app_support(self, app_id: str): try: app_id = self._validate_app_id(app_id) - resolved = self._resolve_runtime(app_id) + runtime, branch = 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}" - ), + 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=runtime, runtime_branch=branch, support_status="ready" if ready else "needs-runtime", extension_installed=ready, @@ -286,194 +231,114 @@ class FlatpakService(BaseService): installed_branches=[], ) - def install_extension(self, version: str) -> Dict[str, Any]: - """Install one branch, treating an already-installed branch as success.""" + def install_extension(self, branch: str): try: - version = self._validate_runtime(version) + branch = self._validate_runtime(branch) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - 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, - enabled=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" - ) + if branch in self._installed_extension_branches(): + return self._extension_result(branch, True, False, "already installed") + bundle = self._bundled_extension_path(branch) + if not bundle.is_file(): + raise FileNotFoundError(f"Bundled Flatpak extension not found at {bundle}; reinstall the plugin") result = self._run_flatpak_command( - [ - "install", - "--user", - "--noninteractive", - "--or-update", - str(bundle_path), - ], + ["install", "--user", "--noninteractive", "--or-update", str(bundle)], 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, uncertain = self._read_owned_branches() - if not uncertain: - owned.add(version) + if branch not in self._installed_extension_branches(): + raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards") + owned = self._owned_branches() + owned.add(branch) + self._write_owned_branches(owned) + 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) + + def _remove_extension(self, branch: str) -> bool: + if branch not in self._installed_extension_branches(): + return False + 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") + return True + + def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str): + return self._success_response( + dict, + f"lsfg-vk {branch} runtime extension {verb}", + runtime_branch=branch, + installed=installed, + enabled=installed, + removed=removed, + ) + + def uninstall_extension(self, branch: str): + try: + branch = self._validate_runtime(branch) + if not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak is not available on this system") + with self._lock: + removed = self._remove_extension(branch) + owned = self._owned_branches() + if branch in owned: + owned.remove(branch) self._write_owned_branches(owned) - return self._success_response( - dict, - f"lsfg-vk {version} runtime extension installed", - runtime_branch=version, - installed=True, - enabled=True, - ) + return self._extension_result(branch, False, removed, "uninstalled") except Exception as error: return self._error_response( dict, str(error), - runtime_branch=version, + runtime_branch=branch, + removed=False, installed=False, enabled=False, ) - 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", - ) + def ensure_extension(self, branch: str): 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( - dict, - f"lsfg-vk {version} runtime extension is ready", - runtime_branch=version, - installed=True, - ) - return self.install_extension(version) + 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, support_status="error") + return self.install_extension(branch) - def ensure_app_support(self, app_id: str) -> Dict[str, Any]: - """Provision only the branch returned by flatpak info for this app.""" + def ensure_app_support(self, app_id: str): 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) + result = self.ensure_extension(resolved["runtime_branch"]) if not result.get("success"): return self._error_response( 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, + runtime_branch=resolved.get("runtime_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 uninstall_extension(self, version: str) -> Dict[str, Any]: - """Uninstall one branch, treating an already-absent branch as success.""" - try: - version = self._validate_runtime(version) - if not self.check_flatpak_available(): - raise FileNotFoundError("Flatpak is not available on this system") - with self._lock: - installed = self._installed_extension_branches() - was_installed = version in installed - if was_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, uncertain = self._read_owned_branches() - if not uncertain and version in owned: - owned.remove(version) - self._write_owned_branches(owned) - return self._success_response( - dict, - f"lsfg-vk {version} runtime extension uninstalled", - runtime_branch=version, - removed=was_installed, - installed=False, - enabled=False, - ) - except Exception as error: - return self._error_response( - dict, - str(error), - runtime_branch=version, - removed=False, - installed=False, - enabled=False, - ) + return self.resolve_app_support(app_id) - def set_extension_enabled(self, version: str, enabled: bool) -> Dict[str, Any]: - """Set one runtime branch to the requested state, safely and idempotently.""" + def set_extension_enabled(self, branch: str, enabled: bool): if type(enabled) is not bool: - return self._error_response( - dict, - "enabled must be a boolean", - runtime_branch=version, - installed=False, - enabled=False, - ) - return self.install_extension(version) if enabled else self.uninstall_extension(version) + return self._error_response(dict, "enabled must be a boolean", runtime_branch=branch, installed=False, enabled=False) + return self.install_extension(branch) if enabled else self.uninstall_extension(branch) - def remove_plugin_owned_extensions(self) -> Dict[str, Any]: - """Uninstall only branches recorded as installed by this plugin.""" + def remove_plugin_owned_extensions(self): 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, - ) + owned = self._owned_branches() if not owned: return self._success_response( dict, @@ -483,36 +348,11 @@ class FlatpakService(BaseService): 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] = [] + raise RuntimeError("Flatpak is not available; plugin-owned extension metadata was preserved") + removed, failures = [], [] 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" - ) + self._remove_extension(branch) removed.append(branch) except Exception as error: failures.append(f"{branch}: {error}") @@ -539,5 +379,5 @@ class FlatpakService(BaseService): str(error), removed_branches=[], preserved_branches=[], - ownership_uncertain=False, + ownership_uncertain=True, ) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 8a3094d..5a583f5 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -48,26 +48,20 @@ class InstallationService(BaseService): def install(self) -> InstallationResponse: try: - plugin_dir = Path(__file__).parent.parent.parent - archive_path = plugin_dir / BIN_DIR / ARCHIVE_FILENAME + archive_path = Path(__file__).parent.parent.parent / BIN_DIR / ARCHIVE_FILENAME if not archive_path.exists(): raise FileNotFoundError(f"{ARCHIVE_FILENAME} not found at {archive_path}") - 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, - config_content, - 0o644, - ) + content = ConfigurationManager.generate_toml_content_multi_profile(profile_data) + self.runtime_service.validate_config_content(content) + self._write_file(self.config_file_path, content, 0o644) self._remove_legacy_layer_files() return self._success_response(InstallationResponse, "lsfg-vk 2.0.0 installed successfully") except Exception as error: self.log.error(f"Error installing lsfg-vk: {error}") - return self._error_response(InstallationResponse, str(error), message="") + return self._error_response(InstallationResponse, str(error)) def _payload_destinations(self) -> Dict[str, tuple[Path, int]]: return { @@ -82,7 +76,7 @@ class InstallationService(BaseService): 0o644, ), f"share/icons/hicolor/256x256/apps/{UI_ICON_FILENAME}": ( - self.user_home / LOCAL_SHARE / "icons" / "hicolor" / "256x256" / "apps" / UI_ICON_FILENAME, + self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME, 0o644, ), } @@ -124,35 +118,26 @@ class InstallationService(BaseService): if temporary_path is not None: temporary_path.unlink(missing_ok=True) raise - missing = sorted(set(destinations) - found) if missing: raise OSError("Archive is missing required files: " + ", ".join(missing)) def _prepare_config(self) -> ProfileData: if self.config_file_path.exists(): - content = self.config_file_path.read_text(encoding="utf-8") - legacy = ConfigurationManager.is_legacy_v1(content) - profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - if legacy: - backup_path = self.config_file_path.with_name(f"{self.config_file_path.name}.v1.bak") - if not backup_path.exists(): - self._write_file(backup_path, content, 0o644) + profile_data = ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) else: - default = dict(ConfigurationManager.get_defaults()) + defaults = ConfigurationManager.get_defaults() profile_data = ProfileData( profiles={}, - global_config={ - "dll": default.get("dll", ""), - "no_fp16": default.get("no_fp16", False), - }, + global_config={"dll": defaults["dll"], "no_fp16": defaults["no_fp16"]}, ) - self._resolve_dll_path(profile_data) - defaults = dict(ConfigurationManager.get_defaults()) - for profile_name, raw_profile in list(profile_data["profiles"].items()): - profile_data["profiles"][profile_name] = ConfigurationManager.validate_config( - {**defaults, **raw_profile, **profile_data["global_config"]} + defaults = ConfigurationManager.get_defaults() + for name, profile in profile_data["profiles"].items(): + profile_data["profiles"][name] = ConfigurationManager.validate_config( + {**defaults, **profile, **profile_data["global_config"]} ) return profile_data @@ -160,7 +145,6 @@ class InstallationService(BaseService): current_path = str(profile_data["global_config"].get("dll") or "") if current_path and Path(current_path).is_file(): return False - dll_path = self.steam_service.find_lsfg_vk_dll() if dll_path and current_path != dll_path: profile_data["global_config"]["dll"] = dll_path @@ -179,7 +163,6 @@ class InstallationService(BaseService): except Exception as error: installed = False installation_error = str(error) - lossless_scaling = self.runtime_service.check_lossless_scaling() return { "installed": installed, @@ -197,21 +180,22 @@ class InstallationService(BaseService): def uninstall(self) -> UninstallationResponse: try: - removed = [] - for path in ( - self.lib_file, - self.lib_x86_file, - 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, - ): - if self._remove_if_exists(path): - removed.append(str(path)) + removed = [ + str(path) + for path in ( + self.lib_file, + self.lib_x86_file, + 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, + ) + if self._remove_if_exists(path) + ] if not removed: return self._success_response( UninstallationResponse, @@ -227,7 +211,6 @@ class InstallationService(BaseService): return self._error_response( UninstallationResponse, str(error), - message="", removed_files=None, ) diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index c576cc2..071b19b 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -1,34 +1,18 @@ -""" -Main plugin class for the lsfg-vk Decky Loader plugin. - -This plugin provides services for installing and managing the lsfg-vk -Vulkan layer for frame generation on Steam Deck. -""" - import os from typing import Any, Dict, Optional import decky -from .installation import InstallationService from .configuration import ConfigurationService from .flatpak_service import FlatpakService +from .installation import InstallationService from .runtime_service import RuntimeService from .steam_service import SteamService from .wrapper_service import WrapperService class Plugin: - """ - Main plugin class for lsfg-vk management. - - This class provides a unified interface for installation, configuration, - 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.runtime_service = RuntimeService() self.steam_service = SteamService() self.installation_service = InstallationService( @@ -39,63 +23,45 @@ class Plugin: self.flatpak_service = FlatpakService() self.wrapper_service = WrapperService() - async def install_lsfg_vk(self) -> Dict[str, Any]: - """Install the bundled lsfg-vk runtime to ~/.local - - Returns: - InstallationResponse dict with success status and message/error - """ + async def install_lsfg_vk(self): return self.installation_service.install() - async def check_lsfg_vk_installed(self) -> Dict[str, Any]: - """Check if lsfg-vk is already installed - - Returns: - InstallationCheckResponse dict with installation status and paths - """ + async def check_lsfg_vk_installed(self): return self.installation_service.check_installation() - async def uninstall_lsfg_vk(self) -> Dict[str, Any]: - """Uninstall lsfg-vk by removing the installed files - - Returns: - UninstallationResponse dict with success status and removed files - """ + async def uninstall_lsfg_vk(self): return self.installation_service.uninstall() - async def get_game_configs(self) -> Dict[str, Any]: + async def get_game_configs(self): return self.configuration_service.get_game_configs() - async def get_installed_games(self) -> Dict[str, Any]: + async def get_installed_games(self): result = self.steam_service.get_installed_games() if not result.get("success"): return result - - support_cache: Dict[str, Dict[str, Any]] = {} + 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": + transport = game.get("transport", {}) + if transport.get("kind") != "flatpak": continue - flatpak_app_id = transport.get("flatpakAppId") - if not isinstance(flatpak_app_id, str) or not flatpak_app_id: + app_id = transport.get("flatpakAppId") + if not 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] + if app_id not in cache: + cache[app_id] = self.flatpak_service.resolve_app_support(app_id) + game["flatpakSupport"] = cache[app_id] return result - async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]: + async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]): return self.configuration_service.update_game_config(appid, game_name, config) - async def reset_game_config(self, appid: str) -> Dict[str, Any]: + async def reset_game_config(self, appid: str): return self.configuration_service.reset_game_config(appid) - async def reset_all_game_configs(self) -> Dict[str, Any]: + async def reset_all_game_configs(self): return self.configuration_service.reset_all_game_configs() - async def get_workaround_state(self, appid: str) -> Dict[str, Any]: + async def get_workaround_state(self, appid: str): return self.wrapper_service.get(appid) async def set_workaround_state( @@ -105,7 +71,7 @@ class Plugin: 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, @@ -114,118 +80,82 @@ class Plugin: transport, ) - async def remove_workaround_state(self, appid: str) -> Dict[str, Any]: + async def remove_workaround_state(self, appid: str): return self.wrapper_service.remove(appid) - async def get_config_file_content(self) -> Dict[str, Any]: - """Get the current config file content - - Returns: - Dict containing the config file content or error message - """ + async def get_config_file_content(self): + path = self.configuration_service.config_file_path try: - config_path = self.configuration_service.config_file_path - if not config_path.exists(): + if not path.exists(): return { "success": False, "content": None, - "path": str(config_path), - "error": "Config file does not exist" + "path": str(path), + "error": "Config file does not exist", } - - content = config_path.read_text(encoding='utf-8') return { "success": True, - "content": content, - "path": str(config_path), - "error": None + "content": path.read_text(encoding="utf-8"), + "path": str(path), + "error": None, } - except Exception as e: + except Exception as error: return { "success": False, "content": None, - "path": str(config_path) if 'config_path' in locals() else "unknown", - "error": f"Error reading config file: {str(e)}" + "path": str(path), + "error": f"Error reading config file: {error}", } - async def get_lossless_scaling_branch_status(self) -> Dict[str, Any]: + async def get_lossless_scaling_branch_status(self): return self.steam_service.get_branch_status() - async def get_flatpak_support_status(self) -> Dict[str, Any]: + async def get_flatpak_support_status(self): return self.flatpak_service.get_flatpak_support_status() - async def ensure_flatpak_support(self, flatpak_app_id: str) -> Dict[str, Any]: + async def ensure_flatpak_support(self, flatpak_app_id: str): return self.flatpak_service.ensure_app_support(flatpak_app_id) - async def repair_flatpak_support(self, flatpak_app_id: str) -> Dict[str, Any]: + async def repair_flatpak_support(self, flatpak_app_id: str): return self.flatpak_service.ensure_app_support(flatpak_app_id) - async def set_flatpak_extension_enabled(self, version: str, enabled: bool) -> Dict[str, Any]: + async def set_flatpak_extension_enabled(self, version: str, enabled: bool): return self.flatpak_service.set_extension_enabled(version, enabled) - + async def _main(self): - """ - Main entry point for the plugin. - - This method is called by Decky Loader when the plugin is loaded. - Any initialization code should go here. - """ repair = self.wrapper_service.repair() if not repair.get("success"): decky.logger.error(f"Could not repair lsfg workaround wrapper: {repair.get('error')}") decky.logger.info("decky-lsfg-vk plugin loaded") async def _unload(self): - """ - Cleanup tasks when the plugin is unloaded. - - This method is called by Decky Loader when the plugin is being unloaded. - Any cleanup code should go here. - """ decky.logger.info("decky-lsfg-vk plugin unloaded") async def _uninstall(self): - """ - Called when the plugin is uninstalled. - - This method is called by Decky Loader when the plugin is being uninstalled. - Performs cleanup of plugin files and flatpak extensions. - """ decky.logger.info("decky-lsfg-vk plugin being uninstalled") - - # Clean up lsfg-vk files when the plugin is uninstalled - # Launch integrations are removed with their profiles. Keep the - # generated pass-through wrapper if it is still referenced elsewhere; - # InstallationService only removes files owned by the runtime bundle. self.installation_service.cleanup_on_uninstall() - try: 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}") - decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed") async def _migration(self): - """ - Migrations that should be performed before entering `_main()`. - - This method is called by Decky Loader for plugin migrations. - Currently migrates logs, settings, and runtime data from old locations. - """ decky.logger.info("Running decky-lsfg-vk plugin migrations") - - decky.migrate_logs(os.path.join(decky.DECKY_USER_HOME, - ".config", "decky-lossless-scaling-vk", "lossless-scaling-vk.log")) - + decky.migrate_logs(os.path.join( + decky.DECKY_USER_HOME, + ".config", + "decky-lossless-scaling-vk", + "lossless-scaling-vk.log", + )) decky.migrate_settings( os.path.join(decky.DECKY_HOME, "settings", "lossless-scaling-vk.json"), - os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk")) - + os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk"), + ) decky.migrate_runtime( os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"), - os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk")) - + os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk"), + ) decky.logger.info("decky-lsfg-vk plugin migrations completed") diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 50d722b..201441e 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -10,7 +10,6 @@ from .constants import ( WRAPPER_FILENAME, ) - _FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$") _WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" @@ -25,106 +24,88 @@ def _split_command(value: Optional[str]) -> Optional[list[str]]: def _is_managed_wrapper(value: str) -> bool: - """Recognize the wrapper Target while keeping arbitrary launchers as host games.""" if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}: return True path = Path(value) return path.is_absolute() and path.name == WRAPPER_FILENAME -def classify_shortcut_transport( - executable: Optional[str], - launch_options: Optional[str] = None, -) -> Dict[str, object]: - """Classify direct Flatpak invocations, including the managed wrapper Target.""" +def classify_shortcut_transport(executable: Optional[str], launch_options: Optional[str] = None) -> Dict[str, object]: 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"} - direct_flatpak = executable_tokens[0] == "/usr/bin/flatpak" managed_wrapper = len(executable_tokens) == 1 and _is_managed_wrapper(executable_tokens[0]) if not direct_flatpak and not managed_wrapper: 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 == "--": + if argument == "--" or argument.startswith("-"): continue - if argument.startswith("-"): - continue - if _FLATPAK_APP_ID.fullmatch(argument): - return {"kind": "flatpak", "flatpakAppId": argument} - return {"kind": "host"} + return ( + {"kind": "flatpak", "flatpakAppId": argument} + if _FLATPAK_APP_ID.fullmatch(argument) + else {"kind": "host"} + ) return {"kind": "host"} +def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: + return next((values[key] for key in keys if isinstance(values.get(key), str)), None) + + class SteamService(BaseService): DEFAULT_BRANCH = "public" MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" - # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG. GAME_SELECTOR_EXCLUDED_APPIDS = { - "858280", # Proton 3.7 - "961940", # Proton 3.16 - "1054830", # Proton 4.2 - "1113280", # Proton 4.11 - "1245040", # Proton 5.0 - "1420170", # Proton 5.13 - "1493710", # Proton Experimental - "1580130", # Proton 6.3 - "1887720", # Proton 7 - "2180100", # Proton Hotfix - "228980", # Steamworks Common Redistributables - "2348590", # Proton 8 - "2805730", # Proton 9 - "3029110", # Lepton - "3127680", # fex - "3658110", # Proton 10 - "4183110", # Steam Linux Runtime 4.0 - "4185400", # Steam Linux Runtime 4.0 for arm64 - "4427310", # Proton Experimental (ARM64) - "4628710", # Proton 11 / Proton Next - "4628740", # Proton 11 (ARM64) - "4690330", # Legacy Steam Runtime - "993090", # Lossless Scaling - "1070560", # Steam Linux Runtime 1.0 - "1391110", # Steam Linux Runtime 2.0 - "1628350", # Steam Linux Runtime 3.0 + "858280", "961940", "1054830", "1113280", "1245040", "1420170", + "1493710", "1580130", "1887720", "2180100", "228980", "2348590", + "2805730", "3029110", "3127680", "3658110", "4183110", "4185400", + "4427310", "4628710", "4628740", "4690330", "993090", "1070560", + "1391110", "1628350", } def _steam_roots(self): - candidates = ( + seen = set() + for candidate in ( self.user_home / ".local/share/Steam", self.user_home / ".steam/steam", self.user_home / ".steam/root", self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", - ) - seen = set() - - for candidate in candidates: + ): yield from self._unique_existing_root(candidate, seen) def _steam_library_roots(self): seen = set() - for candidate in self._steam_roots(): - yield from self._unique_existing_root(candidate, seen) - + for root in self._steam_roots(): + yield from self._unique_existing_root(root, seen) for library_file in ( - candidate / "steamapps/libraryfolders.vdf", - candidate / "config/libraryfolders.vdf", + root / "steamapps/libraryfolders.vdf", + root / "config/libraryfolders.vdf", ): try: content = library_file.read_text(encoding="utf-8") except OSError: continue - for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') yield from self._unique_existing_root(Path(path), seen) + @staticmethod + def _unique_existing_root(path: Path, seen: set[str]): + if not path.exists(): + return + try: + resolved = str(path.resolve()) + except OSError: + resolved = str(path) + if resolved not in seen: + seen.add(resolved) + yield path + @staticmethod def _read_shortcuts(data: bytes) -> Dict[str, object]: def read_string(offset: int) -> Tuple[str, int]: @@ -142,16 +123,12 @@ class SteamService(BaseService): value, offset = read_object(offset) elif value_type == 1: value, offset = read_string(offset) - elif value_type == 2: - if offset + 4 > len(data): + elif value_type in (2, 7): + width = 4 if value_type == 2 else 8 + if offset + width > len(data): raise ValueError("truncated binary VDF integer") - value = int.from_bytes(data[offset:offset + 4], "little", signed=True) - offset += 4 - elif value_type == 7: - if offset + 8 > len(data): - raise ValueError("truncated binary VDF 64-bit integer") - value = int.from_bytes(data[offset:offset + 8], "little", signed=True) - offset += 8 + value = int.from_bytes(data[offset:offset + width], "little", signed=True) + offset += width else: raise ValueError(f"unsupported binary VDF type {value_type}") values[key] = value @@ -170,53 +147,28 @@ 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 - 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, - ) + executable = _first_string(shortcut, "Exe", "exe", "executable") + arguments = _first_string(shortcut, "LaunchOptions", "launchoptions", "launch_options", "arguments") + start_dir = _first_string(shortcut, "StartDir", "startdir", "start_dir") game: Dict[str, object] = { - "appid": str(appid & 0xffffffff), + "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "transport": classify_shortcut_transport(executable, launch_options), + "transport": classify_shortcut_transport(executable, arguments), } - 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 + for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): + if value is not None: + game[key] = value return game def _shortcut_games(self): games = {} - for steam_root in self._steam_roots(): - for shortcuts_file in sorted((steam_root / "userdata").glob("*/config/shortcuts.vdf")): + for root in self._steam_roots(): + for path in sorted((root / "userdata").glob("*/config/shortcuts.vdf")): try: - root = self._read_shortcuts(shortcuts_file.read_bytes()) + shortcuts = self._read_shortcuts(path.read_bytes()).get("shortcuts", {}) except (OSError, ValueError): continue - shortcuts = root.get("shortcuts", {}) if not isinstance(shortcuts, dict): continue for shortcut in shortcuts.values(): @@ -225,25 +177,12 @@ class SteamService(BaseService): games.setdefault(game["appid"], game) return list(games.values()) - @staticmethod - def _unique_existing_root(path: Path, seen: set[str]): - if not path.exists(): - return - try: - resolved = str(path.resolve()) - except OSError: - resolved = str(path) - if resolved in seen: - return - seen.add(resolved) - yield path - def _manifest_path(self) -> Optional[Path]: - for library_root in self._steam_library_roots(): - manifest = library_root / "steamapps" / self.MANIFEST_FILENAME - if manifest.is_file(): - return manifest - return None + return next(( + path + for root in self._steam_library_roots() + if (path := root / "steamapps" / self.MANIFEST_FILENAME).is_file() + ), None) @staticmethod def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]: @@ -253,7 +192,6 @@ class SteamService(BaseService): ) if section is None: return None - depth = 1 in_string = False escaped = False @@ -266,9 +204,7 @@ class SteamService(BaseService): escaped = True elif character == '"': in_string = False - continue - - if character == '"': + elif character == '"': in_string = True elif character == "{": depth += 1 @@ -283,98 +219,82 @@ class SteamService(BaseService): bounds = cls._section_bounds(content, section_name) if bounds is None: return None - body_start, body_end, _ = bounds - pattern = re.compile( - r'(?m)^[ \t]*"(?P[^"]+)"[ \t]+"(?P(?:\\.|[^"\\])*)"' - ) - for match in pattern.finditer(content, body_start, body_end): - if match.group("key") == key: - return match.group("value") - return None + start, end, _ = bounds + pattern = re.compile(r'(?m)^[ \t]*"(?P[^"]+)"[ \t]+"(?P(?:\\.|[^"\\])*)"') + return next(( + match.group("value") + for match in pattern.finditer(content, start, end) + if match.group("key") == key + ), None) @classmethod def _branch_or_default(cls, branch: Optional[str]) -> str: return branch or cls.DEFAULT_BRANCH def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]: - selected_branch = self._branch_or_default( - self._section_value(content, "UserConfig", "BetaKey") - ) - current_branch = self._branch_or_default( + selected = self._branch_or_default(self._section_value(content, "UserConfig", "BetaKey")) + current = self._branch_or_default( self._section_value(content, "MountedConfig", "BetaKey") or self._section_value(content, "UserConfig", "BetaKey") ) - needs_switch = ( - selected_branch != STEAM_LOSSLESS_SCALING_BRANCH - or current_branch != STEAM_LOSSLESS_SCALING_BRANCH - ) + needs_switch = selected != STEAM_LOSSLESS_SCALING_BRANCH or current != STEAM_LOSSLESS_SCALING_BRANCH return { "installed": True, "manifest_path": str(manifest_path), - "selected_branch": selected_branch, - "current_branch": current_branch, + "selected_branch": selected, + "current_branch": current, "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, "needs_switch": needs_switch, - "restart_required": ( - selected_branch == STEAM_LOSSLESS_SCALING_BRANCH - and current_branch != STEAM_LOSSLESS_SCALING_BRANCH - ), + "restart_required": selected == STEAM_LOSSLESS_SCALING_BRANCH and current != STEAM_LOSSLESS_SCALING_BRANCH, + } + + @staticmethod + def _missing_branch_fields() -> Dict[str, object]: + return { + "installed": False, + "manifest_path": None, + "selected_branch": None, + "current_branch": None, + "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, + "needs_switch": False, + "restart_required": False, } def find_lsfg_vk_dll(self) -> Optional[str]: - """Find the branch-specific upstream DLL in any Steam library.""" if self.get_branch_status().get("needs_switch"): return None - for library_root in self._steam_library_roots(): - dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll" - if dll_path.is_file(): - return str(dll_path) - return None + return next(( + str(path) + for root in self._steam_library_roots() + if (path := root / "steamapps/common/Lossless Scaling/lsfg-vk.dll").is_file() + ), None) def get_branch_status(self) -> Dict[str, object]: try: - manifest_path = self._manifest_path() - if manifest_path is None: + manifest = self._manifest_path() + if manifest is None: return self._success_response( dict, "Lossless Scaling is not installed through Steam", - installed=False, - manifest_path=None, - selected_branch=None, - current_branch=None, - target_branch=STEAM_LOSSLESS_SCALING_BRANCH, - needs_switch=False, - restart_required=False, + **self._missing_branch_fields(), ) - - content = manifest_path.read_text(encoding="utf-8") - fields = self._status_fields(manifest_path, content) - if not fields["needs_switch"]: - message = "Lossless Scaling is using the lsfg-vk Steam branch" - elif fields["restart_required"]: - message = "lsfg-vk is selected; restart Steam to finish the branch switch" - else: - message = "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" + fields = self._status_fields(manifest, manifest.read_text(encoding="utf-8")) + message = ( + "Lossless Scaling is using the lsfg-vk Steam branch" + if not fields["needs_switch"] + else "lsfg-vk is selected; restart Steam to finish the branch switch" + if fields["restart_required"] + else "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" + ) return self._success_response(dict, message, **fields) except Exception as error: - return self._error_response( - dict, - str(error), - installed=False, - manifest_path=None, - selected_branch=None, - current_branch=None, - target_branch=STEAM_LOSSLESS_SCALING_BRANCH, - needs_switch=False, - restart_required=False, - ) + return self._error_response(dict, str(error), **self._missing_branch_fields()) def get_installed_games(self) -> Dict[str, object]: - """Return installed Steam app IDs and names for the Game Mode selector.""" try: games: Dict[str, Dict[str, object]] = {} - for library_root in self._steam_library_roots(): - for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"): + for root in self._steam_library_roots(): + for manifest in (root / "steamapps").glob("appmanifest_*.acf"): match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) if not match: continue @@ -385,10 +305,9 @@ class SteamService(BaseService): appid = match.group(1) 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, + "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, "transport": {"kind": "host"}, } diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 7b85708..ce541ec 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -1,40 +1,17 @@ -""" -Type definitions for the lsfg-vk plugin responses. -""" +from typing import List, Optional, TypedDict -from typing import TypedDict, Optional, List - -class BaseResponse(TypedDict): - """Base response structure""" +class InstallationResponse(TypedDict): success: bool - - -class ErrorResponse(BaseResponse): - """Response structure for errors""" - error: str - - -class MessageResponse(BaseResponse): - """Response structure with message""" - message: str - - -class InstallationResponse(BaseResponse): - """Response for installation operations""" message: str error: Optional[str] -class UninstallationResponse(BaseResponse): - """Response for uninstallation operations""" - message: str +class UninstallationResponse(InstallationResponse): removed_files: Optional[List[str]] - error: Optional[str] class InstallationCheckResponse(TypedDict): - """Response for installation check""" installed: bool lossless_scaling_installed: bool lossless_scaling_status: str -- cgit v1.2.3 From 1c520a2f72d0d25fc63a77e01ae07e5733c6773d Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:26:02 -0400 Subject: fix: reset unsupported configs during migration --- py_modules/lsfg_vk/installation.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 5a583f5..a73706c 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -122,17 +122,24 @@ class InstallationService(BaseService): if missing: raise OSError("Archive is missing required files: " + ", ".join(missing)) + def _default_config(self) -> ProfileData: + defaults = ConfigurationManager.get_defaults() + return ProfileData( + profiles={}, + global_config={"dll": defaults["dll"], "no_fp16": defaults["no_fp16"]}, + ) + def _prepare_config(self) -> ProfileData: - if self.config_file_path.exists(): - profile_data = ConfigurationManager.parse_toml_content_multi_profile( - self.config_file_path.read_text(encoding="utf-8") - ) - else: - defaults = ConfigurationManager.get_defaults() - profile_data = ProfileData( - profiles={}, - global_config={"dll": defaults["dll"], "no_fp16": defaults["no_fp16"]}, + try: + profile_data = ( + ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + if self.config_file_path.exists() + else self._default_config() ) + except ValueError: + profile_data = self._default_config() self._resolve_dll_path(profile_data) defaults = ConfigurationManager.get_defaults() for name, profile in profile_data["profiles"].items(): -- cgit v1.2.3 From b197e25b45d53c6c7175a45dd0b14642f2aab198 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 11:51:22 -0400 Subject: fixes for appimage and flatpak --- py_modules/lsfg_vk/flatpak_service.py | 88 +++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 20 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index d2241ab..f486b74 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -24,6 +24,8 @@ from .constants import ( class FlatpakService(BaseService): EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") + DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"} + RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL" OWNERSHIP_FILENAME = "flatpak_extensions.json" OWNERSHIP_VERSION = 1 APP_ID_PATTERN = re.compile( @@ -93,6 +95,26 @@ class FlatpakService(BaseService): raise ValueError(f"Unsupported Flatpak runtime reference: {runtime_ref}") return cls._validate_runtime(parts[2]) + @classmethod + def runtime_branch_from_metadata(cls, metadata: str) -> str: + section = None + versions = [] + for raw_line in metadata.splitlines() if isinstance(metadata, str) else []: + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() + continue + if section != cls.RUNTIME_METADATA_SECTION: + continue + key, separator, value = line.partition("=") + if separator and key.strip() == "versions": + versions.extend(part.strip() for part in value.split(";")) + for value in versions: + for branch in cls.SUPPORTED_RUNTIMES: + if value == branch or value.startswith(f"{branch}-"): + return branch + raise ValueError("Could not determine a supported Freedesktop base runtime from Flatpak metadata") + @classmethod def _extension_ref(cls, branch: str) -> str: return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}" @@ -105,18 +127,22 @@ class FlatpakService(BaseService): }[self._validate_runtime(branch)] return Path(__file__).resolve().parent.parent.parent / BIN_DIR / filename - 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, - ) + def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]: + scopes = ("user", "system") if scope is None else (scope,) + if any(item not in ("user", "system") for item in scopes): + raise ValueError("Flatpak installation scope must be user or system") installed = set() - for line in result.stdout.splitlines(): - fields = line.split("\t") if "\t" in line else line.split() - if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64": - installed.add(fields[2]) + for item in scopes: + result = self._run_flatpak_command( + ["list", f"--{item}", "--runtime", "--columns=application,arch,branch"], + capture_output=True, + text=True, + check=True, + ) + for line in result.stdout.splitlines(): + fields = line.split("\t") if "\t" in line else line.split() + if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64": + installed.add(fields[2]) return installed def _owned_branches(self) -> Set[str]: @@ -188,7 +214,26 @@ class FlatpakService(BaseService): if result.returncode != 0: raise OSError(result.stderr.strip() or f"Could not inspect Flatpak app {app_id}") runtime = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" - return runtime, self.runtime_branch_from_ref(runtime) + parts = runtime.split("/") + if len(parts) != 3: + raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") + if parts[0] == "org.freedesktop.Platform": + branch = self._validate_runtime(parts[2]) + elif parts[0] in self.DERIVED_RUNTIME_IDS: + metadata_result = self._run_flatpak_command( + ["info", "--show-metadata", runtime], + capture_output=True, + text=True, + ) + if metadata_result.returncode != 0: + raise OSError( + metadata_result.stderr.strip() + or f"Could not inspect Flatpak runtime {runtime}" + ) + branch = self.runtime_branch_from_metadata(metadata_result.stdout) + else: + raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") + return runtime, branch def resolve_app_support(self, app_id: str): try: @@ -249,7 +294,7 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Flatpak installation failed") - if branch not in self._installed_extension_branches(): + if branch not in self._installed_extension_branches("user"): raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards") owned = self._owned_branches() owned.add(branch) @@ -259,7 +304,7 @@ class FlatpakService(BaseService): return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False) def _remove_extension(self, branch: str) -> bool: - if branch not in self._installed_extension_branches(): + if branch not in self._installed_extension_branches("user"): return False result = self._run_flatpak_command( ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)], @@ -268,7 +313,7 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Flatpak uninstall failed") - if branch in self._installed_extension_branches(): + if branch in self._installed_extension_branches("user"): raise RuntimeError(f"Flatpak uninstall completed but {self._extension_ref(branch)} is still installed") return True @@ -288,12 +333,15 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - removed = self._remove_extension(branch) owned = self._owned_branches() - if branch in owned: - owned.remove(branch) - self._write_owned_branches(owned) - return self._extension_result(branch, False, removed, "uninstalled") + if branch not in owned: + installed = branch in self._installed_extension_branches() + return self._extension_result(branch, installed, False, "preserved (not plugin-owned)") + removed = self._remove_extension(branch) + owned.remove(branch) + self._write_owned_branches(owned) + installed = branch in self._installed_extension_branches() + return self._extension_result(branch, installed, removed, "uninstalled") except Exception as error: return self._error_response( dict, -- cgit v1.2.3 From 670f36e8cc75da9c8b1b174c24e722657bbf2a56 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 12:15:05 -0400 Subject: cleanup flatpak handles --- py_modules/lsfg_vk/wrapper_service.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py index dae565b..1ed39bc 100644 --- a/py_modules/lsfg_vk/wrapper_service.py +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -119,7 +119,7 @@ class WrapperService(BaseService): } if type(entry["command_token_added"]) is not bool: raise ValueError("command_token_added must be a boolean") - if "shortcut_exe" in raw and raw["shortcut_exe"] is not None: + if entry["transport"]["kind"] == "flatpak" and "shortcut_exe" in raw and raw["shortcut_exe"] is not None: shortcut_exe = raw["shortcut_exe"] if ( not isinstance(shortcut_exe, str) @@ -475,10 +475,11 @@ class WrapperService(BaseService): "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}) - elif previous_entry and "shortcut_exe" in previous_entry: - entry["shortcut_exe"] = previous_entry["shortcut_exe"] + if selected_transport["kind"] == "flatpak": + if shortcut_exe is not None: + entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe}) + elif previous_entry and "shortcut_exe" in previous_entry: + entry["shortcut_exe"] = previous_entry["shortcut_exe"] document["apps"][normalized] = entry self._write_pair(document) return self._response(document, normalized) -- cgit v1.2.3 From 9902135e53be129bd6096d51d5e510ab298c1ae2 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 12:19:40 -0400 Subject: fix: handle bare Flatpak shortcut targets --- py_modules/lsfg_vk/steam_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 201441e..a952fcb 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -35,7 +35,7 @@ def classify_shortcut_transport(executable: Optional[str], launch_options: Optio option_tokens = _split_command(launch_options) if executable_tokens is None or option_tokens is None or not executable_tokens: return {"kind": "host"} - direct_flatpak = executable_tokens[0] == "/usr/bin/flatpak" + direct_flatpak = executable_tokens[0] in {"flatpak", "/usr/bin/flatpak"} managed_wrapper = len(executable_tokens) == 1 and _is_managed_wrapper(executable_tokens[0]) if not direct_flatpak and not managed_wrapper: return {"kind": "host"} -- cgit v1.2.3 From de74f0d2499159ed1cf8f628a166302146ae1f13 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 12:58:44 -0400 Subject: fix flatpak disable leftovers --- py_modules/lsfg_vk/plugin.py | 37 +++++++++++++++++++++++++++++++++++ py_modules/lsfg_vk/wrapper_service.py | 3 +++ 2 files changed, 40 insertions(+) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 071b19b..13df7a4 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -107,6 +107,43 @@ class Plugin: "error": f"Error reading config file: {error}", } + async def get_debug_file_contents(self): + files = ( + ("config", "LSFG-VK configuration", self.configuration_service.config_file_path), + ("workarounds", "Per-app workarounds", self.wrapper_service.sidecar_path), + ("wrapper", "Generated launch wrapper", self.wrapper_service.wrapper_path), + ("flatpak_extensions", "Flatpak extension ownership", self.flatpak_service.ownership_path), + ) + contents = [] + for file_id, label, path in files: + item = { + "id": file_id, + "label": label, + "path": str(path), + "exists": False, + "content": None, + "error": None, + } + try: + if path.is_symlink(): + item["error"] = "Path is a symlink; refusing to read it" + elif not path.exists(): + item["error"] = "File does not exist" + elif not path.is_file(): + item["error"] = "Path is not a regular file" + else: + item["exists"] = True + item["content"] = path.read_text(encoding="utf-8") + except Exception as error: + item["error"] = f"Error reading file: {error}" + contents.append(item) + return { + "success": True, + "message": "Debug file contents retrieved", + "error": None, + "files": contents, + } + async def get_lossless_scaling_branch_status(self): return self.steam_service.get_branch_status() diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py index 1ed39bc..980f7ae 100644 --- a/py_modules/lsfg_vk/wrapper_service.py +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -35,6 +35,8 @@ class WrapperService(BaseService): "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck", + "DISABLE_LSFGVK", + "DISABLE_LSFG", "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE", @@ -262,6 +264,7 @@ class WrapperService(BaseService): self._shell(f"--env=LSFGVK_CONFIG={config_file}"), '"--env=LSFGVK_FLATPAK=1"', '"--env=SteamAppId=$appid"', + '"--unset-env=DISABLE_LSFGVK" "--unset-env=DISABLE_LSFG"', '"--unset-env=DISABLE_GAMESCOPE_WSI"', '"--unset-env=ENABLE_GAMESCOPE_WSI"' if not state["disableGamescopeWsi"] else '"--env=ENABLE_GAMESCOPE_WSI=0"', -- cgit v1.2.3 From 66ca1fdf555f9f17671bcac9d25c909c8aad0e9a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:36:34 -0400 Subject: refactor: decouple flatpak from workaround wrapper --- py_modules/lsfg_vk/wrapper_service.py | 222 +++++----------------------------- 1 file changed, 29 insertions(+), 193 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py index 980f7ae..c476024 100644 --- a/py_modules/lsfg_vk/wrapper_service.py +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -1,5 +1,3 @@ -"""Own the small per-AppID workaround dispatcher used by Steam launches.""" - from __future__ import annotations import json @@ -14,8 +12,6 @@ from .constants import WRAPPER_FILENAME class WrapperService(BaseService): - """Persist workaround state and compile it into a safe POSIX wrapper.""" - LEGACY_FORMAT_VERSION = 1 FORMAT_VERSION = 2 LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1" @@ -85,51 +81,25 @@ class WrapperService(BaseService): raise ValueError(f"{field} must be a boolean") 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 = { + entry: Dict[str, Any] = { "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") - if entry["transport"]["kind"] == "flatpak" and "shortcut_exe" in raw and raw["shortcut_exe"] is not None: - shortcut_exe = raw["shortcut_exe"] + shortcut_exe = raw.get("shortcut_exe") + if shortcut_exe is not None: if ( not isinstance(shortcut_exe, str) or not shortcut_exe.startswith("/") or "\x00" in shortcut_exe - or not shortcut_exe.strip() + or Path(shortcut_exe).name != "flatpak" ): - raise ValueError("shortcut_exe must be an absolute executable path") + raise ValueError("shortcut_exe must be an absolute flatpak executable path") entry["shortcut_exe"] = shortcut_exe return entry @@ -160,11 +130,11 @@ class WrapperService(BaseService): if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file(): raise RuntimeError("Workaround state path is not a regular file") try: - raw = json.loads(self.sidecar_path.read_text(encoding="utf-8")) + content = self.sidecar_path.read_text(encoding="utf-8") + raw = json.loads(content) except (OSError, json.JSONDecodeError) as error: raise RuntimeError(f"Could not read workaround state: {error}") from error - document = self._validate_document(raw) - return document, True, self.sidecar_path.read_text(encoding="utf-8") + return self._validate_document(raw), True, content def _wrapper_marker(self) -> bool: if self.wrapper_path.is_symlink() or not self.wrapper_path.exists(): @@ -181,29 +151,21 @@ class WrapperService(BaseService): if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink(): return False if self.wrapper_path.is_symlink() or not self._wrapper_marker(): - raise RuntimeError( - f"Refusing to replace unowned wrapper at {self.wrapper_path}" - ) + raise RuntimeError(f"Refusing to replace unowned wrapper at {self.wrapper_path}") return True @staticmethod 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)] + def _state_lines(self, state: Dict[str, Any]) -> list[str]: + lines = [" unset " + " ".join(self.MANAGED_ENV_KEYS)] + lines.extend([ + ' SteamAppId="$appid"', + " export SteamAppId", + f" LSFGVK_CONFIG={self._shell(str(self.config_file_path))}", + " export LSFGVK_CONFIG", + ]) if state["disableGamescopeWsi"]: lines.extend([" ENABLE_GAMESCOPE_WSI=0", " export ENABLE_GAMESCOPE_WSI"]) if state["disableHdr"]: @@ -235,73 +197,12 @@ class WrapperService(BaseService): " fi", " export DXVK_CONFIG", ]) - lines.append(f" shortcut_exe={cls._shell(shortcut_exe or '')}") return lines - 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_LSFGVK" "--unset-env=DISABLE_LSFG"', - '"--unset-env=DISABLE_GAMESCOPE_WSI"', - '"--unset-env=ENABLE_GAMESCOPE_WSI"' if not state["disableGamescopeWsi"] else - '"--env=ENABLE_GAMESCOPE_WSI=0"', - '"--unset-env=DXVK_HDR"' if not state["disableHdr"] else - '"--env=DXVK_HDR=0"', - '"--unset-env=SteamDeck"' if not state["disableSteamdeckMode"] else - '"--env=SteamDeck=0"', - '"--unset-env=DISABLE_VKBASALT" "--unset-env=ENABLE_VKBASALT"', - ] - if state["disableVkbasalt"]: - args.append('"--env=DISABLE_VKBASALT=1"') - args.extend([ - '"--unset-env=MESA_LOADER_DRIVER_OVERRIDE" "--unset-env=__GLX_VENDOR_LIBRARY_NAME" "--unset-env=GALLIUM_DRIVER"', - ]) - if state["enableZink"]: - args.extend([ - '"--env=__GLX_VENDOR_LIBRARY_NAME=mesa"', - '"--env=MESA_LOADER_DRIVER_OVERRIDE=zink"', - '"--env=GALLIUM_DRIVER=zink"', - ]) - args.extend([ - '"--unset-env=DXVK_FRAME_RATE"', - ]) - static_args = " ".join(args) - return [ - ' if [ -n "${DXVK_CONFIG+x}" ]; then', - f' set -- "$flatpak_command" {static_args} "--env=DXVK_CONFIG=$DXVK_CONFIG" "$@"', - " else", - f' set -- "$flatpak_command" {static_args} "$@"', - " fi", - ] - def _render_wrapper(self, document: Dict[str, Any]) -> str: lines = [ "#!/bin/sh", self.MARKER, - "# Generated by Decky LSFG-VK; edits will be rejected on the next update.", "", "appid=", 'case "${SteamAppId-}" in', @@ -320,77 +221,25 @@ class WrapperService(BaseService): ' *) appid="${STEAM_COMPAT_APP_ID}" ;;', " esac", "fi", - "shortcut_exe=", 'case "$appid" in', ] for appid in sorted(document["apps"], key=lambda value: int(value)): - entry = document["apps"][appid] lines.append(f" {appid})") - lines.extend(self._state_lines(entry["state"], entry.get("shortcut_exe"))) + lines.extend(self._state_lines(document["apps"][appid]["state"])) lines.append(" ;;") lines.extend([ "esac", - "", - 'if [ -n "$shortcut_exe" ]; then', - ]) - # 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] - 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([ - *( - [ - 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", - ' exec "$shortcut_exe" "$@"', - "fi", 'exec "$@"', "", ]) return "\n".join(lines) def _write_document(self, document: Dict[str, Any]) -> None: - content = json.dumps(document, indent=2, sort_keys=True) + "\n" - self._write_file(self.sidecar_path, content, 0o644) + self._write_file( + self.sidecar_path, + json.dumps(document, indent=2, sort_keys=True) + "\n", + 0o644, + ) def _write_pair(self, document: Dict[str, Any]) -> None: old_sidecar_exists = self.sidecar_path.exists() @@ -426,7 +275,6 @@ 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]: @@ -453,7 +301,6 @@ 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) @@ -464,25 +311,15 @@ 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, + "command_token_added": command_token_added, } - if selected_transport["kind"] == "flatpak": - if shortcut_exe is not None: - entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe}) - elif previous_entry and "shortcut_exe" in previous_entry: - entry["shortcut_exe"] = previous_entry["shortcut_exe"] + selected_exe = shortcut_exe + if selected_exe is None and previous_entry: + selected_exe = previous_entry.get("shortcut_exe") + if selected_exe is not None: + entry = self._validate_entry({**entry, "shortcut_exe": selected_exe}) document["apps"][normalized] = entry self._write_pair(document) return self._response(document, normalized) @@ -520,7 +357,6 @@ class WrapperService(BaseService): } def repair(self) -> Dict[str, Any]: - """Regenerate a missing owned wrapper without importing old global state.""" try: with self._lock: document, _, _ = self._read_document() -- cgit v1.2.3 From c9d1c1980b415c3710f3e049cfcbd6a0cd90977a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:37:05 -0400 Subject: refactor: reduce flatpak shortcut detection --- py_modules/lsfg_vk/steam_service.py | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index a952fcb..df6e3a2 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -10,7 +10,6 @@ from .constants import ( WRAPPER_FILENAME, ) -_FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$") _WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" @@ -30,27 +29,17 @@ def _is_managed_wrapper(value: str) -> bool: return path.is_absolute() and path.name == WRAPPER_FILENAME -def classify_shortcut_transport(executable: Optional[str], launch_options: Optional[str] = None) -> Dict[str, object]: - 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"} - direct_flatpak = executable_tokens[0] in {"flatpak", "/usr/bin/flatpak"} - managed_wrapper = len(executable_tokens) == 1 and _is_managed_wrapper(executable_tokens[0]) - if not direct_flatpak and not managed_wrapper: - 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 == "--" or argument.startswith("-"): - continue - return ( - {"kind": "flatpak", "flatpakAppId": argument} - if _FLATPAK_APP_ID.fullmatch(argument) - else {"kind": "host"} - ) - return {"kind": "host"} +def is_direct_flatpak_shortcut(executable: Optional[str]) -> bool: + tokens = _split_command(executable) + if not tokens: + return False + if tokens[0] in {"flatpak", "/usr/bin/flatpak"}: + return True + return ( + len(tokens) == 2 + and _is_managed_wrapper(tokens[0]) + and tokens[1] == "/usr/bin/flatpak" + ) def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: @@ -154,7 +143,7 @@ class SteamService(BaseService): "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "transport": classify_shortcut_transport(executable, arguments), + "directFlatpak": is_direct_flatpak_shortcut(executable), } for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): if value is not None: @@ -309,7 +298,7 @@ class SteamService(BaseService): "appid": appid, "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, - "transport": {"kind": "host"}, + "directFlatpak": False, } for game in self._shortcut_games(): games.setdefault(str(game["appid"]), game) -- cgit v1.2.3 From 6991f81c9522ff61059f4cc8c84527f20b860032 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:38:19 -0400 Subject: refactor: make flatpak support explicit per app --- py_modules/lsfg_vk/flatpak_service.py | 535 +++++++++++++++++++++++----------- 1 file changed, 365 insertions(+), 170 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index f486b74..62f6a50 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,7 +1,6 @@ -"""Flatpak runtime support for classified Steam targets.""" - from __future__ import annotations +import hashlib import json import os import pwd @@ -26,8 +25,8 @@ class FlatpakService(BaseService): SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"} RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL" - OWNERSHIP_FILENAME = "flatpak_extensions.json" - OWNERSHIP_VERSION = 1 + OWNERSHIP_FILENAME = "flatpak_state.json" + OWNERSHIP_VERSION = 2 APP_ID_PATTERN = re.compile( r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$" ) @@ -41,6 +40,10 @@ class FlatpakService(BaseService): def ownership_path(self) -> Path: return self.config_dir / self.OWNERSHIP_FILENAME + @property + def backup_dir(self) -> Path: + return self.config_dir / "flatpak-overrides" + def _clean_env(self) -> Dict[str, str]: env = os.environ.copy() env.pop("LD_LIBRARY_PATH", None) @@ -88,13 +91,6 @@ class FlatpakService(BaseService): ) return branch - @classmethod - def runtime_branch_from_ref(cls, runtime_ref: str) -> str: - parts = runtime_ref.strip().split("/") if isinstance(runtime_ref, str) else [] - if len(parts) != 3 or parts[0] != "org.freedesktop.Platform": - raise ValueError(f"Unsupported Flatpak runtime reference: {runtime_ref}") - return cls._validate_runtime(parts[2]) - @classmethod def runtime_branch_from_metadata(cls, metadata: str) -> str: section = None @@ -129,8 +125,6 @@ class FlatpakService(BaseService): def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]: scopes = ("user", "system") if scope is None else (scope,) - if any(item not in ("user", "system") for item in scopes): - raise ValueError("Flatpak installation scope must be user or system") installed = set() for item in scopes: result = self._run_flatpak_command( @@ -145,64 +139,78 @@ class FlatpakService(BaseService): installed.add(fields[2]) return installed - def _owned_branches(self) -> Set[str]: - path = self.ownership_path - if not path.exists() and not path.is_symlink(): - return set() - if path.is_symlink() or not path.is_file(): + def _empty_state(self) -> Dict[str, object]: + return { + "version": self.OWNERSHIP_VERSION, + "plugin_owned_branches": [], + "prepared_apps": {}, + } + + def _read_state(self) -> Dict[str, object]: + if not self.ownership_path.exists(): + return self._empty_state() + if self.ownership_path.is_symlink() or not self.ownership_path.is_file(): raise RuntimeError("Flatpak ownership metadata is not a regular file") try: - data = json.loads(path.read_text(encoding="utf-8")) - branches = data.get("plugin_owned_branches") - if data.get("version") != self.OWNERSHIP_VERSION or not isinstance(branches, list): - raise ValueError("invalid ownership metadata") - owned = {self._validate_runtime(branch) for branch in branches} - if len(owned) != len(branches): - raise ValueError("invalid ownership metadata") - return owned - except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: - raise RuntimeError(f"Could not trust Flatpak ownership metadata: {error}") from error - - def _write_owned_branches(self, branches: Set[str]) -> None: - if not branches: + data = json.loads(self.ownership_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"Could not read Flatpak ownership metadata: {error}") from error + if data.get("version") != self.OWNERSHIP_VERSION: + raise RuntimeError("Unsupported Flatpak ownership metadata version") + branches = data.get("plugin_owned_branches") + apps = data.get("prepared_apps") + if not isinstance(branches, list) or not isinstance(apps, dict): + raise RuntimeError("Invalid Flatpak ownership metadata") + for branch in branches: + self._validate_runtime(branch) + for app_id, entry in apps.items(): + self._validate_app_id(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Invalid Flatpak app ownership metadata") + if type(entry.get("override_existed")) is not bool: + raise RuntimeError("Invalid Flatpak app ownership metadata") + if not isinstance(entry.get("managed_sha256"), str): + raise RuntimeError("Invalid Flatpak app ownership metadata") + return data + + def _write_state(self, state: Dict[str, object]) -> None: + branches = state.get("plugin_owned_branches", []) + apps = state.get("prepared_apps", {}) + if not branches and not apps: self.ownership_path.unlink(missing_ok=True) + if self.backup_dir.exists() and not any(self.backup_dir.iterdir()): + self.backup_dir.rmdir() return self._write_file( self.ownership_path, - json.dumps( - { - "version": self.OWNERSHIP_VERSION, - "plugin_owned_branches": sorted(branches), - }, - indent=2, - ) + "\n", + json.dumps(state, indent=2, sort_keys=True) + "\n", ) - def get_extension_status(self): - try: - available = self.check_flatpak_available() - installed = self._installed_extension_branches() if available else set() - return self._success_response( - dict, - "Flatpak runtime extension status retrieved" if available else "Flatpak is not available", - available=available, - extension_id=self.EXTENSION_ID, - supported_branches=list(self.SUPPORTED_RUNTIMES), - installed_branches=sorted(installed), - ) - except Exception as error: - return self._error_response( - dict, - str(error), - available=False, - extension_id=self.EXTENSION_ID, - supported_branches=list(self.SUPPORTED_RUNTIMES), - installed_branches=[], - ) + def _owned_branches(self, state: Optional[Dict[str, object]] = None) -> Set[str]: + current = state if state is not None else self._read_state() + return {self._validate_runtime(branch) for branch in current["plugin_owned_branches"]} - get_flatpak_support_status = get_extension_status + def _override_path(self, app_id: str) -> Path: + return self.user_home / ".local/share/flatpak/overrides" / self._validate_app_id(app_id) + + def _backup_path(self, app_id: str) -> Path: + return self.backup_dir / f"{self._validate_app_id(app_id)}.ini" + + @staticmethod + def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]: + path = self._override_path(app_id) + if path.is_symlink(): + raise RuntimeError("Flatpak override path is a symlink") + if not path.exists(): + return False, b"" + if not path.is_file(): + raise RuntimeError("Flatpak override path is not a regular file") + return True, path.read_bytes() - def _resolve_runtime(self, app_id: str): + def _resolve_runtime(self, app_id: str) -> tuple[str, str]: self._validate_app_id(app_id) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") @@ -218,72 +226,126 @@ class FlatpakService(BaseService): if len(parts) != 3: raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") if parts[0] == "org.freedesktop.Platform": - branch = self._validate_runtime(parts[2]) - elif parts[0] in self.DERIVED_RUNTIME_IDS: - metadata_result = self._run_flatpak_command( - ["info", "--show-metadata", runtime], - capture_output=True, - text=True, - ) - if metadata_result.returncode != 0: - raise OSError( - metadata_result.stderr.strip() - or f"Could not inspect Flatpak runtime {runtime}" - ) - branch = self.runtime_branch_from_metadata(metadata_result.stdout) - else: + return runtime, self._validate_runtime(parts[2]) + if parts[0] not in self.DERIVED_RUNTIME_IDS: raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") - return runtime, branch + metadata_result = self._run_flatpak_command( + ["info", "--show-metadata", runtime], + capture_output=True, + text=True, + ) + if metadata_result.returncode != 0: + raise OSError(metadata_result.stderr.strip() or f"Could not inspect Flatpak runtime {runtime}") + return runtime, self.runtime_branch_from_metadata(metadata_result.stdout) + + 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 resolve_app_support(self, app_id: str): + def _filesystem_present(self, entries: str, host_path: Path) -> bool: + accepted = {str(host_path)} try: - app_id = self._validate_app_id(app_id) - runtime, branch = self._resolve_runtime(app_id) - installed = self._installed_extension_branches() - ready = branch in installed + accepted.add(f"~/{host_path.relative_to(self.user_home).as_posix()}") + except ValueError: + pass + enabled = False + for raw in entries.split(";"): + value = raw.strip() + if not value: + continue + denied = value.startswith("!") + path = value[1:] if denied else value + path = path.split(":", 1)[0] + if path in accepted: + if denied: + return False + enabled = True + return enabled + + def _app_override_status(self, app_id: str) -> Dict[str, object]: + result = self._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + output = result.stdout if result.returncode == 0 else "" + section = None + filesystems = "" + unset_environment = set() + environment = {} + for raw_line in output.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + key, separator, value = line.partition("=") + if not separator: + continue + if section == "Context" and key == "filesystems": + filesystems = value + elif section == "Context" and key == "unset-environment": + unset_environment.update(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + config_ready = self._filesystem_present(filesystems, self.config_dir) + dll_ready = self._filesystem_present(filesystems, self._dll_directory()) + env_ready = ( + environment.get("LSFGVK_CONFIG") == str(self.config_file_path) + and environment.get("LSFGVK_FLATPAK") == "1" + and "DISABLE_LSFGVK" in unset_environment + and "DISABLE_LSFG" in unset_environment + ) + return { + "filesystem_ready": config_ready and dll_ready, + "environment_ready": env_ready, + "prepared": config_ready and dll_ready and env_ready, + } + + def get_extension_status(self): + try: + available = self.check_flatpak_available() + installed = self._installed_extension_branches() if available else set() 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=runtime, - runtime_branch=branch, - support_status="ready" if ready else "needs-runtime", - extension_installed=ready, + "Flatpak runtime extension status retrieved" if available else "Flatpak is not available", + available=available, + extension_id=self.EXTENSION_ID, + supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=sorted(installed), ) - except ValueError as error: - return self._success_response( - 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( dict, str(error), - flatpak_app_id=app_id, - runtime=None, - runtime_branch=None, - support_status="error", - extension_installed=False, + available=False, + extension_id=self.EXTENSION_ID, + supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=[], ) + get_flatpak_support_status = get_extension_status + def install_extension(self, branch: str): try: branch = self._validate_runtime(branch) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - if branch in self._installed_extension_branches(): - return self._extension_result(branch, True, False, "already installed") + installed = self._installed_extension_branches() + if branch in 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") @@ -296,9 +358,11 @@ class FlatpakService(BaseService): raise OSError(result.stderr.strip() or "Flatpak installation failed") if branch not in self._installed_extension_branches("user"): raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards") - owned = self._owned_branches() + state = self._read_state() + owned = self._owned_branches(state) owned.add(branch) - self._write_owned_branches(owned) + state["plugin_owned_branches"] = sorted(owned) + self._write_state(state) 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) @@ -313,8 +377,6 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Flatpak uninstall failed") - if branch in self._installed_extension_branches("user"): - raise RuntimeError(f"Flatpak uninstall completed but {self._extension_ref(branch)} is still installed") return True def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str): @@ -333,24 +395,19 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - owned = self._owned_branches() + state = self._read_state() + owned = self._owned_branches(state) if branch not in owned: installed = branch in self._installed_extension_branches() return self._extension_result(branch, installed, False, "preserved (not plugin-owned)") removed = self._remove_extension(branch) owned.remove(branch) - self._write_owned_branches(owned) + state["plugin_owned_branches"] = sorted(owned) + self._write_state(state) installed = branch in self._installed_extension_branches() return self._extension_result(branch, installed, removed, "uninstalled") except Exception as error: - return self._error_response( - dict, - str(error), - runtime_branch=branch, - removed=False, - installed=False, - enabled=False, - ) + return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False) def ensure_extension(self, branch: str): try: @@ -358,74 +415,212 @@ class FlatpakService(BaseService): 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, support_status="error") + return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False) return self.install_extension(branch) - def ensure_app_support(self, app_id: str): - resolved = self.resolve_app_support(app_id) - if not resolved.get("success") or resolved.get("support_status") != "needs-runtime": - return resolved - result = self.ensure_extension(resolved["runtime_branch"]) - if not result.get("success"): - return self._error_response( - dict, - result.get("error") or "Could not install the required Flatpak runtime extension", - flatpak_app_id=app_id, - runtime=resolved.get("runtime"), - runtime_branch=resolved.get("runtime_branch"), - support_status="error", - extension_installed=False, - ) - return self.resolve_app_support(app_id) - def set_extension_enabled(self, branch: str, enabled: bool): if type(enabled) is not bool: return self._error_response(dict, "enabled must be a boolean", runtime_branch=branch, installed=False, enabled=False) return self.install_extension(branch) if enabled else self.uninstall_extension(branch) - def remove_plugin_owned_extensions(self): + def get_flatpak_apps(self): + try: + if not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak is not available on this system") + installed_extensions = self._installed_extension_branches() + state = self._read_state() + owned_apps = state["prepared_apps"] + result = self._run_flatpak_command( + ["list", "--app", "--columns=name,application"], + capture_output=True, + text=True, + check=True, + ) + apps = [] + for line in result.stdout.splitlines(): + fields = line.split("\t") + if len(fields) < 2: + continue + name, app_id = fields[0].strip(), fields[1].strip() + if not app_id: + continue + item = { + "app_id": app_id, + "app_name": name or app_id, + "runtime": None, + "runtime_branch": None, + "runtime_ready": False, + "prepared": False, + "owned": app_id in owned_apps, + "error": None, + } + try: + runtime, branch = self._resolve_runtime(app_id) + status = self._app_override_status(app_id) + item.update({ + "runtime": runtime, + "runtime_branch": branch, + "runtime_ready": branch in installed_extensions, + "prepared": status["prepared"], + }) + except Exception as error: + item["error"] = str(error) + apps.append(item) + apps.sort(key=lambda item: str(item["app_name"]).lower()) + return self._success_response(dict, f"Found {len(apps)} Flatpak applications", apps=apps) + except Exception as error: + return self._error_response(dict, str(error), apps=[]) + + def prepare_app(self, app_id: str): + try: + app_id = self._validate_app_id(app_id) + with self._lock: + runtime, branch = self._resolve_runtime(app_id) + extension = self.ensure_extension(branch) + if not extension.get("success") or not extension.get("installed"): + raise RuntimeError(extension.get("error") or f"Could not install Flatpak runtime {branch}") + state = self._read_state() + apps = state["prepared_apps"] + status = self._app_override_status(app_id) + if status["prepared"] and app_id not in apps: + return self._success_response( + dict, + "Flatpak application is already prepared outside this plugin", + app_id=app_id, + runtime=runtime, + runtime_branch=branch, + prepared=True, + owned=False, + ) + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + backup = self._backup_path(app_id) + if existed: + self._write_file(backup, original.decode("utf-8")) + else: + backup.unlink(missing_ok=True) + apps[app_id] = { + "override_existed": existed, + "managed_sha256": "", + } + result = self._run_flatpak_command( + [ + "override", + "--user", + f"--filesystem={self.config_dir}:ro", + f"--filesystem={self._dll_directory()}:ro", + f"--env=LSFGVK_CONFIG={self.config_file_path}", + "--env=LSFGVK_FLATPAK=1", + "--unset-env=DISABLE_LSFGVK", + "--unset-env=DISABLE_LSFG", + app_id, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or f"Could not prepare Flatpak app {app_id}") + status = self._app_override_status(app_id) + if not status["prepared"]: + raise RuntimeError(f"Flatpak preparation did not become visible for {app_id}") + existed, managed = self._snapshot_override(app_id) + if not existed: + raise RuntimeError(f"Flatpak override for {app_id} was not created") + apps[app_id]["managed_sha256"] = self._sha256(managed) + self._write_state(state) + return self._success_response( + dict, + "Flatpak application prepared for lsfg-vk", + app_id=app_id, + runtime=runtime, + runtime_branch=branch, + prepared=True, + owned=True, + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=False) + + def remove_app_override(self, app_id: str): try: + app_id = self._validate_app_id(app_id) with self._lock: - owned = self._owned_branches() - if not owned: + state = self._read_state() + apps = state["prepared_apps"] + entry = apps.get(app_id) + if entry is None: return self._success_response( dict, - "No plugin-owned Flatpak extensions to remove", + "Flatpak application is not plugin-owned; existing overrides were preserved", + app_id=app_id, + prepared=self._app_override_status(app_id)["prepared"], + owned=False, + ) + existed, current = self._snapshot_override(app_id) + current_hash = self._sha256(current) if existed else self._sha256(b"") + if current_hash != entry["managed_sha256"]: + raise RuntimeError( + "Flatpak override changed after preparation; refusing to overwrite unrelated settings" + ) + override_path = self._override_path(app_id) + backup_path = self._backup_path(app_id) + if entry["override_existed"]: + if not backup_path.is_file() or backup_path.is_symlink(): + raise RuntimeError("Flatpak override backup is unavailable") + self._write_file(override_path, backup_path.read_text(encoding="utf-8")) + else: + override_path.unlink(missing_ok=True) + backup_path.unlink(missing_ok=True) + apps.pop(app_id, None) + self._write_state(state) + return self._success_response( + dict, + "Plugin-owned Flatpak preparation removed", + app_id=app_id, + prepared=False, + owned=False, + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=True) + + def remove_plugin_owned_environment(self): + try: + with self._lock: + state = self._read_state() + failures = [] + removed_apps = [] + for app_id in list(state["prepared_apps"]): + result = self.remove_app_override(app_id) + if result.get("success"): + removed_apps.append(app_id) + else: + failures.append(f"{app_id}: {result.get('error')}") + if failures: + return self._error_response( + dict, + "; ".join(failures), + removed_apps=removed_apps, removed_branches=[], - preserved_branches=[], - ownership_uncertain=False, ) - if not self.check_flatpak_available(): - raise RuntimeError("Flatpak is not available; plugin-owned extension metadata was preserved") - removed, failures = [], [] - for branch in sorted(owned): - try: - self._remove_extension(branch) - removed.append(branch) - except Exception as error: - failures.append(f"{branch}: {error}") - remaining = owned - set(removed) - self._write_owned_branches(remaining) + state = self._read_state() + removed_branches = [] + for branch in sorted(self._owned_branches(state)): + result = self.uninstall_extension(branch) + if result.get("success"): + removed_branches.append(branch) + else: + failures.append(f"{branch}: {result.get('error')}") if failures: return self._error_response( dict, "; ".join(failures), - removed_branches=removed, - preserved_branches=sorted(remaining), - ownership_uncertain=False, + removed_apps=removed_apps, + removed_branches=removed_branches, ) return self._success_response( dict, - "Plugin-owned Flatpak extensions removed", - removed_branches=removed, - preserved_branches=[], - ownership_uncertain=False, + "Plugin-owned Flatpak state removed", + removed_apps=removed_apps, + removed_branches=removed_branches, ) except Exception as error: - return self._error_response( - dict, - str(error), - removed_branches=[], - preserved_branches=[], - ownership_uncertain=True, - ) + return self._error_response(dict, str(error), removed_apps=[], removed_branches=[]) -- cgit v1.2.3 From 769cd7f03990bf1d03c1b57f0929be9c3158e463 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:38:41 -0400 Subject: refactor: expose explicit flatpak preparation --- py_modules/lsfg_vk/plugin.py | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 13df7a4..a253d8a 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -36,21 +36,7 @@ class Plugin: return self.configuration_service.get_game_configs() async def get_installed_games(self): - result = self.steam_service.get_installed_games() - if not result.get("success"): - return result - cache: Dict[str, Dict[str, Any]] = {} - for game in result.get("games", []): - transport = game.get("transport", {}) - if transport.get("kind") != "flatpak": - continue - app_id = transport.get("flatpakAppId") - if not app_id: - continue - if app_id not in cache: - cache[app_id] = self.flatpak_service.resolve_app_support(app_id) - game["flatpakSupport"] = cache[app_id] - return result + return self.steam_service.get_installed_games() async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]): return self.configuration_service.update_game_config(appid, game_name, config) @@ -70,14 +56,12 @@ class Plugin: state: Dict[str, Any], shortcut_exe: Optional[str] = None, command_token_added: bool = False, - transport: Optional[Dict[str, Any]] = None, ): return self.wrapper_service.set( appid, state, shortcut_exe, command_token_added, - transport, ) async def remove_workaround_state(self, appid: str): @@ -112,7 +96,7 @@ class Plugin: ("config", "LSFG-VK configuration", self.configuration_service.config_file_path), ("workarounds", "Per-app workarounds", self.wrapper_service.sidecar_path), ("wrapper", "Generated launch wrapper", self.wrapper_service.wrapper_path), - ("flatpak_extensions", "Flatpak extension ownership", self.flatpak_service.ownership_path), + ("flatpak", "Flatpak ownership state", self.flatpak_service.ownership_path), ) contents = [] for file_id, label, path in files: @@ -150,11 +134,14 @@ class Plugin: async def get_flatpak_support_status(self): return self.flatpak_service.get_flatpak_support_status() - async def ensure_flatpak_support(self, flatpak_app_id: str): - return self.flatpak_service.ensure_app_support(flatpak_app_id) + async def get_flatpak_apps(self): + return self.flatpak_service.get_flatpak_apps() - async def repair_flatpak_support(self, flatpak_app_id: str): - return self.flatpak_service.ensure_app_support(flatpak_app_id) + async def prepare_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_service.prepare_app(flatpak_app_id) + + async def remove_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_service.remove_app_override(flatpak_app_id) async def set_flatpak_extension_enabled(self, version: str, enabled: bool): return self.flatpak_service.set_extension_enabled(version, enabled) @@ -170,13 +157,13 @@ class Plugin: async def _uninstall(self): decky.logger.info("decky-lsfg-vk plugin being uninstalled") - self.installation_service.cleanup_on_uninstall() try: - result = self.flatpak_service.remove_plugin_owned_extensions() + result = self.flatpak_service.remove_plugin_owned_environment() if not result.get("success"): decky.logger.warning(result.get("error")) except Exception as error: decky.logger.error(f"Error during Flatpak cleanup: {error}") + self.installation_service.cleanup_on_uninstall() decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed") async def _migration(self): -- cgit v1.2.3 From 2a000183846522d02df182d797c519aebbc1d9da Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:40:00 -0400 Subject: refactor: remove flatpak target state from wrapper --- py_modules/lsfg_vk/wrapper_service.py | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py index c476024..ebe9526 100644 --- a/py_modules/lsfg_vk/wrapper_service.py +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -4,7 +4,6 @@ import json import re import shlex import threading -from pathlib import Path from typing import Any, Dict, Optional, Tuple from .base_service import BaseService @@ -85,22 +84,12 @@ class WrapperService(BaseService): def _validate_entry(cls, raw: Any) -> Dict[str, Any]: if not isinstance(raw, dict): raise ValueError("Workaround AppID entry must be an object") - entry: Dict[str, Any] = { + entry = { "state": cls._validate_state(raw.get("state")), "command_token_added": raw.get("command_token_added", False), } if type(entry["command_token_added"]) is not bool: raise ValueError("command_token_added must be a boolean") - shortcut_exe = raw.get("shortcut_exe") - if shortcut_exe is not None: - if ( - not isinstance(shortcut_exe, str) - or not shortcut_exe.startswith("/") - or "\x00" in shortcut_exe - or Path(shortcut_exe).name != "flatpak" - ): - raise ValueError("shortcut_exe must be an absolute flatpak executable path") - entry["shortcut_exe"] = shortcut_exe return entry @classmethod @@ -273,7 +262,6 @@ class WrapperService(BaseService): "state": dict(entry["state"]) if entry else None, "wrapper_path": self.WRAPPER_TOKEN, "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, } @@ -299,7 +287,6 @@ class WrapperService(BaseService): self, appid: str, state: Dict[str, Any], - shortcut_exe: Optional[str] = None, command_token_added: bool = False, ) -> Dict[str, Any]: try: @@ -310,17 +297,10 @@ class WrapperService(BaseService): with self._lock: self._assert_wrapper_owned_or_absent() document, _, _ = self._read_document() - previous_entry = document["apps"].get(normalized) - entry: Dict[str, Any] = { + document["apps"][normalized] = { "state": validated_state, "command_token_added": command_token_added, } - selected_exe = shortcut_exe - if selected_exe is None and previous_entry: - selected_exe = previous_entry.get("shortcut_exe") - if selected_exe is not None: - entry = self._validate_entry({**entry, "shortcut_exe": selected_exe}) - document["apps"][normalized] = entry self._write_pair(document) return self._response(document, normalized) except Exception as error: -- cgit v1.2.3 From 868438873136c1b0a6c323b1a55e69e6b2f855ca Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:41:19 -0400 Subject: refactor: simplify workaround state api --- py_modules/lsfg_vk/plugin.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index a253d8a..854800a 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -1,5 +1,5 @@ import os -from typing import Any, Dict, Optional +from typing import Any, Dict import decky @@ -54,15 +54,9 @@ class Plugin: self, appid: str, state: Dict[str, Any], - shortcut_exe: Optional[str] = None, command_token_added: bool = False, ): - return self.wrapper_service.set( - appid, - state, - shortcut_exe, - command_token_added, - ) + return self.wrapper_service.set(appid, state, command_token_added) async def remove_workaround_state(self, appid: str): return self.wrapper_service.remove(appid) -- cgit v1.2.3 From 01e4a99ef2409666883ede7886169c96dc6b46fb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:52:15 -0400 Subject: fix: recognize released direct flatpak wrapper targets --- py_modules/lsfg_vk/steam_service.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index df6e3a2..8018f9b 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -11,6 +11,8 @@ from .constants import ( ) _WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" +_LEGACY_WRAPPER_NAMES = {"lsfg", "lsfg-vk-experimental", "mako-run", "mako-launch"} +_FLATPAK_TOKENS = {"flatpak", "/usr/bin/flatpak", "usr/bin/flatpak"} def _split_command(value: Optional[str]) -> Optional[list[str]]: @@ -22,24 +24,19 @@ def _split_command(value: Optional[str]) -> Optional[list[str]]: return None -def _is_managed_wrapper(value: str) -> bool: +def _is_wrapper(value: str) -> bool: if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}: return True - path = Path(value) - return path.is_absolute() and path.name == WRAPPER_FILENAME + return Path(value).name in _LEGACY_WRAPPER_NAMES or Path(value).name == WRAPPER_FILENAME def is_direct_flatpak_shortcut(executable: Optional[str]) -> bool: tokens = _split_command(executable) if not tokens: return False - if tokens[0] in {"flatpak", "/usr/bin/flatpak"}: + if tokens[0] in _FLATPAK_TOKENS: return True - return ( - len(tokens) == 2 - and _is_managed_wrapper(tokens[0]) - and tokens[1] == "/usr/bin/flatpak" - ) + return len(tokens) == 2 and _is_wrapper(tokens[0]) and tokens[1] in _FLATPAK_TOKENS def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: -- cgit v1.2.3 From c448f8b703c293c78ffe82c5f10c242ca47d751a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:49 -0400 Subject: refactor: make flatpak lifecycle app-centric --- py_modules/lsfg_vk/plugin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 854800a..e00788c 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -30,6 +30,14 @@ class Plugin: return self.installation_service.check_installation() async def uninstall_lsfg_vk(self): + flatpak = self.flatpak_service.remove_plugin_owned_environment() + if not flatpak.get("success"): + return { + "success": False, + "message": "", + "error": flatpak.get("error") or "Could not clean up Flatpak support", + "removed_files": None, + } return self.installation_service.uninstall() async def get_game_configs(self): @@ -125,9 +133,6 @@ class Plugin: async def get_lossless_scaling_branch_status(self): return self.steam_service.get_branch_status() - async def get_flatpak_support_status(self): - return self.flatpak_service.get_flatpak_support_status() - async def get_flatpak_apps(self): return self.flatpak_service.get_flatpak_apps() @@ -137,9 +142,6 @@ class Plugin: async def remove_flatpak_app(self, flatpak_app_id: str): return self.flatpak_service.remove_app_override(flatpak_app_id) - async def set_flatpak_extension_enabled(self, version: str, enabled: bool): - return self.flatpak_service.set_extension_enabled(version, enabled) - async def _main(self): repair = self.wrapper_service.repair() if not repair.get("success"): -- cgit v1.2.3 From 63fb391cacf54cc336c91172fff9976bd51eaa9f Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:28:23 -0400 Subject: refactor: retain selector-only v2 profiles --- py_modules/lsfg_vk/config_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 3816d88..bf3e174 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -113,6 +113,6 @@ class ConfigurationManager: **profile, **global_config, }) - if config["active_in"]: + if name or config["active_in"]: profiles[name] = config return {"profiles": profiles, "global_config": global_config} -- cgit v1.2.3 From ba63c284631ba2aefe5906b0edecf51fab829ef3 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:28:46 -0400 Subject: feat: add explicit flatpak profile configuration --- py_modules/lsfg_vk/configuration.py | 63 +++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index bec3828..7e68479 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -7,7 +7,7 @@ from .runtime_service import RuntimeService class ConfigurationService(BaseService): - """Controller-facing adapter over upstream lsfg-vk profiles.""" + FLATPAK_PROFILE_PREFIX = "flatpak:" def __init__(self, logger=None, runtime_service: RuntimeService = None): super().__init__(logger) @@ -47,6 +47,13 @@ class ConfigurationService(BaseService): (None, None), ) + @classmethod + def flatpak_profile_name(cls, app_id: str) -> str: + value = str(app_id).strip() + if not value: + raise ValueError("Flatpak application ID is required") + return f"{cls.FLATPAK_PROFILE_PREFIX}{value}" + @staticmethod def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: return ConfigurationManager.validate_config(config) @@ -87,6 +94,51 @@ class ConfigurationService(BaseService): except Exception as error: return self._error_response(dict, str(error), appid=str(appid), config=None) + def get_flatpak_config(self, app_id: str) -> Dict[str, Any]: + try: + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + raw = data["profiles"].get(name) + return self._success_response( + dict, + app_id=str(app_id), + profile=name, + exists=raw is not None, + config=self._public_config(raw) if raw is not None else None, + global_config=dict(data["global_config"]), + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def update_flatpak_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: + try: + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + merged_config = {**data["global_config"], **config} + 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) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def reset_flatpak_config(self, app_id: str) -> Dict[str, Any]: + try: + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + data["profiles"].pop(name, None) + self._save_profile_data(data) + return self._success_response(dict, app_id=str(app_id), profile=name, exists=False) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + def reset_game_config(self, appid: str) -> Dict[str, Any]: try: data = self._get_profile_data() @@ -101,7 +153,14 @@ class ConfigurationService(BaseService): def reset_all_game_configs(self) -> Dict[str, Any]: try: data = self._get_profile_data() - data["profiles"] = {} + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not ( + len(profile.get("active_in", [])) == 1 + and re.fullmatch(r"-?[0-9]+", str(profile.get("active_in", [""])[0])) + ) + } self._save_profile_data(data) return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) except Exception as error: -- cgit v1.2.3 From 9d7a066a6fb133bd8ca991760448bc124bcb807b Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:29:50 -0400 Subject: feat: add flatpak profile environment service --- py_modules/lsfg_vk/flatpak_profile_service.py | 358 ++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 py_modules/lsfg_vk/flatpak_profile_service.py (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py new file mode 100644 index 0000000..29751d4 --- /dev/null +++ b/py_modules/lsfg_vk/flatpak_profile_service.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import re +from typing import Any, Dict + +from .configuration import ConfigurationService +from .flatpak_service import FlatpakService + + +class FlatpakProfileService: + STATE_FIELDS = ( + "dxvkFrameRate", + "disableGamescopeWsi", + "disableHdr", + "disableSteamdeckMode", + "disableVkbasalt", + "enableZink", + ) + BOOLEAN_FIELDS = STATE_FIELDS[1:] + DXVK_FRAME_RATE_SEGMENT = re.compile( + r"^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=", + re.IGNORECASE, + ) + + def __init__( + self, + flatpak_service: FlatpakService, + configuration_service: ConfigurationService, + ): + self.flatpak_service = flatpak_service + self.configuration_service = configuration_service + + @classmethod + def default_state(cls) -> Dict[str, Any]: + return { + "dxvkFrameRate": 0, + "disableGamescopeWsi": True, + "disableHdr": True, + "disableSteamdeckMode": False, + "disableVkbasalt": False, + "enableZink": False, + } + + @classmethod + def _validate_state(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("Workaround state must be an object") + missing = [field for field in cls.STATE_FIELDS if field not in raw] + if missing: + raise ValueError("Workaround state is missing: " + ", ".join(missing)) + state = {field: raw[field] for field in cls.STATE_FIELDS} + frame_rate = state["dxvkFrameRate"] + if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60: + raise ValueError("Base FPS Cap must be an integer from 0 to 60") + for field in cls.BOOLEAN_FIELDS: + if type(state[field]) is not bool: + raise ValueError(f"{field} must be a boolean") + return state + + def _state_entry(self, app_id: str) -> tuple[Dict[str, object], Dict[str, Any]]: + state = self.flatpak_service._read_state() + entry = state["prepared_apps"].get(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Flatpak application is not owned by this plugin") + return state, entry + + def _baseline_content(self, app_id: str, entry: Dict[str, Any]) -> str: + if not entry.get("override_existed"): + return "" + path = self.flatpak_service._backup_path(app_id) + if path.is_symlink() or not path.is_file(): + raise RuntimeError("Flatpak override backup is unavailable") + return path.read_text(encoding="utf-8") + + @staticmethod + def _environment_value(content: str, key: str) -> str: + section = None + for raw_line in content.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + if section != "Environment": + continue + name, separator, value = line.partition("=") + if separator and name == key: + return value + return "" + + @classmethod + def _dxvk_config(cls, baseline: str, frame_rate: int) -> str: + existing = cls._environment_value(baseline, "DXVK_CONFIG") + parts = [part.strip() for part in existing.split(";") if part.strip()] + parts = [part for part in parts if not cls.DXVK_FRAME_RATE_SEGMENT.match(part)] + if frame_rate > 0: + parts.append(f"dxvk.maxFrameRate = {frame_rate}") + return "; ".join(parts) + + def _restore_baseline(self, app_id: str, entry: Dict[str, Any]) -> None: + existed, current = self.flatpak_service._snapshot_override(app_id) + current_hash = self.flatpak_service._sha256(current) if existed else self.flatpak_service._sha256(b"") + if current_hash != entry.get("managed_sha256"): + raise RuntimeError("Flatpak override changed after preparation; refusing to overwrite unrelated settings") + path = self.flatpak_service._override_path(app_id) + if entry.get("override_existed"): + baseline = self._baseline_content(app_id, entry) + self.flatpak_service._write_file(path, baseline) + else: + path.unlink(missing_ok=True) + + def _apply_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: + workaround_state = self._validate_state(workaround_state) + state, entry = self._state_entry(app_id) + baseline = self._baseline_content(app_id, entry) + self._restore_baseline(app_id, entry) + prepared = self.flatpak_service.prepare_app(app_id) + if not prepared.get("success") or not prepared.get("owned"): + raise RuntimeError(prepared.get("error") or "Could not restore plugin-owned Flatpak preparation") + profile = self.configuration_service.flatpak_profile_name(app_id) + args = [ + "override", + "--user", + f"--env=LSFGVK_PROFILE={profile}", + "--unset-env=DISABLE_LSFGVK", + "--unset-env=DISABLE_LSFG", + ] + if workaround_state["disableGamescopeWsi"]: + args.extend(["--env=ENABLE_GAMESCOPE_WSI=0", "--unset-env=DISABLE_GAMESCOPE_WSI"]) + if workaround_state["disableHdr"]: + args.append("--env=DXVK_HDR=0") + if workaround_state["disableSteamdeckMode"]: + args.append("--env=SteamDeck=0") + if workaround_state["disableVkbasalt"]: + args.extend(["--env=DISABLE_VKBASALT=1", "--unset-env=ENABLE_VKBASALT"]) + if workaround_state["enableZink"]: + args.extend([ + "--env=__GLX_VENDOR_LIBRARY_NAME=mesa", + "--env=MESA_LOADER_DRIVER_OVERRIDE=zink", + "--env=GALLIUM_DRIVER=zink", + ]) + dxvk_config = self._dxvk_config(baseline, workaround_state["dxvkFrameRate"]) + if dxvk_config: + args.append(f"--env=DXVK_CONFIG={dxvk_config}") + args.append(app_id) + result = self.flatpak_service._run_flatpak_command(args, capture_output=True, text=True) + if result.returncode != 0: + raise OSError(result.stderr.strip() or f"Could not apply Flatpak workarounds for {app_id}") + existed, managed = self.flatpak_service._snapshot_override(app_id) + if not existed: + raise RuntimeError(f"Flatpak override for {app_id} was not created") + state = self.flatpak_service._read_state() + entry = state["prepared_apps"].get(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Flatpak application ownership state disappeared") + entry["managed_sha256"] = self.flatpak_service._sha256(managed) + entry["workaround_state"] = workaround_state + self.flatpak_service._write_state(state) + return workaround_state + + def enable_app(self, app_id: str) -> Dict[str, Any]: + try: + existing = self.configuration_service.get_flatpak_config(app_id) + prepared = self.flatpak_service.prepare_app(app_id) + if not prepared.get("success"): + raise RuntimeError(prepared.get("error") or "Could not prepare Flatpak application") + if not prepared.get("owned"): + raise RuntimeError("Flatpak application is prepared outside this plugin and cannot be managed safely") + if not existing.get("exists"): + config = { + **self.configuration_service._public_config({}), + **(existing.get("global_config") or {}), + } + config["active_in"] = [] + saved = self.configuration_service.update_flatpak_config(app_id, config) + if not saved.get("success"): + raise RuntimeError(saved.get("error") or "Could not create Flatpak profile") + state, entry = self._state_entry(app_id) + workaround_state = self._validate_state(entry.get("workaround_state", self.default_state())) + self._apply_state(app_id, workaround_state) + return self.get_app(app_id) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": False, + } + + def update_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: + try: + self._state_entry(app_id) + 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) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": False, + } + + def get_workaround_state(self, app_id: str) -> Dict[str, Any]: + try: + _, entry = self._state_entry(app_id) + state = self._validate_state(entry.get("workaround_state", self.default_state())) + return { + "success": True, + "message": "", + "error": None, + "app_id": app_id, + "state": state, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "state": None, + } + + def set_workaround_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: + try: + state = self._apply_state(app_id, workaround_state) + return { + "success": True, + "message": "", + "error": None, + "app_id": app_id, + "state": state, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "state": None, + } + + def remove_app(self, app_id: str) -> Dict[str, Any]: + try: + removed = self.flatpak_service.remove_app_override(app_id) + if not removed.get("success"): + raise RuntimeError(removed.get("error") or "Could not remove Flatpak preparation") + reset = self.configuration_service.reset_flatpak_config(app_id) + if not reset.get("success"): + raise RuntimeError(reset.get("error") or "Could not remove Flatpak profile") + return { + "success": True, + "message": "Flatpak profile removed", + "error": None, + "app_id": app_id, + "enabled": False, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": True, + } + + def get_app(self, app_id: str) -> Dict[str, Any]: + apps = self.get_apps() + if not apps.get("success"): + return { + "success": False, + "message": "", + "error": apps.get("error"), + "app_id": str(app_id), + "enabled": False, + } + app = next((item for item in apps.get("apps", []) if item.get("app_id") == app_id), None) + if app is None: + return { + "success": False, + "message": "", + "error": "Flatpak application is not installed", + "app_id": str(app_id), + "enabled": False, + } + return {"success": True, "message": "", "error": None, **app} + + def get_apps(self) -> Dict[str, Any]: + try: + result = self.flatpak_service.get_flatpak_apps() + if not result.get("success"): + raise RuntimeError(result.get("error") or "Could not list Flatpak applications") + ownership = self.flatpak_service._read_state() + apps = [] + for item in result.get("apps", []): + app_id = item["app_id"] + config_result = self.configuration_service.get_flatpak_config(app_id) + entry = ownership["prepared_apps"].get(app_id) + workarounds = self.default_state() + if isinstance(entry, dict): + workarounds = self._validate_state(entry.get("workaround_state", workarounds)) + profile = self.configuration_service.flatpak_profile_name(app_id) + selector_ready = False + if item.get("prepared"): + shown = self.flatpak_service._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + selector_ready = shown.returncode == 0 and f"LSFGVK_PROFILE={profile}" in shown.stdout.splitlines() + apps.append({ + **item, + "profile": profile, + "enabled": bool(item.get("owned") and config_result.get("exists") and selector_ready), + "config": config_result.get("config"), + "workarounds": workarounds, + }) + return { + "success": True, + "message": result.get("message", ""), + "error": None, + "apps": apps, + } + except Exception as error: + return {"success": False, "message": "", "error": str(error), "apps": []} + + def get_running_apps(self) -> Dict[str, Any]: + try: + apps = self.get_apps() + if not apps.get("success"): + raise RuntimeError(apps.get("error") or "Could not list Flatpak applications") + enabled = {item["app_id"]: item for item in apps.get("apps", []) if item.get("enabled")} + if not enabled: + return {"success": True, "message": "", "error": None, "apps": []} + result = self.flatpak_service._run_flatpak_command( + ["ps", "--columns=application,active,pid"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Could not inspect running Flatpak applications") + running = [] + for line in result.stdout.splitlines(): + fields = line.split("\t") if "\t" in line else line.split() + if len(fields) < 1: + continue + app_id = fields[0] + if app_id not in enabled: + continue + active = len(fields) > 1 and fields[1].strip().lower() in {"1", "true", "yes", "active"} + pid = fields[2].strip() if len(fields) > 2 else "" + running.append({**enabled[app_id], "active": active, "pid": pid}) + running.sort(key=lambda item: (not item.get("active", False), str(item.get("app_name", "")).lower())) + return {"success": True, "message": "", "error": None, "apps": running} + except Exception as error: + return {"success": False, "message": "", "error": str(error), "apps": []} -- cgit v1.2.3 From b12e456585b60479d4e830a96590d18928a3aba7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:30:16 -0400 Subject: refactor: isolate flatpak profile cleanup --- py_modules/lsfg_vk/configuration.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index 7e68479..17dcaf3 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -139,6 +139,19 @@ class ConfigurationService(BaseService): except Exception as error: return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + def reset_all_flatpak_configs(self) -> Dict[str, Any]: + try: + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not name.startswith(self.FLATPAK_PROFILE_PREFIX) + } + 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)) + def reset_game_config(self, appid: str) -> Dict[str, Any]: try: data = self._get_profile_data() -- cgit v1.2.3 From 7b54ba042d32cd67618703ff5c5ada0ae5dccb36 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:30:35 -0400 Subject: feat: expose flatpak profile APIs --- py_modules/lsfg_vk/plugin.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index e00788c..d20fabf 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -4,6 +4,7 @@ from typing import Any, Dict import decky from .configuration import ConfigurationService +from .flatpak_profile_service import FlatpakProfileService from .flatpak_service import FlatpakService from .installation import InstallationService from .runtime_service import RuntimeService @@ -21,6 +22,10 @@ class Plugin: ) self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() + self.flatpak_profile_service = FlatpakProfileService( + self.flatpak_service, + self.configuration_service, + ) self.wrapper_service = WrapperService() async def install_lsfg_vk(self): @@ -38,6 +43,7 @@ class Plugin: "error": flatpak.get("error") or "Could not clean up Flatpak support", "removed_files": None, } + self.configuration_service.reset_all_flatpak_configs() return self.installation_service.uninstall() async def get_game_configs(self): @@ -134,13 +140,25 @@ class Plugin: return self.steam_service.get_branch_status() async def get_flatpak_apps(self): - return self.flatpak_service.get_flatpak_apps() + return self.flatpak_profile_service.get_apps() + + async def enable_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_profile_service.enable_app(flatpak_app_id) + + async def update_flatpak_config(self, flatpak_app_id: str, config: Dict[str, Any]): + return self.flatpak_profile_service.update_config(flatpak_app_id, config) - async def prepare_flatpak_app(self, flatpak_app_id: str): - return self.flatpak_service.prepare_app(flatpak_app_id) + async def get_flatpak_workaround_state(self, flatpak_app_id: str): + return self.flatpak_profile_service.get_workaround_state(flatpak_app_id) + + async def set_flatpak_workaround_state(self, flatpak_app_id: str, state: Dict[str, Any]): + return self.flatpak_profile_service.set_workaround_state(flatpak_app_id, state) async def remove_flatpak_app(self, flatpak_app_id: str): - return self.flatpak_service.remove_app_override(flatpak_app_id) + return self.flatpak_profile_service.remove_app(flatpak_app_id) + + async def get_running_flatpak_apps(self): + return self.flatpak_profile_service.get_running_apps() async def _main(self): repair = self.wrapper_service.repair() @@ -155,7 +173,9 @@ class Plugin: decky.logger.info("decky-lsfg-vk plugin being uninstalled") try: result = self.flatpak_service.remove_plugin_owned_environment() - if not result.get("success"): + if result.get("success"): + self.configuration_service.reset_all_flatpak_configs() + else: decky.logger.warning(result.get("error")) except Exception as error: decky.logger.error(f"Error during Flatpak cleanup: {error}") -- cgit v1.2.3 From 1fcd7f031c3f6dc38c19bbafaf2c4174f11fa1cb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:33:59 -0400 Subject: refactor: decouple steam discovery from flatpak --- py_modules/lsfg_vk/steam_service.py | 32 -------------------------------- 1 file changed, 32 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 8018f9b..e493ba1 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -1,5 +1,4 @@ import re -import shlex from pathlib import Path from typing import Dict, Optional, Tuple @@ -7,37 +6,8 @@ from .base_service import BaseService from .constants import ( STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH, - WRAPPER_FILENAME, ) -_WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" -_LEGACY_WRAPPER_NAMES = {"lsfg", "lsfg-vk-experimental", "mako-run", "mako-launch"} -_FLATPAK_TOKENS = {"flatpak", "/usr/bin/flatpak", "usr/bin/flatpak"} - - -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 _is_wrapper(value: str) -> bool: - if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}: - return True - return Path(value).name in _LEGACY_WRAPPER_NAMES or Path(value).name == WRAPPER_FILENAME - - -def is_direct_flatpak_shortcut(executable: Optional[str]) -> bool: - tokens = _split_command(executable) - if not tokens: - return False - if tokens[0] in _FLATPAK_TOKENS: - return True - return len(tokens) == 2 and _is_wrapper(tokens[0]) and tokens[1] in _FLATPAK_TOKENS - def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: return next((values[key] for key in keys if isinstance(values.get(key), str)), None) @@ -140,7 +110,6 @@ class SteamService(BaseService): "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "directFlatpak": is_direct_flatpak_shortcut(executable), } for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): if value is not None: @@ -295,7 +264,6 @@ class SteamService(BaseService): "appid": appid, "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, - "directFlatpak": False, } for game in self._shortcut_games(): games.setdefault(str(game["appid"]), game) -- cgit v1.2.3 From 7118e48b48e66b444c4a74ac663511e87429e1fc Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:39:48 -0400 Subject: perf: make flatpak running detection lightweight --- py_modules/lsfg_vk/flatpak_profile_service.py | 53 +++++++++++++++++++-------- 1 file changed, 38 insertions(+), 15 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py index 29751d4..ddec116 100644 --- a/py_modules/lsfg_vk/flatpak_profile_service.py +++ b/py_modules/lsfg_vk/flatpak_profile_service.py @@ -103,14 +103,13 @@ class FlatpakProfileService: raise RuntimeError("Flatpak override changed after preparation; refusing to overwrite unrelated settings") path = self.flatpak_service._override_path(app_id) if entry.get("override_existed"): - baseline = self._baseline_content(app_id, entry) - self.flatpak_service._write_file(path, baseline) + self.flatpak_service._write_file(path, self._baseline_content(app_id, entry)) else: path.unlink(missing_ok=True) def _apply_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: workaround_state = self._validate_state(workaround_state) - state, entry = self._state_entry(app_id) + _, entry = self._state_entry(app_id) baseline = self._baseline_content(app_id, entry) self._restore_baseline(app_id, entry) prepared = self.flatpak_service.prepare_app(app_id) @@ -158,13 +157,18 @@ class FlatpakProfileService: return workaround_state def enable_app(self, app_id: str) -> Dict[str, Any]: + created_profile = False + newly_owned = False try: existing = self.configuration_service.get_flatpak_config(app_id) + before = self.flatpak_service._read_state() + was_owned = app_id in before["prepared_apps"] prepared = self.flatpak_service.prepare_app(app_id) if not prepared.get("success"): raise RuntimeError(prepared.get("error") or "Could not prepare Flatpak application") if not prepared.get("owned"): raise RuntimeError("Flatpak application is prepared outside this plugin and cannot be managed safely") + newly_owned = not was_owned if not existing.get("exists"): config = { **self.configuration_service._public_config({}), @@ -174,11 +178,16 @@ class FlatpakProfileService: saved = self.configuration_service.update_flatpak_config(app_id, config) if not saved.get("success"): raise RuntimeError(saved.get("error") or "Could not create Flatpak profile") - state, entry = self._state_entry(app_id) + created_profile = True + _, entry = self._state_entry(app_id) workaround_state = self._validate_state(entry.get("workaround_state", self.default_state())) self._apply_state(app_id, workaround_state) return self.get_app(app_id) except Exception as error: + if created_profile: + self.configuration_service.reset_flatpak_config(app_id) + if newly_owned: + self.flatpak_service.remove_app_override(app_id) return { "success": False, "message": "", @@ -328,10 +337,24 @@ class FlatpakProfileService: def get_running_apps(self) -> Dict[str, Any]: try: - apps = self.get_apps() - if not apps.get("success"): - raise RuntimeError(apps.get("error") or "Could not list Flatpak applications") - enabled = {item["app_id"]: item for item in apps.get("apps", []) if item.get("enabled")} + state = self.flatpak_service._read_state() + enabled = set() + for app_id, entry in state["prepared_apps"].items(): + if not isinstance(entry, dict): + continue + config = self.configuration_service.get_flatpak_config(app_id) + if not config.get("exists"): + continue + existed, content = self.flatpak_service._snapshot_override(app_id) + if not existed or self.flatpak_service._sha256(content) != entry.get("managed_sha256"): + continue + profile = self.configuration_service.flatpak_profile_name(app_id) + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + continue + if self._environment_value(text, "LSFGVK_PROFILE") == profile: + enabled.add(app_id) if not enabled: return {"success": True, "message": "", "error": None, "apps": []} result = self.flatpak_service._run_flatpak_command( @@ -344,15 +367,15 @@ class FlatpakProfileService: running = [] for line in result.stdout.splitlines(): fields = line.split("\t") if "\t" in line else line.split() - if len(fields) < 1: - continue - app_id = fields[0] - if app_id not in enabled: + if not fields or fields[0] not in enabled: continue active = len(fields) > 1 and fields[1].strip().lower() in {"1", "true", "yes", "active"} - pid = fields[2].strip() if len(fields) > 2 else "" - running.append({**enabled[app_id], "active": active, "pid": pid}) - running.sort(key=lambda item: (not item.get("active", False), str(item.get("app_name", "")).lower())) + running.append({ + "app_id": fields[0], + "active": active, + "pid": 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": []} -- cgit v1.2.3 From 5b5f9df2b13f7502e897de56af6b7cc84eca2176 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:44:34 -0400 Subject: refactor: remove unused shortcut launch metadata --- py_modules/lsfg_vk/steam_service.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index e493ba1..2108071 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -9,10 +9,6 @@ from .constants import ( ) -def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: - return next((values[key] for key in keys if isinstance(values.get(key), str)), None) - - class SteamService(BaseService): DEFAULT_BRANCH = "public" MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" @@ -103,18 +99,11 @@ 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 - executable = _first_string(shortcut, "Exe", "exe", "executable") - arguments = _first_string(shortcut, "LaunchOptions", "launchoptions", "launch_options", "arguments") - start_dir = _first_string(shortcut, "StartDir", "startdir", "start_dir") - game: Dict[str, object] = { + return { "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, } - for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): - if value is not None: - game[key] = value - return game def _shortcut_games(self): games = {} -- cgit v1.2.3 From 904e2e6131071c3b132d3148947b613c2830b1bb Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 20:49:05 -0400 Subject: fix; correct flatpak extension sources --- py_modules/lsfg_vk/constants.py | 3 -- py_modules/lsfg_vk/flatpak_service.py | 60 +++++++++++++++++++---------------- 2 files changed, 33 insertions(+), 30 deletions(-) (limited to 'py_modules/lsfg_vk') 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_service.py b/py_modules/lsfg_vk/flatpak_service.py index 62f6a50..4f04382 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 @@ -115,14 +111,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 +127,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, @@ -343,14 +339,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") - bundle = self._bundled_extension_path(branch) - if not bundle.is_file(): - raise FileNotFoundError(f"Bundled Flatpak extension not found at {bundle}; reinstall the plugin") + 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") + 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 +374,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 +422,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): -- cgit v1.2.3 From d2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 09:05:14 -0400 Subject: flatpak correctness, ui alignment, tests --- py_modules/lsfg_vk/flatpak_profile_service.py | 12 +++++++++-- py_modules/lsfg_vk/flatpak_service.py | 30 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py index ddec116..9eaf9c7 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,16 @@ 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"])) + running.sort(key=lambda item: ( + not item["active"], + -(item["start_time"] if isinstance(item["start_time"], int) else -1), + -int(item["pid"]) if str(item["pid"]).isdigit() else 1, + 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 4f04382..f2ed90c 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -44,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: @@ -196,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(): -- cgit v1.2.3 From a8388009b2ba141caafe7f2e35f7029d93e7811f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 09:38:35 -0400 Subject: handle edge cases on flatpaks and ordering --- py_modules/lsfg_vk/flatpak_profile_service.py | 6 ------ 1 file changed, 6 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py index 9eaf9c7..9d2d104 100644 --- a/py_modules/lsfg_vk/flatpak_profile_service.py +++ b/py_modules/lsfg_vk/flatpak_profile_service.py @@ -378,12 +378,6 @@ class FlatpakProfileService: fields[2].strip() if len(fields) > 2 else "" ), }) - running.sort(key=lambda item: ( - not item["active"], - -(item["start_time"] if isinstance(item["start_time"], int) else -1), - -int(item["pid"]) if str(item["pid"]).isdigit() else 1, - item["app_id"], - )) return {"success": True, "message": "", "error": None, "apps": running} except Exception as error: return {"success": False, "message": "", "error": str(error), "apps": []} -- cgit v1.2.3 From 954b2abc47d8772211cb8ecee523900f823184e8 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 11:08:12 -0400 Subject: better uninstall cleanup --- py_modules/lsfg_vk/configuration.py | 26 +++++++----- py_modules/lsfg_vk/installation.py | 31 ++++++++++++++- py_modules/lsfg_vk/plugin.py | 38 +++++++++++++----- py_modules/lsfg_vk/wrapper_service.py | 74 +++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 22 deletions(-) (limited to 'py_modules/lsfg_vk') 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/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..918c3f8 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): 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.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() + 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..906e55b 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,66 @@ 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, + } -- cgit v1.2.3 From c7ca9c7ec2660d279f5e6957ef882390a8783667 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 11:47:16 -0400 Subject: handle decky uninstall to avoid unlaunchable game wrappers --- py_modules/lsfg_vk/plugin.py | 6 +++--- py_modules/lsfg_vk/wrapper_service.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 918c3f8..a1fd40f 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -34,14 +34,14 @@ class Plugin: async def check_lsfg_vk_installed(self): return self.installation_service.check_installation() - def _cleanup_runtime_state(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.purge() + 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 @@ -190,7 +190,7 @@ class Plugin: async def _uninstall(self): decky.logger.info("decky-lsfg-vk plugin being uninstalled") try: - error = self._cleanup_runtime_state() + error = self._cleanup_runtime_state(preserve_wrapper=True) if error: decky.logger.warning(f"Preserving lsfg-vk files because uninstall cleanup failed: {error}") return diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py index 906e55b..30c2323 100644 --- a/py_modules/lsfg_vk/wrapper_service.py +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -427,3 +427,38 @@ class WrapperService(BaseService): "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, + } -- cgit v1.2.3 From f3074fbe1427411dc3b5597d2e87918383299e3f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 13:47:52 -0400 Subject: feat: non steam tab, faster bulk actions --- py_modules/lsfg_vk/configuration.py | 27 +++++++++++++++++++++++++++ py_modules/lsfg_vk/plugin.py | 3 +++ 2 files changed, 30 insertions(+) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index d1852a0..3c65972 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -169,6 +169,33 @@ class ConfigurationService(BaseService): except Exception as error: return self._error_response(dict, str(error), appid=str(appid), config=None) + def reset_game_configs(self, appids: list[str]) -> Dict[str, Any]: + try: + if not isinstance(appids, list): + raise ValueError("appids must be a list") + requested = set() + for appid in appids: + if isinstance(appid, bool) or not isinstance(appid, (str, int)): + raise ValueError("appids must contain only strings or integers") + value = str(appid) + if not re.fullmatch(r"-?[0-9]+", value): + raise ValueError("appids must contain only numeric App IDs") + requested.add(value) + + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not ( + len(profile.get("active_in", [])) == 1 + and str(profile["active_in"][0]) in requested + ) + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) + except Exception as error: + return self._error_response(dict, str(error), games=[]) + def reset_all_game_configs(self) -> Dict[str, Any]: try: data = self._get_profile_data() diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index a1fd40f..c63e3a9 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -72,6 +72,9 @@ class Plugin: async def reset_game_config(self, appid: str): return self.configuration_service.reset_game_config(appid) + async def reset_game_configs(self, appids): + return self.configuration_service.reset_game_configs(appids) + async def reset_all_game_configs(self): return self.configuration_service.reset_all_game_configs() -- cgit v1.2.3 From 81feb03288166755545401b9df2ff2c9bc3ae7d7 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 16:48:35 -0400 Subject: handle flatpak grey, id proton and exclusions --- py_modules/lsfg_vk/steam_service.py | 38 +++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) (limited to 'py_modules/lsfg_vk') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 2108071..93f2593 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -12,12 +12,34 @@ from .constants import ( class SteamService(BaseService): DEFAULT_BRANCH = "public" MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" + # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG. GAME_SELECTOR_EXCLUDED_APPIDS = { - "858280", "961940", "1054830", "1113280", "1245040", "1420170", - "1493710", "1580130", "1887720", "2180100", "228980", "2348590", - "2805730", "3029110", "3127680", "3658110", "4183110", "4185400", - "4427310", "4628710", "4628740", "4690330", "993090", "1070560", - "1391110", "1628350", + "858280", # Proton 3.7 + "961940", # Proton 3.16 + "1054830", # Proton 4.2 + "1113280", # Proton 4.11 + "1245040", # Proton 5.0 + "1420170", # Proton 5.13 + "1493710", # Proton Experimental + "1580130", # Proton 6.3 + "1887720", # Proton 7 + "2180100", # Proton Hotfix + "228980", # Steamworks Common Redistributables + "2348590", # Proton 8 + "2805730", # Proton 9 + "3029110", # Lepton + "3127680", # fex + "3658110", # Proton 10 + "4183110", # Steam Linux Runtime 4.0 + "4185400", # Steam Linux Runtime 4.0 for arm64 + "4427310", # Proton Experimental (ARM64) + "4628710", # Proton 11 / Proton Next + "4628740", # Proton 11 (ARM64) + "4690330", # Legacy Steam Runtime + "993090", # Lossless Scaling + "1070560", # Steam Linux Runtime 1.0 + "1391110", # Steam Linux Runtime 2.0 + "1628350", # Steam Linux Runtime 3.0 } def _steam_roots(self): @@ -99,11 +121,15 @@ 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 { + game = { "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, } + executable = shortcut.get("Exe") or shortcut.get("exe") + if isinstance(executable, str) and executable.strip().strip('"') in {"flatpak", "/usr/bin/flatpak"}: + game["isFlatpakShortcut"] = True + return game def _shortcut_games(self): games = {} -- cgit v1.2.3