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 --- package.json | 5 ---- py_modules/lsfg_vk/configuration.py | 31 ++++++----------------- py_modules/lsfg_vk/constants.py | 3 --- py_modules/lsfg_vk/installation.py | 50 +++---------------------------------- 4 files changed, 11 insertions(+), 78 deletions(-) diff --git a/package.json b/package.json index 4e32940..ccc4e40 100644 --- a/package.json +++ b/package.json @@ -67,11 +67,6 @@ "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v1.0.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", "sha256hash": "0651bda96751ef0f1314a5179585926a0cd354476790ca2616662c39fe6fae54" - }, - { - "name": "liblsfg-vk-arm64.so", - "url": "https://github.com/Janleyx/decky-lsfg-vk-for-Ayn-Odin-2/releases/download/arm64-layer-3e89e54/liblsfg-vk-arm64.so", - "sha256hash": "2e84f8d1a1dd0344474846f6252d6f948f72caaaeab3cd316c04254be05a9949" } ], "pnpm": { 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 --- package.json | 6 +- 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 +++++++----------- shared_config.py | 107 ++-- src/components/ConfigurationSection.tsx | 14 +- src/config/configSchema.ts | 2 +- src/config/generatedConfigSchema.ts | 20 +- 11 files changed, 733 insertions(+), 1469 deletions(-) diff --git a/package.json b/package.json index ccc4e40..7b5df97 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,9 @@ "remote_binary_bundling" : true, "remote_binary": [ { - "name": "lsfg-vk_noui.zip", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/fp16-test-2/lsfg-vk_noui.zip", - "sha256hash": "a406b3730144c2011e2c2acd3cf44f3ec6c048ee86099bc9c3ac90aa1515e5ec" + "name": "lsfg-vk-2.0.0.tar.xz", + "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", + "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" }, { "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", 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()) diff --git a/shared_config.py b/shared_config.py index 997717b..bb7eea6 100644 --- a/shared_config.py +++ b/shared_config.py @@ -1,17 +1,8 @@ -""" -Shared configuration schema constants. - -This file contains the canonical configuration schema that should be used -by both Python and TypeScript code. Any changes to the configuration -structure should be made here first. -""" - -from typing import Dict, Any, Union from enum import Enum +from typing import Dict, Union class ConfigFieldType(str, Enum): - """Configuration field types - must match TypeScript enum""" BOOLEAN = "boolean" INTEGER = "integer" FLOAT = "float" @@ -22,141 +13,111 @@ CONFIG_SCHEMA_DEF = { "dll": { "name": "dll", "fieldType": ConfigFieldType.STRING, - "default": "/games/Lossless Scaling/Lossless.dll", - "description": "specify where Lossless.dll is stored", - "location": "toml" + "default": "", + "description": "override the lsfg-vk.dll path", + "location": "toml", }, - "no_fp16": { "name": "no_fp16", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "force-disable fp16 (use on older nvidia cards)", - "location": "toml" + "description": "disable FP16 acceleration", + "location": "toml", }, - "multiplier": { "name": "multiplier", "fieldType": ConfigFieldType.INTEGER, "default": 1, - "description": "change the fps multiplier", - "location": "toml" + "description": "frame generation multiplier", + "location": "toml", }, - "flow_scale": { "name": "flow_scale", "fieldType": ConfigFieldType.FLOAT, - "default": 0.8, - "description": "change the flow scale", - "location": "toml" + "default": 1.0, + "description": "motion estimation resolution scale", + "location": "toml", }, - "performance_mode": { "name": "performance_mode", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "use a lighter model for FG (recommended for most games)", - "location": "toml" - }, - - "hdr_mode": { - "name": "hdr_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable HDR mode (only for games that support HDR)", - "location": "toml" + "description": "use the lighter frame generation model", + "location": "toml", }, - "experimental_present_mode": { "name": "experimental_present_mode", "fieldType": ConfigFieldType.STRING, "default": "fifo", - "description": "override Vulkan present mode (may cause crashes)", - "location": "toml" + "description": "control the v2 present mode override", + "location": "toml", }, - "dxvk_frame_rate": { "name": "dxvk_frame_rate", "fieldType": ConfigFieldType.INTEGER, "default": 0, "description": "base framerate cap for DirectX games before frame multiplier", - "location": "script" + "location": "script", }, - "enable_wow64": { "name": "enable_wow64", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)", - "location": "script" + "description": "enable PROTON_USE_WOW64=1 for 32-bit games", + "location": "script", }, - "disable_steamdeck_mode": { "name": "disable_steamdeck_mode", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "disable Steam Deck mode (unlocks hidden settings in some games)", - "location": "script" + "description": "disable Steam Deck mode", + "location": "script", }, - "mangohud_workaround": { "name": "mangohud_workaround", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", - "location": "script" + "description": "enable a transparent MangoHud overlay workaround", + "location": "script", }, - "disable_vkbasalt": { "name": "disable_vkbasalt", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", - "location": "script" + "description": "disable vkBasalt for games where it conflicts with lsfg-vk", + "location": "script", }, - "force_enable_vkbasalt": { "name": "force_enable_vkbasalt", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "location": "script" + "description": "force-enable vkBasalt", + "location": "script", }, - "enable_wsi": { "name": "enable_wsi", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Enable Gamescope WSI Layer, disable if frame generation isn't applying or isn't feeling smooth (use with HDR off)", - "location": "script" + "description": "enable the Gamescope WSI layer", + "location": "script", }, - "enable_zink": { "name": "enable_zink", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Enable Zink (Vulkan-based OpenGL implementation) for OpenGL games", - "location": "script" - } + "description": "enable Zink for OpenGL games", + "location": "script", + }, } def get_field_names() -> list[str]: - """Get ordered list of configuration field names""" - return list(CONFIG_SCHEMA_DEF.keys()) + return list(CONFIG_SCHEMA_DEF) def get_defaults() -> Dict[str, Union[bool, int, float, str]]: - """Get default configuration values""" - return { - field_name: field_def["default"] - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - } + return {name: definition["default"] for name, definition in CONFIG_SCHEMA_DEF.items()} def get_field_types() -> Dict[str, str]: - """Get field type mapping""" - return { - field_name: field_def["fieldType"].value - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - } + return {name: definition["fieldType"].value for name, definition in CONFIG_SCHEMA_DEF.items()} diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index a10aab2..e83bd58 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { - FLOW_SCALE, NO_FP16, PERFORMANCE_MODE, HDR_MODE, + FLOW_SCALE, NO_FP16, PERFORMANCE_MODE, EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK } from "../config/generatedConfigSchema"; @@ -149,8 +149,8 @@ export function ConfigurationSection({ onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")} /> @@ -165,14 +165,6 @@ export function ConfigurationSection({ /> - - onConfigChange(HDR_MODE, value)} - /> - )} diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 8b4fc1e..befbd8d 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -6,7 +6,7 @@ export { getFieldNames, getDefaults, getFieldTypes, - DLL, NO_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, + 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/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 3c5d34e..8fae79a 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -13,7 +13,6 @@ export const NO_FP16 = "no_fp16" as const; export const MULTIPLIER = "multiplier" as const; export const FLOW_SCALE = "flow_scale" as const; export const PERFORMANCE_MODE = "performance_mode" as const; -export const HDR_MODE = "hdr_mode" as const; export const EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const; export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const; export const ENABLE_WOW64 = "enable_wow64" as const; @@ -37,7 +36,7 @@ export const CONFIG_SCHEMA: Record = { dll: { name: "dll", fieldType: ConfigFieldType.STRING, - default: "/games/Lossless Scaling/Lossless.dll", + default: "", description: "specify where Lossless.dll is stored" }, no_fp16: { @@ -55,8 +54,8 @@ export const CONFIG_SCHEMA: Record = { flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, - default: 0.8, - description: "change the flow scale" + default: 1, + description: "motion estimation resolution scale" }, performance_mode: { name: "performance_mode", @@ -64,12 +63,6 @@ export const CONFIG_SCHEMA: Record = { default: false, description: "use a lighter model for FG (recommended for most games)" }, - hdr_mode: { - name: "hdr_mode", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable HDR mode (only for games that support HDR)" - }, experimental_present_mode: { name: "experimental_present_mode", fieldType: ConfigFieldType.STRING, @@ -133,7 +126,6 @@ export interface ConfigurationData { multiplier: number; flow_scale: number; performance_mode: boolean; - hdr_mode: boolean; experimental_present_mode: string; dxvk_frame_rate: number; enable_wow64: boolean; @@ -152,12 +144,11 @@ export function getFieldNames(): string[] { export function getDefaults(): ConfigurationData { return { - dll: "/games/Lossless Scaling/Lossless.dll", + dll: "", no_fp16: false, multiplier: 1, - flow_scale: 0.8, + flow_scale: 1, performance_mode: false, - hdr_mode: false, experimental_present_mode: "fifo", dxvk_frame_rate: 0, enable_wow64: false, @@ -177,7 +168,6 @@ export function getFieldTypes(): Record { multiplier: ConfigFieldType.INTEGER, flow_scale: ConfigFieldType.FLOAT, performance_mode: ConfigFieldType.BOOLEAN, - hdr_mode: ConfigFieldType.BOOLEAN, experimental_present_mode: ConfigFieldType.STRING, dxvk_frame_rate: ConfigFieldType.INTEGER, enable_wow64: ConfigFieldType.BOOLEAN, -- 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 --- package.json | 15 - py_modules/lsfg_vk/constants.py | 2 +- py_modules/lsfg_vk/flatpak_service.py | 662 ++++++++++++++-------------------- py_modules/lsfg_vk/plugin.py | 44 +-- src/api/lsfgApi.ts | 1 - src/components/FlatpaksModal.tsx | 40 -- 6 files changed, 279 insertions(+), 485 deletions(-) diff --git a/package.json b/package.json index 7b5df97..365ad02 100644 --- a/package.json +++ b/package.json @@ -52,21 +52,6 @@ "name": "lsfg-vk-2.0.0.tar.xz", "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v0.9.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "sha256hash": "8381d1eff0b0786af5a0e66955521eadec783866f73b9b1aa4664c952ed601a4" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v0.9.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "sha256hash": "70ea6d9d2a8adad63b8e5c2c3d94a636764ad4d0266ce9f37cb9dc7943d8fc3a" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v1.0.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "sha256hash": "0651bda96751ef0f1314a5179585926a0cd354476790ca2616662c39fe6fae54" } ], "pnpm": { 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") diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 82f37c8..2c9f4a9 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -84,7 +84,6 @@ export interface FlatpakExtensionStatus { success: boolean; message: string; error?: string; - installed_23_08: boolean; installed_24_08: boolean; installed_25_08: boolean; } diff --git a/src/components/FlatpaksModal.tsx b/src/components/FlatpaksModal.tsx index 16d0369..479b919 100644 --- a/src/components/FlatpaksModal.tsx +++ b/src/components/FlatpaksModal.tsx @@ -172,46 +172,6 @@ export const FlatpaksModal: FC = ({ closeModal }) => { {extensionStatus && extensionStatus.success ? ( <> - {/* 23.08 Runtime */} - - : } - > - { - const operation = extensionStatus.installed_23_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '23.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 23.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} - > - {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( - - ) : extensionStatus.installed_23_08 ? ( - <> - {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - - ) : ( - <> - {t('FLATPAK_INSTALL_BTN', 'Install')} - - )} - - - - {/* 24.08 Runtime */} 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(-) 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(-) 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(-) 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 f5994d1da763d5e10572fd10cc3e0089c9112924 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:55:31 -0400 Subject: chore: sync generated v2 schema --- src/config/generatedConfigSchema.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 8fae79a..20aa0e6 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -37,19 +37,19 @@ export const CONFIG_SCHEMA: Record = { name: "dll", fieldType: ConfigFieldType.STRING, default: "", - description: "specify where Lossless.dll is stored" + description: "override the lsfg-vk.dll path" }, no_fp16: { name: "no_fp16", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "force-disable fp16 (use on older nvidia cards)" + description: "disable FP16 acceleration" }, multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 1, - description: "change the fps multiplier" + description: "frame generation multiplier" }, flow_scale: { name: "flow_scale", @@ -61,13 +61,13 @@ export const CONFIG_SCHEMA: Record = { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "use a lighter model for FG (recommended for most games)" + description: "use the lighter frame generation model" }, experimental_present_mode: { name: "experimental_present_mode", fieldType: ConfigFieldType.STRING, default: "fifo", - description: "override Vulkan present mode (may cause crashes)" + description: "control the v2 present mode override" }, dxvk_frame_rate: { name: "dxvk_frame_rate", @@ -85,13 +85,13 @@ export const CONFIG_SCHEMA: Record = { name: "disable_steamdeck_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "disable Steam Deck mode (unlocks hidden settings in some games)" + description: "disable Steam Deck mode" }, mangohud_workaround: { name: "mangohud_workaround", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode" + description: "enable a transparent MangoHud overlay workaround" }, disable_vkbasalt: { name: "disable_vkbasalt", @@ -103,19 +103,19 @@ export const CONFIG_SCHEMA: Record = { name: "force_enable_vkbasalt", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Force vkBasalt to engage to fix framepacing issues in gamemode" + description: "force-enable vkBasalt" }, enable_wsi: { name: "enable_wsi", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Enable Gamescope WSI Layer, disable if frame generation isn't applying or isn't feeling smooth (use with HDR off)" + description: "enable the Gamescope WSI layer" }, enable_zink: { name: "enable_zink", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Enable Zink (Vulkan-based OpenGL implementation) for OpenGL games" + description: "enable Zink for OpenGL games" }, }; -- 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(-) 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 05c333ef555dbd0a18f650daf2b21e3728259383 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:58:04 -0400 Subject: docs: update lsfg-vk v2 usage --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e8bf1fa..199e5e7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ## What is this? -A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://github.com/PancakeTAS/lsfg-vk)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. +A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://lsfg-vk.dev/)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. ## Installation @@ -33,7 +33,7 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer -4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, performance mode, HDR settings, and experimental features +4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, FP16 acceleration, performance mode, and launch workarounds 5. **Apply launch option** to games you want to use frame generation with: - Add `~/lsfg %command%` to your game's launch options in Steam Properties - Or use the "Launch Option Clipboard" button in the plugin to copy the command @@ -44,7 +44,7 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali The plugin provides several configuration options to optimize frame generation for your games: ### Core Settings -- **FPS Multiplier**: Choose between 2x, 3x, or 4x frame generation +- **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) - **Performance Mode**: Uses a lighter processing model - recommended for most games - **HDR Mode**: Enable for games that support HDR output @@ -70,17 +70,17 @@ For per-game feedback and community support, please join the [decky-lsfg-vk Disc ## What it does The plugin: -- Automatically downloads and installs the latest lsfg-vk Vulkan layer to `~/.local/lib/` +- Installs the pinned lsfg-vk 2.0.0 x86_64 and x86 Vulkan layers from the official upstream build - Configures the Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` -- Creates a TOML configuration file in `~/.config/lsfg-vk/conf.toml` with your settings +- Creates and migrates the v2 TOML configuration in `~/.config/lsfg-vk/conf.toml`, preserving a one-time v1 backup during upgrade - Automatically detects your Lossless Scaling DLL installation - Provides an easy-to-use interface to configure frame generation settings: - - **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation + - **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality vs performance - **Performance Mode**: Use lighter processing for better performance - **HDR Mode**: Enable for HDR-compatible games - **Experimental Features**: Override present mode and set FPS limits -- **Hot-reloading**: Configuration changes apply immediately without restarting games +- **Hot-reloading**: Multiplier, flow scale, and performance mode changes apply without restarting games - Easy uninstallation that removes all installed files when no longer needed ## Credits -- cgit v1.2.3 From 230e26e967b985d09f2d753fdf58e48b4b367a0a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:58:28 -0400 Subject: docs: remove stale v1 references --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 199e5e7..66c11ff 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The plugin provides several configuration options to optimize frame generation f - **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) - **Performance Mode**: Uses a lighter processing model - recommended for most games -- **HDR Mode**: Enable for games that support HDR output +- **FP16 Acceleration**: Use half-precision acceleration when supported ## Feedback and Support @@ -78,14 +78,14 @@ The plugin: - **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality vs performance - **Performance Mode**: Use lighter processing for better performance - - **HDR Mode**: Enable for HDR-compatible games + - **FP16 Acceleration**: Use half-precision acceleration when supported - **Experimental Features**: Override present mode and set FPS limits - **Hot-reloading**: Multiplier, flow scale, and performance mode changes apply without restarting games - Easy uninstallation that removes all installed files when no longer needed ## Credits -- **[PancakeTAS](https://github.com/PancakeTAS/lsfg-vk)** for creating the lsfg-vk Vulkan compatibility layer +- **[PancakeTAS](https://lsfg-vk.dev/)** for creating the lsfg-vk Vulkan compatibility layer - **[Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/)** developers for the original frame generation technology - **[Deck Wizard](https://www.youtube.com/@DeckWizard)** - Extensive community support including comprehensive guides, promotional content, thorough testing and feedback, custom artworks, and tutorial videos. His passionate advocacy and continuous support have been instrumental in this plugin's success. - The **Decky Loader** team for the plugin framework -- cgit v1.2.3 From 77656ad88655effe0411808baf2fb90c629a24f8 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:00:50 -0400 Subject: chore: regenerate config schema --- src/config/generatedConfigSchema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 20aa0e6..edbde88 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -79,7 +79,7 @@ export const CONFIG_SCHEMA: Record = { name: "enable_wow64", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)" + description: "enable PROTON_USE_WOW64=1 for 32-bit games" }, disable_steamdeck_mode: { name: "disable_steamdeck_mode", @@ -97,7 +97,7 @@ export const CONFIG_SCHEMA: Record = { name: "disable_vkbasalt", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)" + description: "disable vkBasalt for games where it conflicts with lsfg-vk" }, force_enable_vkbasalt: { name: "force_enable_vkbasalt", -- 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 --- README.md | 4 +- package.json | 17 ++- 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 +-- src/api/lsfgApi.ts | 27 +--- src/components/Content.tsx | 21 ++-- src/components/FlatpaksModal.tsx | 67 ++++++++++ src/components/NerdStuffModal.tsx | 55 ++------- src/components/StatusDisplay.tsx | 21 ++-- src/hooks/useInstallationActions.ts | 12 +- src/hooks/useLsfgHooks.ts | 37 ++---- src/i18n/languages.json | 12 -- 19 files changed, 383 insertions(+), 531 deletions(-) delete mode 100644 py_modules/lsfg_vk/dll_detection.py create mode 100644 py_modules/lsfg_vk/runtime_service.py diff --git a/README.md b/README.md index 66c11ff..3d9ee7e 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ For per-game feedback and community support, please join the [decky-lsfg-vk Disc **Frame generation not working?** - Ensure you've added `~/lsfg %command%` to your game's launch options -- Check that the Lossless Scaling DLL was detected correctly in the plugin +- Ensure the Lossless Scaling installation is visible to the upstream lsfg-vk search paths - Try enabling Performance Mode if you're experiencing crashes - Make sure your game is running in fullscreen mode for best results @@ -73,7 +73,7 @@ The plugin: - Installs the pinned lsfg-vk 2.0.0 x86_64 and x86 Vulkan layers from the official upstream build - Configures the Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` - Creates and migrates the v2 TOML configuration in `~/.config/lsfg-vk/conf.toml`, preserving a one-time v1 backup during upgrade -- Automatically detects your Lossless Scaling DLL installation +- Delegates Lossless Scaling DLL discovery to lsfg-vk's upstream runtime search - Provides an easy-to-use interface to configure frame generation settings: - **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality vs performance diff --git a/package.json b/package.json index 365ad02..6cd5ebc 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,22 @@ { "name": "lsfg-vk-2.0.0.tar.xz", "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", - "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" + "sha256hash": "d8378b45d378150ea9aba803a0ba855d8ce91ad9b3366ee0eb2036b06b08380c" + }, + { + "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", + "sha256hash": "7eff81f96b278fde6fe395c88461c55e611547bf5991d179b9145ec6f5a778b1" + }, + { + "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", + "sha256hash": "cbd663b9021c355dddec61ec51fd4388c12705f16637cadd4b45aead0e5dc427" + }, + { + "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", + "sha256hash": "615e87c03b18368ed1cca97e036f1bf54fff95fc6126aac697bb368f33281cbf" } ], "pnpm": { 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] diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 2c9f4a9..68cf6bf 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -11,28 +11,8 @@ export interface InstallationResult { export interface InstallationStatus { installed: boolean; - lib_exists: boolean; - json_exists: boolean; - script_exists: boolean; - lib_path: string; - json_path: string; - script_path: string; - error?: string; -} - -export interface DllDetectionResult { - detected: boolean; - path?: string; - source?: string; - message?: string; - error?: string; -} - -export interface DllStatsResult { - success: boolean; - dll_path?: string; - dll_sha256?: string; - dll_source?: string; + lossless_scaling_installed: boolean; + lossless_scaling_status: string; error?: string; } @@ -84,6 +64,7 @@ export interface FlatpakExtensionStatus { success: boolean; message: string; error?: string; + installed_23_08: boolean; installed_24_08: boolean; installed_25_08: boolean; } @@ -131,8 +112,6 @@ export interface ProfileResult { export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); -export const checkLosslessScalingDll = callable<[], DllDetectionResult>("check_lossless_scaling_dll"); -export const getDllStats = callable<[], DllStatsResult>("get_dll_stats"); export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config"); export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema"); export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_option"); diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 5988e79..aab2fb8 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; -import { useInstallationStatus, useDllDetection, useLsfgConfig } from "../hooks/useLsfgHooks"; +import { useInstallationStatus, useLsfgConfig } from "../hooks/useLsfgHooks"; import { useProfileManagement } from "../hooks/useProfileManagement"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { StatusDisplay } from "./StatusDisplay"; @@ -21,11 +21,12 @@ export function Content() { isInstalled, installationStatus, setIsInstalled, - setInstallationStatus + setInstallationStatus, + losslessScalingInstalled, + losslessScalingStatus, + checkInstallation } = useInstallationStatus(); - const { dllDetected, dllDetectionStatus } = useDllDetection(); - const { config, loadLsfgConfig, @@ -59,11 +60,11 @@ export function Content() { }; const onInstall = () => { - handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig); + handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig, checkInstallation); }; const onUninstall = () => { - handleUninstall(setIsInstalled, setInstallationStatus); + handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); }; const handleShowNerdStuff = () => { @@ -87,10 +88,10 @@ export function Content() { /> )} @@ -167,10 +168,10 @@ export function Content() { {isInstalled && ( <> void; @@ -38,6 +39,7 @@ export const FlatpaksModal: FC = ({ closeModal }) => { const [flatpakApps, setFlatpakApps] = useState(null); const [loading, setLoading] = useState(true); const [operationInProgress, setOperationInProgress] = useState(null); + const [operationError, setOperationError] = useState(null); const loadData = async () => { setLoading(true); @@ -63,6 +65,7 @@ export const FlatpaksModal: FC = ({ closeModal }) => { const handleExtensionOperation = async (operation: 'install' | 'uninstall', version: string) => { const operationId = `${operation}-${version}`; setOperationInProgress(operationId); + setOperationError(null); try { const result = operation === 'install' @@ -73,9 +76,16 @@ export const FlatpaksModal: FC = ({ closeModal }) => { // Reload status after operation const newStatus = await checkFlatpakExtensionStatus(); setExtensionStatus(newStatus); + } else { + const message = result.error || result.message || 'Flatpak operation failed'; + setOperationError(message); + showErrorToast('Flatpak operation failed', message); } } catch (error) { console.error(`Error ${operation}ing extension:`, error); + const message = String(error); + setOperationError(message); + showErrorToast('Flatpak operation failed', message); } finally { setOperationInProgress(null); } @@ -85,6 +95,7 @@ export const FlatpaksModal: FC = ({ closeModal }) => { const hasOverrides = app.has_filesystem_override && app.has_env_override; const operationId = `app-${app.app_id}`; setOperationInProgress(operationId); + setOperationError(null); try { const result = hasOverrides @@ -95,9 +106,16 @@ export const FlatpaksModal: FC = ({ closeModal }) => { // Reload apps data after operation const newApps = await getFlatpakApps(); setFlatpakApps(newApps); + } else { + const message = result.error || result.message || 'Flatpak override failed'; + setOperationError(message); + showErrorToast('Flatpak override failed', message); } } catch (error) { console.error('Error toggling app override:', error); + const message = String(error); + setOperationError(message); + showErrorToast('Flatpak override failed', message); } finally { setOperationInProgress(null); } @@ -170,8 +188,57 @@ export const FlatpaksModal: FC = ({ closeModal }) => { {t('FLATPAK_RUNTIME_INSTALLER', 'Runtime Extension Installer')} + {operationError && ( + + } + /> + + )} + {extensionStatus && extensionStatus.success ? ( <> + + : } + > + { + const operation = extensionStatus.installed_23_08 ? 'uninstall' : 'install'; + const action = () => handleExtensionOperation(operation, '23.08'); + + if (operation === 'uninstall') { + confirmOperation( + action, + t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), + `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 23.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` + ); + } else { + action(); + } + }} + disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} + > + {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( + + ) : extensionStatus.installed_23_08 ? ( + <> + {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} + + ) : ( + <> + {t('FLATPAK_INSTALL_BTN', 'Install')} + + )} + + + + {/* 24.08 Runtime */} (null); const [configContent, setConfigContent] = useState(null); const [scriptContent, setScriptContent] = useState(null); const [loading, setLoading] = useState(true); @@ -28,13 +31,11 @@ export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { setError(null); // Load all data in parallel - const [dllResult, configResult, scriptResult] = await Promise.all([ - getDllStats(), + const [configResult, scriptResult] = await Promise.all([ getConfigFileContent(), - getLaunchScriptContent() + getLaunchScriptContent(), ]); - setDllStats(dllResult); setConfigContent(configResult); setScriptContent(scriptResult); } catch (err) { @@ -47,11 +48,6 @@ export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { loadData(); }, []); - const formatSHA256 = (hash: string) => { - // Format SHA256 hash for better readability (add spaces every 8 characters) - return hash.replace(/(.{8})/g, '$1 ').trim(); - }; - const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); @@ -73,41 +69,6 @@ export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { {!loading && !error && ( <> - {/* DLL Stats Section */} - {dllStats && ( - <> - {!dllStats.success ? ( -
{dllStats.error || "Failed to get DLL stats"}
- ) : ( -
- - dllStats.dll_path && copyToClipboard(dllStats.dll_path)} - onActivate={() => dllStats.dll_path && copyToClipboard(dllStats.dll_path)} - > - {dllStats.dll_path || t('NERD_NOT_AVAILABLE', 'Not available')} - - - - - dllStats.dll_sha256 && copyToClipboard(dllStats.dll_sha256)} - onActivate={() => dllStats.dll_sha256 && copyToClipboard(dllStats.dll_sha256)} - > - {dllStats.dll_sha256 ? formatSHA256(dllStats.dll_sha256) : t('NERD_NOT_AVAILABLE', 'Not available')} - - - - {dllStats.dll_source && ( - -
{dllStats.dll_source}
-
- )} -
- )} - - )} - {/* Launch Script Section */} {scriptContent && ( @@ -168,7 +129,7 @@ export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { )} )} - + {/* Close Button */} diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx index 3a48a15..31584eb 100644 --- a/src/components/StatusDisplay.tsx +++ b/src/components/StatusDisplay.tsx @@ -1,24 +1,24 @@ import { PanelSectionRow } from "@decky/ui"; interface StatusDisplayProps { - dllDetected: boolean; - dllDetectionStatus: string; isInstalled: boolean; installationStatus: string; + losslessScalingInstalled: boolean; + losslessScalingStatus: string; } export function StatusDisplay({ - dllDetected, - dllDetectionStatus, isInstalled, - installationStatus + installationStatus, + losslessScalingInstalled, + losslessScalingStatus }: StatusDisplayProps) { return (
- {dllDetected ? "✅" : "❌"} + {losslessScalingInstalled ? "✅" : "❌"} - {dllDetectionStatus} + {losslessScalingInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"}
+ {!losslessScalingInstalled && losslessScalingStatus && ( +
+ {losslessScalingStatus} +
+ )}
void, setInstallationStatus: (value: string) => void, - reloadConfig?: () => Promise + reloadConfig?: () => Promise, + reloadStatus?: () => Promise ) => { setIsInstalling(true); setInstallationStatus("Installing lsfg-vk..."); @@ -30,6 +31,9 @@ export function useInstallationActions() { if (reloadConfig) { await reloadConfig(); } + if (reloadStatus) { + await reloadStatus(); + } } else { setInstallationStatus(`Installation failed: ${result.error}`); showInstallErrorToast(result.error); @@ -44,7 +48,8 @@ export function useInstallationActions() { const handleUninstall = async ( setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void + setInstallationStatus: (value: string) => void, + reloadStatus?: () => Promise ) => { setIsUninstalling(true); setInstallationStatus("Uninstalling lsfg-vk..."); @@ -54,6 +59,9 @@ export function useInstallationActions() { if (result.success) { setIsInstalled(false); setInstallationStatus("lsfg-vk uninstalled successfully!"); + if (reloadStatus) { + await reloadStatus(); + } showUninstallSuccessToast(); } else { setInstallationStatus(`Uninstallation failed: ${result.error}`); diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index d9bbe3e..597110e 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { checkLsfgVkInstalled, - checkLosslessScalingDll, getLsfgConfig, updateLsfgConfigFromObject, type ConfigUpdateResult @@ -12,11 +11,15 @@ import { showErrorToast, ToastMessages } from "../utils/toastUtils"; export function useInstallationStatus() { const [isInstalled, setIsInstalled] = useState(false); const [installationStatus, setInstallationStatus] = useState(""); + const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); + const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); const checkInstallation = async () => { try { const status = await checkLsfgVkInstalled(); setIsInstalled(status.installed); + setLosslessScalingInstalled(status.lossless_scaling_installed); + setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed"); if (status.installed) { setInstallationStatus("lsfg-vk Installed"); } else { @@ -24,6 +27,8 @@ export function useInstallationStatus() { } return status.installed; } catch (error) { + setLosslessScalingInstalled(false); + setLosslessScalingStatus("Lossless Scaling Not Installed"); setInstallationStatus("lsfg-vk Not Installed"); return false; } @@ -38,38 +43,12 @@ export function useInstallationStatus() { installationStatus, setIsInstalled, setInstallationStatus, + losslessScalingInstalled, + losslessScalingStatus, checkInstallation }; } -export function useDllDetection() { - const [dllDetected, setDllDetected] = useState(false); - const [dllDetectionStatus, setDllDetectionStatus] = useState(""); - - const checkDllDetection = async () => { - try { - const result = await checkLosslessScalingDll(); - setDllDetected(result.detected); - if (result.detected) { - setDllDetectionStatus("Lossless Scaling Installed"); - } else { - setDllDetectionStatus("Lossless Scaling Not Installed"); - } - } catch (error) { - setDllDetectionStatus("Lossless Scaling Not Installed"); - } - }; - - useEffect(() => { - checkDllDetection(); - }, []); - - return { - dllDetected, - dllDetectionStatus - }; -} - export function useLsfgConfig() { const [config, setConfig] = useState(() => getDefaults()); diff --git a/src/i18n/languages.json b/src/i18n/languages.json index c1a49b8..46b4cd9 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -67,10 +67,6 @@ "FLATPAK_STEP_FINAL": "最終的な結果はこのようになります:", "FLATPAK_CLOSE": "閉じる", "NERD_LOADING": "情報を読み込み中...", - "NERD_DLL_PATH": "DLLパス", - "NERD_NOT_AVAILABLE": "利用不可", - "NERD_DLL_HASH": "DLL SHA256ハッシュ", - "NERD_DETECTION_SOURCE": "検出ソース", "NERD_LAUNCH_SCRIPT": "起動スクリプト", "NERD_SCRIPT_NOT_FOUND_PREFIX": "スクリプトが見つかりません:", "NERD_PATH_PREFIX": "パス:", @@ -174,10 +170,6 @@ "FLATPAK_STEP_FINAL": "최종 결과는 다음과 같아야 합니다:", "FLATPAK_CLOSE": "닫기", "NERD_LOADING": "정보 불러오는 중...", - "NERD_DLL_PATH": "DLL 경로", - "NERD_NOT_AVAILABLE": "사용 불가", - "NERD_DLL_HASH": "DLL SHA256 해시", - "NERD_DETECTION_SOURCE": "감지 소스", "NERD_LAUNCH_SCRIPT": "실행 스크립트", "NERD_SCRIPT_NOT_FOUND_PREFIX": "스크립트 없음:", "NERD_PATH_PREFIX": "경로:", @@ -309,10 +301,6 @@ "FLATPAK_STEP_FINAL": "Final result should look like:", "FLATPAK_CLOSE": "Close", "NERD_LOADING": "Loading information...", - "NERD_DLL_PATH": "DLL Path", - "NERD_NOT_AVAILABLE": "Not available", - "NERD_DLL_HASH": "DLL SHA256 Hash", - "NERD_DETECTION_SOURCE": "Detection Source", "NERD_LAUNCH_SCRIPT": "Launch Script", "NERD_SCRIPT_NOT_FOUND_PREFIX": "Script not found:", "NERD_PATH_PREFIX": "Path:", -- 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 +++ src/api/lsfgApi.ts | 19 +++ src/components/Content.tsx | 22 +++ src/components/StatusDisplay.tsx | 115 ++++++++++----- src/hooks/useLsfgHooks.ts | 46 +++++- 8 files changed, 463 insertions(+), 39 deletions(-) create mode 100644 py_modules/lsfg_vk/steam_service.py 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] diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 68cf6bf..64d43cb 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -16,6 +16,23 @@ export interface InstallationStatus { error?: string; } +export interface SteamBranchStatus { + success: boolean; + message: string; + error?: string; + installed: boolean; + manifest_path?: string; + selected_branch?: string; + current_branch?: string; + target_branch: string; + needs_switch: boolean; + restart_required: boolean; +} + +export interface SteamBranchOperationResult extends SteamBranchStatus { + changed: boolean; +} + // Use centralized configuration data type export type LsfgConfig = ConfigurationData; @@ -112,6 +129,8 @@ export interface ProfileResult { export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); +export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); +export const selectLosslessScalingBranch = callable<[], SteamBranchOperationResult>("select_lossless_scaling_branch"); export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config"); export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema"); export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_option"); diff --git a/src/components/Content.tsx b/src/components/Content.tsx index aab2fb8..4e4e33a 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -15,6 +15,7 @@ import { NerdStuffModal } from "./NerdStuffModal"; import { FlatpaksModal } from "./FlatpaksModal"; import { ConfigurationData } from "../config/configSchema"; import t from '../i18n/i18n'; +import { showErrorToast, showSuccessToast } from "../utils/toastUtils"; export function Content() { const { @@ -24,6 +25,9 @@ export function Content() { setInstallationStatus, losslessScalingInstalled, losslessScalingStatus, + steamBranchStatus, + isSwitchingSteamBranch, + selectLosslessScalingBranch, checkInstallation } = useInstallationStatus(); @@ -67,6 +71,18 @@ export function Content() { handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); }; + const onSelectLosslessScalingBranch = async () => { + const result = await selectLosslessScalingBranch(); + if (result.success) { + showSuccessToast("Steam branch selected", result.message); + } else { + showErrorToast( + "Steam branch selection failed", + result.error || "Unable to select the lsfg-vk Steam branch" + ); + } + }; + const handleShowNerdStuff = () => { showModal(); }; @@ -92,6 +108,9 @@ export function Content() { installationStatus={installationStatus} losslessScalingInstalled={losslessScalingInstalled} losslessScalingStatus={losslessScalingStatus} + steamBranchStatus={steamBranchStatus} + isSwitchingSteamBranch={isSwitchingSteamBranch} + onSelectLosslessScalingBranch={onSelectLosslessScalingBranch} /> )} @@ -172,6 +191,9 @@ export function Content() { installationStatus={installationStatus} losslessScalingInstalled={losslessScalingInstalled} losslessScalingStatus={losslessScalingStatus} + steamBranchStatus={steamBranchStatus} + isSwitchingSteamBranch={isSwitchingSteamBranch} + onSelectLosslessScalingBranch={onSelectLosslessScalingBranch} /> void; } export function StatusDisplay({ isInstalled, installationStatus, losslessScalingInstalled, - losslessScalingStatus + losslessScalingStatus, + steamBranchStatus, + isSwitchingSteamBranch, + onSelectLosslessScalingBranch }: StatusDisplayProps) { return ( - -
-
- - {losslessScalingInstalled ? "✅" : "❌"} - - {losslessScalingInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"} -
- {!losslessScalingInstalled && losslessScalingStatus && ( -
- {losslessScalingStatus} + <> + +
+
+ + {losslessScalingInstalled ? "✅" : "❌"} + + {losslessScalingInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"} +
+ {!losslessScalingInstalled && losslessScalingStatus && ( +
+ {losslessScalingStatus} +
+ )} +
+ + {isInstalled ? "✅" : "❌"} + + {installationStatus}
- )} -
- - {isInstalled ? "✅" : "❌"} - - {installationStatus}
-
-
+ + + {losslessScalingInstalled && steamBranchStatus?.installed && ( + +
+
+ Steam branch: {steamBranchStatus.current_branch || "public"} + {steamBranchStatus.needs_switch && ( +
+ {steamBranchStatus.message} +
+ )} +
+ {steamBranchStatus.needs_switch && ( + + {isSwitchingSteamBranch ? "Selecting lsfg-vk..." : "Use lsfg-vk Steam branch"} + + )} +
+
+ )} + ); } diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index 597110e..dfbf2cd 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -2,8 +2,12 @@ import { useState, useEffect, useCallback } from "react"; import { checkLsfgVkInstalled, getLsfgConfig, + getLosslessScalingBranchStatus, + selectLosslessScalingBranch, updateLsfgConfigFromObject, - type ConfigUpdateResult + type ConfigUpdateResult, + type SteamBranchOperationResult, + type SteamBranchStatus } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; import { showErrorToast, ToastMessages } from "../utils/toastUtils"; @@ -13,8 +17,17 @@ export function useInstallationStatus() { const [installationStatus, setInstallationStatus] = useState(""); const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); + const [steamBranchStatus, setSteamBranchStatus] = useState(null); + const [isSwitchingSteamBranch, setIsSwitchingSteamBranch] = useState(false); const checkInstallation = async () => { + try { + setSteamBranchStatus(await getLosslessScalingBranchStatus()); + } catch (error) { + console.error("Error checking Lossless Scaling Steam branch:", error); + setSteamBranchStatus(null); + } + try { const status = await checkLsfgVkInstalled(); setIsInstalled(status.installed); @@ -27,6 +40,7 @@ export function useInstallationStatus() { } return status.installed; } catch (error) { + setSteamBranchStatus(null); setLosslessScalingInstalled(false); setLosslessScalingStatus("Lossless Scaling Not Installed"); setInstallationStatus("lsfg-vk Not Installed"); @@ -34,6 +48,33 @@ export function useInstallationStatus() { } }; + const selectLosslessScalingBranchForUser = async (): Promise => { + setIsSwitchingSteamBranch(true); + try { + const result = await selectLosslessScalingBranch(); + setSteamBranchStatus(result); + return result; + } catch (error) { + const result: SteamBranchOperationResult = { + success: false, + message: "", + error: String(error), + installed: false, + manifest_path: undefined, + selected_branch: undefined, + current_branch: undefined, + target_branch: "lsfg-vk", + needs_switch: false, + restart_required: false, + changed: false + }; + setSteamBranchStatus(result); + return result; + } finally { + setIsSwitchingSteamBranch(false); + } + }; + useEffect(() => { checkInstallation(); }, []); @@ -45,6 +86,9 @@ export function useInstallationStatus() { setInstallationStatus, losslessScalingInstalled, losslessScalingStatus, + steamBranchStatus, + isSwitchingSteamBranch, + selectLosslessScalingBranch: selectLosslessScalingBranchForUser, checkInstallation }; } -- cgit v1.2.3 From f9352694a78a2bcfd00d237398a2b30de14c70be Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 00:06:58 -0400 Subject: fix: show Steam branch recovery action --- src/components/StatusDisplay.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx index b6290b0..5eb1823 100644 --- a/src/components/StatusDisplay.tsx +++ b/src/components/StatusDisplay.tsx @@ -20,13 +20,15 @@ export function StatusDisplay({ isSwitchingSteamBranch, onSelectLosslessScalingBranch }: StatusDisplayProps) { + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + return ( <>
- {losslessScalingInstalled ? "✅" : "❌"} + {losslessScalingAppInstalled ? "✅" : "❌"} - {losslessScalingInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"} + {losslessScalingAppInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"}
- {!losslessScalingInstalled && losslessScalingStatus && ( + {!losslessScalingAppInstalled && losslessScalingStatus && (
{losslessScalingStatus}
@@ -61,7 +63,7 @@ export function StatusDisplay({
- {losslessScalingInstalled && steamBranchStatus?.installed && ( + {steamBranchStatus?.installed && (
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 -- scripts/generate_python_boilerplate.py | 2 +- scripts/generate_ts_schema.py | 8 +-- src/api/lsfgApi.ts | 5 -- src/components/Content.tsx | 19 ------ src/components/StatusDisplay.tsx | 23 +------ src/hooks/useLsfgHooks.ts | 32 ---------- 9 files changed, 7 insertions(+), 196 deletions(-) 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] diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py index a03aa2b..d134337 100644 --- a/scripts/generate_python_boilerplate.py +++ b/scripts/generate_python_boilerplate.py @@ -244,7 +244,7 @@ def main(): print(f"Generated {schema_file.relative_to(project_root)}") except Exception as e: - print(f"❌ Error generating Python files: {e}") + print(f"Error generating Python files: {e}") sys.exit(1) diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py index c4c0e8a..27969f1 100644 --- a/scripts/generate_ts_schema.py +++ b/scripts/generate_ts_schema.py @@ -132,11 +132,11 @@ def main(): target_file = project_root / "src" / "config" / "generatedConfigSchema.ts" target_file.write_text(ts_content) - print(f"✅ Generated {target_file} from shared_config.py") + print(f"Generated {target_file} from shared_config.py") print(f" Fields: {len(CONFIG_SCHEMA_DEF)}") # Also generate Python boilerplate - print("\n🔄 Generating Python boilerplate...") + print("\nGenerating Python boilerplate...") from pathlib import Path import subprocess @@ -147,10 +147,10 @@ def main(): if result.returncode == 0: print(result.stdout) else: - print(f"⚠️ Python boilerplate generation had issues:\n{result.stderr}") + print(f"Warning: Python boilerplate generation had issues:\n{result.stderr}") except Exception as e: - print(f"❌ Error generating schema: {e}") + print(f"Error generating schema: {e}") sys.exit(1) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 64d43cb..c0d6528 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -29,10 +29,6 @@ export interface SteamBranchStatus { restart_required: boolean; } -export interface SteamBranchOperationResult extends SteamBranchStatus { - changed: boolean; -} - // Use centralized configuration data type export type LsfgConfig = ConfigurationData; @@ -130,7 +126,6 @@ export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk") export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); -export const selectLosslessScalingBranch = callable<[], SteamBranchOperationResult>("select_lossless_scaling_branch"); export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config"); export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema"); export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_option"); diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 4e4e33a..01f94c9 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -15,7 +15,6 @@ import { NerdStuffModal } from "./NerdStuffModal"; import { FlatpaksModal } from "./FlatpaksModal"; import { ConfigurationData } from "../config/configSchema"; import t from '../i18n/i18n'; -import { showErrorToast, showSuccessToast } from "../utils/toastUtils"; export function Content() { const { @@ -26,8 +25,6 @@ export function Content() { losslessScalingInstalled, losslessScalingStatus, steamBranchStatus, - isSwitchingSteamBranch, - selectLosslessScalingBranch, checkInstallation } = useInstallationStatus(); @@ -71,18 +68,6 @@ export function Content() { handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); }; - const onSelectLosslessScalingBranch = async () => { - const result = await selectLosslessScalingBranch(); - if (result.success) { - showSuccessToast("Steam branch selected", result.message); - } else { - showErrorToast( - "Steam branch selection failed", - result.error || "Unable to select the lsfg-vk Steam branch" - ); - } - }; - const handleShowNerdStuff = () => { showModal(); }; @@ -109,8 +94,6 @@ export function Content() { losslessScalingInstalled={losslessScalingInstalled} losslessScalingStatus={losslessScalingStatus} steamBranchStatus={steamBranchStatus} - isSwitchingSteamBranch={isSwitchingSteamBranch} - onSelectLosslessScalingBranch={onSelectLosslessScalingBranch} /> )} @@ -192,8 +175,6 @@ export function Content() { losslessScalingInstalled={losslessScalingInstalled} losslessScalingStatus={losslessScalingStatus} steamBranchStatus={steamBranchStatus} - isSwitchingSteamBranch={isSwitchingSteamBranch} - onSelectLosslessScalingBranch={onSelectLosslessScalingBranch} /> void; } export function StatusDisplay({ @@ -16,9 +14,7 @@ export function StatusDisplay({ installationStatus, losslessScalingInstalled, losslessScalingStatus, - steamBranchStatus, - isSwitchingSteamBranch, - onSelectLosslessScalingBranch + steamBranchStatus }: StatusDisplayProps) { const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; @@ -36,9 +32,6 @@ export function StatusDisplay({ gap: "6px" }} > - - {losslessScalingAppInstalled ? "✅" : "❌"} - {losslessScalingAppInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"}
{!losslessScalingAppInstalled && losslessScalingStatus && ( @@ -55,9 +48,6 @@ export function StatusDisplay({ gap: "6px" }} > - - {isInstalled ? "✅" : "❌"} - {installationStatus}
@@ -80,15 +70,6 @@ export function StatusDisplay({
)}
- {steamBranchStatus.needs_switch && ( - - {isSwitchingSteamBranch ? "Selecting lsfg-vk..." : "Use lsfg-vk Steam branch"} - - )}
)} diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index dfbf2cd..ea8b3d0 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -3,10 +3,8 @@ import { checkLsfgVkInstalled, getLsfgConfig, getLosslessScalingBranchStatus, - selectLosslessScalingBranch, updateLsfgConfigFromObject, type ConfigUpdateResult, - type SteamBranchOperationResult, type SteamBranchStatus } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; @@ -18,7 +16,6 @@ export function useInstallationStatus() { const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); const [steamBranchStatus, setSteamBranchStatus] = useState(null); - const [isSwitchingSteamBranch, setIsSwitchingSteamBranch] = useState(false); const checkInstallation = async () => { try { @@ -48,33 +45,6 @@ export function useInstallationStatus() { } }; - const selectLosslessScalingBranchForUser = async (): Promise => { - setIsSwitchingSteamBranch(true); - try { - const result = await selectLosslessScalingBranch(); - setSteamBranchStatus(result); - return result; - } catch (error) { - const result: SteamBranchOperationResult = { - success: false, - message: "", - error: String(error), - installed: false, - manifest_path: undefined, - selected_branch: undefined, - current_branch: undefined, - target_branch: "lsfg-vk", - needs_switch: false, - restart_required: false, - changed: false - }; - setSteamBranchStatus(result); - return result; - } finally { - setIsSwitchingSteamBranch(false); - } - }; - useEffect(() => { checkInstallation(); }, []); @@ -87,8 +57,6 @@ export function useInstallationStatus() { losslessScalingInstalled, losslessScalingStatus, steamBranchStatus, - isSwitchingSteamBranch, - selectLosslessScalingBranch: selectLosslessScalingBranchForUser, checkInstallation }; } -- 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 --- README.md | 13 +- justfile | 9 +- 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 +- scripts/generate_python_boilerplate.py | 252 -------------- scripts/generate_ts_schema.py | 158 --------- shared_config.py | 123 ------- src/api/lsfgApi.ts | 54 +-- src/components/ConfigurationSection.tsx | 307 ++--------------- src/components/Content.tsx | 48 +-- src/components/FpsMultiplierControl.tsx | 6 +- src/components/GameConfigurationSelector.tsx | 29 ++ src/components/ProfileManagement.tsx | 477 -------------------------- src/components/index.ts | 2 +- src/config/configSchema.ts | 6 +- src/config/generatedConfigSchema.ts | 193 ++--------- src/hooks/useGameConfiguration.ts | 68 ++++ src/hooks/useProfileManagement.ts | 194 ----------- 24 files changed, 503 insertions(+), 2408 deletions(-) delete mode 100644 py_modules/lsfg_vk/config_schema_generated.py delete mode 100644 scripts/generate_python_boilerplate.py delete mode 100644 scripts/generate_ts_schema.py delete mode 100644 shared_config.py create mode 100644 src/components/GameConfigurationSelector.tsx delete mode 100644 src/components/ProfileManagement.tsx create mode 100644 src/hooks/useGameConfiguration.ts delete mode 100644 src/hooks/useProfileManagement.ts diff --git a/README.md b/README.md index 3d9ee7e..4be9cca 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,18 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer -4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, FP16 acceleration, performance mode, and launch workarounds +4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings 5. **Apply launch option** to games you want to use frame generation with: - Add `~/lsfg %command%` to your game's launch options in Steam Properties - Or use the "Launch Option Clipboard" button in the plugin to copy the command -6. **Launch your game** - frame generation will activate automatically using your plugin configuration +6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile ## Configuration Options -The plugin provides several configuration options to optimize frame generation for your games: +The plugin edits upstream lsfg-vk v2 profiles directly. Unassigned games leave the layer unloaded. ### Core Settings -- **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation +- **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) - **Performance Mode**: Uses a lighter processing model - recommended for most games - **FP16 Acceleration**: Use half-precision acceleration when supported @@ -79,8 +79,9 @@ The plugin: - **Flow Scale**: Adjust motion estimation quality vs performance - **Performance Mode**: Use lighter processing for better performance - **FP16 Acceleration**: Use half-precision acceleration when supported - - **Experimental Features**: Override present mode and set FPS limits -- **Hot-reloading**: Multiplier, flow scale, and performance mode changes apply without restarting games +- **Present Mode**: Override FIFO/VSync behavior +- **Swapchain Image Count**: Preserve the application's swapchain image count +- **Hot-reloading**: Upstream reloads settings for the active profile; profile assignment itself applies on the next launch - Easy uninstallation that removes all installed files when no longer needed ## Credits diff --git a/justfile b/justfile index 54148ad..b190631 100644 --- a/justfile +++ b/justfile @@ -1,11 +1,8 @@ default: - echo "Available recipes: build, test, clean, generate-schema" - -generate-schema: - python3 scripts/generate_ts_schema.py + echo "Available recipes: build, test, clean" build: - python3 scripts/generate_ts_schema.py && sudo rm -rf node_modules && .vscode/build.sh + .vscode/build.sh test: scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop @@ -18,4 +15,4 @@ cef: clean: rm -rf node_modules dist - sudo rm -rf /tmp/decky \ No newline at end of file + sudo rm -rf /tmp/decky 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] diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py deleted file mode 100644 index d134337..0000000 --- a/scripts/generate_python_boilerplate.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate Python boilerplate from shared_config.py - -This script generates repetitive Python code patterns from the canonical schema, -reducing manual maintenance when adding/removing configuration fields. -""" - -import sys -from pathlib import Path - -# Add project root to path to import shared_config -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - - -def get_python_type(field_type: ConfigFieldType) -> str: - """Convert ConfigFieldType to Python type annotation""" - type_map = { - ConfigFieldType.BOOLEAN: "bool", - ConfigFieldType.INTEGER: "int", - ConfigFieldType.FLOAT: "float", - ConfigFieldType.STRING: "str" - } - return type_map.get(field_type, "Any") - - -def get_env_var_name(field_name: str) -> str: - """Convert field name to environment variable name""" - env_map = { - "dxvk_frame_rate": "DXVK_FRAME_RATE", - "enable_wow64": "PROTON_USE_WOW64", - "disable_steamdeck_mode": "SteamDeck", - "mangohud_workaround": "MANGOHUD", - "disable_vkbasalt": "DISABLE_VKBASALT", - "force_enable_vkbasalt": "ENABLE_VKBASALT", - "enable_wsi": "ENABLE_GAMESCOPE_WSI", - "enable_zink": "ZINK_ENABLE" - } - return env_map.get(field_name, field_name.upper()) - - -def generate_typed_dict() -> str: - """Generate ConfigurationData TypedDict""" - lines = [ - "class ConfigurationData(TypedDict):", - " \"\"\"Type-safe configuration data structure - AUTO-GENERATED\"\"\"" - ] - - for field_name, field_def in CONFIG_SCHEMA_DEF.items(): - python_type = get_python_type(ConfigFieldType(field_def["fieldType"])) - lines.append(f" {field_name}: {python_type}") - - return "\n".join(lines) - - -def generate_script_parsing() -> str: - """Generate script content parsing logic""" - lines = [] - - script_fields = [ - (field_name, field_def) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" - ] - - for field_name, field_def in script_fields: - env_var = get_env_var_name(field_name) - field_type = ConfigFieldType(field_def["fieldType"]) - - if field_type == ConfigFieldType.BOOLEAN: - if field_name == "disable_steamdeck_mode": - # Special case: SteamDeck=0 means disable_steamdeck_mode=True - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value == "0"') - elif field_name == "enable_wsi": - # Special case: ENABLE_GAMESCOPE_WSI=0 or DXVK_HDR=0 means enable_wsi=False - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value != "0"') - lines.append(f' elif key == "DXVK_HDR":') - lines.append(f' script_values["{field_name}"] = value != "0"') - elif field_name == "enable_zink": - # Special case: Zink uses multiple environment variables - lines.append(f' elif key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa":') - lines.append(f' script_values["{field_name}"] = True') - lines.append(f' elif key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink":') - lines.append(f' script_values["{field_name}"] = True') - lines.append(f' elif key == "GALLIUM_DRIVER" and value == "zink":') - lines.append(f' script_values["{field_name}"] = True') - else: - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value == "1"') - elif field_type == ConfigFieldType.INTEGER: - lines.append(f' elif key == "{env_var}":') - lines.append(' try:') - lines.append(f' script_values["{field_name}"] = int(value)') - lines.append(' except ValueError:') - lines.append(' pass') - elif field_type == ConfigFieldType.FLOAT: - lines.append(f' elif key == "{env_var}":') - lines.append(' try:') - lines.append(f' script_values["{field_name}"] = float(value)') - lines.append(' except ValueError:') - lines.append(' pass') - elif field_type == ConfigFieldType.STRING: - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value') - - return "\n".join(lines) - - -def generate_script_generation() -> str: - """Generate script content generation logic""" - lines = [] - - script_fields = [ - (field_name, field_def) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" - ] - - for field_name, field_def in script_fields: - env_var = get_env_var_name(field_name) - field_type = ConfigFieldType(field_def["fieldType"]) - - if field_type == ConfigFieldType.BOOLEAN: - if field_name == "disable_steamdeck_mode": - # Special case: disable_steamdeck_mode=True should export SteamDeck=0 - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=0")') - elif field_name == "enable_wsi": - # Special case: enable_wsi=False should export ENABLE_GAMESCOPE_WSI=0 and DXVK_HDR=0 - lines.append(f' if not config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=0")') - lines.append(f' lines.append("export DXVK_HDR=0")') - elif field_name == "enable_zink": - # Special case: enable_zink=True should export multiple Zink environment variables - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa")') - lines.append(f' lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink")') - lines.append(f' lines.append("export GALLIUM_DRIVER=zink")') - else: - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=1")') - elif field_type in [ConfigFieldType.INTEGER, ConfigFieldType.FLOAT]: - default = field_def["default"] - if field_name == "dxvk_frame_rate": - # Special handling for DXVK_FRAME_RATE (only export if > 0) - lines.append(f' {field_name} = config.get("{field_name}", {default})') - lines.append(f' if {field_name} > 0:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - else: - lines.append(f' {field_name} = config.get("{field_name}", {default})') - lines.append(f' if {field_name} != {default}:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - elif field_type == ConfigFieldType.STRING: - lines.append(f' {field_name} = config.get("{field_name}", "")') - lines.append(f' if {field_name}:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - - return "\n".join(lines) - - -def generate_complete_schema_file() -> str: - """Generate complete config_schema_generated.py file""" - - # Generate field name constants - field_constants = [] - for field_name in CONFIG_SCHEMA_DEF.keys(): - const_name = field_name.upper() - field_constants.append(f'{const_name} = "{field_name}"') - - lines = [ - '"""', - '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', - ] + field_constants + [ - '', - '', - generate_typed_dict(), - '', - '', - '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:', - f'{generate_script_parsing().replace(" elif", " if")}', - '', - ' 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 = []', - f'{generate_script_generation()}', - ' return lines', - ' return generate_script_lines', - '', - '', - f'ALL_FIELDS = {list(CONFIG_SCHEMA_DEF.keys())}', - '' - ] - - return '\n'.join(lines) - - -def main(): - """Generate complete Python configuration files""" - try: - # Create generated files in py_modules/lsfg_vk/ - target_dir = project_root / "py_modules" / "lsfg_vk" - - # Generate the complete schema file - schema_content = generate_complete_schema_file() - schema_file = target_dir / "config_schema_generated.py" - schema_file.write_text(schema_content) - print(f"Generated {schema_file.relative_to(project_root)}") - - except Exception as e: - print(f"Error generating Python files: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py deleted file mode 100644 index 27969f1..0000000 --- a/scripts/generate_ts_schema.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate TypeScript schema from Python shared_config.py - -This script reads the canonical schema from shared_config.py and generates -the corresponding TypeScript files, ensuring single source of truth. -""" - -import sys -from pathlib import Path - -# Add project root to path to import shared_config -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - - -def generate_typescript_schema(): - """Generate generatedConfigSchema.ts from Python schema""" - - # Generate field name constants - field_constants = [] - for field_name in CONFIG_SCHEMA_DEF.keys(): - const_name = field_name.upper() - field_constants.append(f'export const {const_name} = "{field_name}" as const;') - - # Generate enum - enum_lines = [ - "// src/config/generatedConfigSchema.ts", - "// Configuration field type enum - matches Python", - "export enum ConfigFieldType {", - " BOOLEAN = \"boolean\",", - " INTEGER = \"integer\",", - " FLOAT = \"float\",", - " STRING = \"string\"", - "}", - "", - "// Field name constants for type-safe access", - ] + field_constants + [ - "", - "// Configuration field definition", - "export interface ConfigField {", - " name: string;", - " fieldType: ConfigFieldType;", - " default: boolean | number | string;", - " description: string;", - "}", - "", - "// Configuration schema - auto-generated from Python", - "export const CONFIG_SCHEMA: Record = {" - ] - - # Generate schema entries - schema_entries = [] - interface_fields = [] - defaults_fields = [] - field_types = [] - - for field_name, field_def in CONFIG_SCHEMA_DEF.items(): - # Schema entry - default_value = field_def["default"] - if isinstance(default_value, str): - default_str = f'"{default_value}"' - elif isinstance(default_value, bool): - default_str = "true" if default_value else "false" - else: - default_str = str(default_value) - - schema_entries.append(f' {field_name}: {{') - schema_entries.append(f' name: "{field_def["name"]}",') - schema_entries.append(f' fieldType: ConfigFieldType.{field_def["fieldType"].upper()},') - schema_entries.append(f' default: {default_str},') - schema_entries.append(f' description: "{field_def["description"]}"') - schema_entries.append(' },') - - # Interface field - if field_def["fieldType"] == ConfigFieldType.BOOLEAN: - ts_type = "boolean" - elif field_def["fieldType"] == ConfigFieldType.INTEGER: - ts_type = "number" - elif field_def["fieldType"] == ConfigFieldType.FLOAT: - ts_type = "number" - elif field_def["fieldType"] == ConfigFieldType.STRING: - ts_type = "string" - else: - ts_type = "any" - - interface_fields.append(f' {field_name}: {ts_type};') - defaults_fields.append(f' {field_name}: {default_str},') - field_types.append(f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},') - - # Complete the file - all_lines = enum_lines + schema_entries + [ - "};", - "", - "// Type-safe configuration data structure", - "export interface ConfigurationData {", - ] + interface_fields + [ - "}", - "", - "// Helper functions", - "export function getFieldNames(): string[] {", - " return Object.keys(CONFIG_SCHEMA);", - "}", - "", - "export function getDefaults(): ConfigurationData {", - " return {", - ] + defaults_fields + [ - " };", - "}", - "", - "export function getFieldTypes(): Record {", - " return {", - ] + field_types + [ - " };", - "}", - "", - "" - ] - - return "\n".join(all_lines) - - -def main(): - """Main function to generate TypeScript schema and Python boilerplate""" - try: - # Generate the TypeScript content - ts_content = generate_typescript_schema() - - # Write to the target file - target_file = project_root / "src" / "config" / "generatedConfigSchema.ts" - target_file.write_text(ts_content) - - print(f"Generated {target_file} from shared_config.py") - print(f" Fields: {len(CONFIG_SCHEMA_DEF)}") - - # Also generate Python boilerplate - print("\nGenerating Python boilerplate...") - from pathlib import Path - import subprocess - - boilerplate_script = project_root / "scripts" / "generate_python_boilerplate.py" - result = subprocess.run([sys.executable, str(boilerplate_script)], - capture_output=True, text=True) - - if result.returncode == 0: - print(result.stdout) - else: - print(f"Warning: Python boilerplate generation had issues:\n{result.stderr}") - - except Exception as e: - print(f"Error generating schema: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/shared_config.py b/shared_config.py deleted file mode 100644 index bb7eea6..0000000 --- a/shared_config.py +++ /dev/null @@ -1,123 +0,0 @@ -from enum import Enum -from typing import Dict, Union - - -class ConfigFieldType(str, Enum): - BOOLEAN = "boolean" - INTEGER = "integer" - FLOAT = "float" - STRING = "string" - - -CONFIG_SCHEMA_DEF = { - "dll": { - "name": "dll", - "fieldType": ConfigFieldType.STRING, - "default": "", - "description": "override the lsfg-vk.dll path", - "location": "toml", - }, - "no_fp16": { - "name": "no_fp16", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable FP16 acceleration", - "location": "toml", - }, - "multiplier": { - "name": "multiplier", - "fieldType": ConfigFieldType.INTEGER, - "default": 1, - "description": "frame generation multiplier", - "location": "toml", - }, - "flow_scale": { - "name": "flow_scale", - "fieldType": ConfigFieldType.FLOAT, - "default": 1.0, - "description": "motion estimation resolution scale", - "location": "toml", - }, - "performance_mode": { - "name": "performance_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "use the lighter frame generation model", - "location": "toml", - }, - "experimental_present_mode": { - "name": "experimental_present_mode", - "fieldType": ConfigFieldType.STRING, - "default": "fifo", - "description": "control the v2 present mode override", - "location": "toml", - }, - "dxvk_frame_rate": { - "name": "dxvk_frame_rate", - "fieldType": ConfigFieldType.INTEGER, - "default": 0, - "description": "base framerate cap for DirectX games before frame multiplier", - "location": "script", - }, - "enable_wow64": { - "name": "enable_wow64", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable PROTON_USE_WOW64=1 for 32-bit games", - "location": "script", - }, - "disable_steamdeck_mode": { - "name": "disable_steamdeck_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable Steam Deck mode", - "location": "script", - }, - "mangohud_workaround": { - "name": "mangohud_workaround", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable a transparent MangoHud overlay workaround", - "location": "script", - }, - "disable_vkbasalt": { - "name": "disable_vkbasalt", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable vkBasalt for games where it conflicts with lsfg-vk", - "location": "script", - }, - "force_enable_vkbasalt": { - "name": "force_enable_vkbasalt", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "force-enable vkBasalt", - "location": "script", - }, - "enable_wsi": { - "name": "enable_wsi", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable the Gamescope WSI layer", - "location": "script", - }, - "enable_zink": { - "name": "enable_zink", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable Zink for OpenGL games", - "location": "script", - }, -} - - -def get_field_names() -> list[str]: - return list(CONFIG_SCHEMA_DEF) - - -def get_defaults() -> Dict[str, Union[bool, int, float, str]]: - return {name: definition["default"] for name, definition in CONFIG_SCHEMA_DEF.items()} - - -def get_field_types() -> Dict[str, str]: - return {name: definition["fieldType"].value for name, definition in CONFIG_SCHEMA_DEF.items()} diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index c0d6528..b38fa3b 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -44,12 +44,31 @@ export interface ConfigUpdateResult { error?: string; } +export interface GameConfigEntry { + appid: string; + profile: string; + config: LsfgConfig; +} +export interface InstalledGame { appid: string; name: string; } +export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } + +export interface GameConfigsResult { + success: boolean; + default?: LsfgConfig; + games?: GameConfigEntry[]; + error?: string; +} + +export interface GameConfigResult extends ConfigUpdateResult { + appid?: string; + exists?: boolean; + config?: LsfgConfig; +} + export interface ConfigSchemaResult { field_names: string[]; field_types: Record; defaults: ConfigurationData; - profiles?: string[]; - current_profile?: string; } export interface LaunchOptionResult { @@ -105,22 +124,6 @@ export interface FlatpakOperationResult { operation?: string; } -// Profile management interfaces -export interface ProfilesResult { - success: boolean; - profiles?: string[]; - current_profile?: string; - message?: string; - error?: string; -} - -export interface ProfileResult { - success: boolean; - profile_name?: string; - message?: string; - error?: string; -} - // API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); @@ -146,17 +149,14 @@ export const updateLsfgConfig = callable< [ConfigurationData], ConfigUpdateResult >("update_lsfg_config"); +export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); +export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); +export const getGameConfig = callable<[string], GameConfigResult>("get_game_config"); +export const updateGameConfig = callable<[string, LsfgConfig], GameConfigResult>("update_game_config"); +export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); +export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); // Legacy helper function for backward compatibility export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise => { return updateLsfgConfig(config); }; - -// Self-updater API functions -// Profile management API functions -export const getProfiles = callable<[], ProfilesResult>("get_profiles"); -export const createProfile = callable<[string, string?], ProfileResult>("create_profile"); -export const deleteProfile = callable<[string], ProfileResult>("delete_profile"); -export const renameProfile = callable<[string, string], ProfileResult>("rename_profile"); -export const setCurrentProfile = callable<[string], ProfileResult>("set_current_profile"); -export const updateProfileConfig = callable<[string, ConfigurationData], ConfigUpdateResult>("update_profile_config"); diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index e83bd58..c2bba7e 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,293 +1,28 @@ -import { PanelSectionRow, ToggleField, SliderField, ButtonItem } from "@decky/ui"; -import { useState, useEffect } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { - FLOW_SCALE, NO_FP16, PERFORMANCE_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, - MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK -} from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; } -const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; -const CONFIG_COLLAPSED_KEY = "lsfg-config-collapsed"; - -export function ConfigurationSection({ - config, - onConfigChange -}: ConfigurationSectionProps) { - // Initialize with localStorage value, fallback to true if not found - const [configCollapsed, setConfigCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(CONFIG_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - const [workaroundsCollapsed, setWorkaroundsCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : true; - } catch { - return true; - } - }); - - // Persist workarounds collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(CONFIG_COLLAPSED_KEY, JSON.stringify(configCollapsed)); - } catch (error) { - console.warn("Failed to save config collapse state:", error); - } - }, [configCollapsed]); - - useEffect(() => { - try { - localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(workaroundsCollapsed)); - } catch (error) { - console.warn("Failed to save workarounds collapse state:", error); - } - }, [workaroundsCollapsed]); - - return ( - <> - - - {/* Config Section */} - -
- {t('CONFIG_SECTION_TITLE', 'Config')} -
-
- - -
- setConfigCollapsed(!configCollapsed)} - > - {configCollapsed ? ( - - ) : ( - - )} - -
-
- - {!configCollapsed && ( - <> - - onConfigChange(FLOW_SCALE, value)} - /> - - - - onConfigChange(NO_FP16, !value)} - /> - - - - 0 ? ` (${config.dxvk_frame_rate} FPS)` : ` (${t('CONFIG_BASE_FPS_CAP_OFF', 'Off')})`}`} - description={t('CONFIG_BASE_FPS_CAP_DESC', 'Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)')} - value={config.dxvk_frame_rate} - min={0} - max={60} - step={1} - onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)} - /> - - - - onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")} - /> - - - - onConfigChange(PERFORMANCE_MODE, value)} - /> - - - - )} - - {/* Workarounds Section */} - -
- {t('CONFIG_WORKAROUNDS_TITLE', 'Workarounds')} -
-
- - -
- setWorkaroundsCollapsed(!workaroundsCollapsed)} - > - {workaroundsCollapsed ? ( - - ) : ( - - )} - -
-
- - {!workaroundsCollapsed && ( - <> - - onConfigChange(ENABLE_WSI, value)} - /> - - - - onConfigChange('enable_wow64', value)} - /> - - - - - - - onConfigChange(MANGOHUD_WORKAROUND, value)} - /> - - - - { - if (value && config.force_enable_vkbasalt) { - // Turn off force enable when enabling disable - onConfigChange(FORCE_ENABLE_VKBASALT, false); - } - onConfigChange(DISABLE_VKBASALT, value); - }} - /> - - - - { - if (value && config.disable_vkbasalt) { - // Turn off disable when enabling force enable - onConfigChange(DISABLE_VKBASALT, false); - } - onConfigChange(FORCE_ENABLE_VKBASALT, value); - }} - /> - - - - onConfigChange(ENABLE_ZINK, value)} - /> - - - )} - - ); +export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) { + return <> + + onConfigChange(FLOW_SCALE, value)} /> + + + onConfigChange(NO_FP16, !value)} /> + + + onConfigChange(PERFORMANCE_MODE, value)} /> + + + onConfigChange(OVERRIDE_PRESENT_MODE, value)} /> + + + onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} /> + + ; } diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 01f94c9..bdb3a04 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,12 +1,12 @@ import { useEffect } from "react"; import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; -import { useInstallationStatus, useLsfgConfig } from "../hooks/useLsfgHooks"; -import { useProfileManagement } from "../hooks/useProfileManagement"; +import { useInstallationStatus } from "../hooks/useLsfgHooks"; +import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { StatusDisplay } from "./StatusDisplay"; import { InstallationButton } from "./InstallationButton"; import { ConfigurationSection } from "./ConfigurationSection"; -import { ProfileManagement } from "./ProfileManagement"; +import { GameConfigurationSelector } from "./GameConfigurationSelector"; import { UsageInstructions } from "./UsageInstructions"; import { SmartClipboardButton } from "./SmartClipboardButton"; import { FgmodClipboardButton } from "./FgmodClipboardButton"; @@ -28,40 +28,20 @@ export function Content() { checkInstallation } = useInstallationStatus(); - const { - config, - loadLsfgConfig, - updateField - } = useLsfgConfig(); - - const { - currentProfile, - updateProfileConfig, - loadProfiles - } = useProfileManagement(); + const { config, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload } = useGameConfiguration(); const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); useEffect(() => { - if (isInstalled) { - loadLsfgConfig(); - } - }, [isInstalled, loadLsfgConfig]); - - const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string) => { - if (currentProfile) { - const newConfig = { ...config, [fieldName]: value }; - const result = await updateProfileConfig(currentProfile, newConfig); - if (result.success) { - await loadLsfgConfig(); - } - } else { - await updateField(fieldName, value); - } + if (isInstalled) void reload(); + }, [isInstalled, reload]); + + const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => { + await save({ ...config, [fieldName]: value }); }; const onInstall = () => { - handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig, checkInstallation); + handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); }; const onUninstall = () => { @@ -124,13 +104,7 @@ export function Content() { )} {isInstalled && ( - { - await loadProfiles(); - await loadLsfgConfig(); - }} - /> + )} {isInstalled && ( diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 206643e..5069c9a 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,11 +1,11 @@ import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import t from "../i18n/i18n"; interface FpsMultiplierControlProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; } export function FpsMultiplierControl({ @@ -50,7 +50,7 @@ export function FpsMultiplierControl({ textAlign: "center" }} > - {config.multiplier < 2 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} + {config.multiplier === 1 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} void; + onReset: () => Promise; + onResetAll: () => Promise; +} + +export function GameConfigurationSelector({ targets, runningGame, selectedAppId, onSelect, onReset, onResetAll }: Props) { + const options: DropdownOption[] = [ + { data: "", label: runningGame ? `Default (editing template) · ${runningGame.name}` : "Default" }, + ...targets.map((target) => ({ data: target.appid, label: `${target.name} · ${target.appid}` })), + ]; + return <> + + onSelect(String(option.data))} /> + + + void onReset()} disabled={!selectedAppId}>Reset selected game + + + void onResetAll()} disabled={!targets.some((target) => target.configured)}>Reset all game profiles + + ; +} diff --git a/src/components/ProfileManagement.tsx b/src/components/ProfileManagement.tsx deleted file mode 100644 index 626d54c..0000000 --- a/src/components/ProfileManagement.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { useState, useEffect } from "react"; -import { - PanelSectionRow, - Dropdown, - DropdownOption, - showModal, - ConfirmModal, - Field, - DialogButton, - ButtonItem, - ModalRoot, - TextField, - Focusable, - AppOverview, - Router -} from "@decky/ui"; -import { RiArrowDownSFill, RiArrowUpSFill, RiEditLine, RiDeleteBinLine } from "react-icons/ri"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - ProfilesResult, - ProfileResult -} from "../api/lsfgApi"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; -import t from '../i18n/i18n'; - -const PROFILES_COLLAPSED_KEY = 'lsfg-profiles-collapsed'; - -interface TextInputModalProps { - title: string; - description: string; - defaultValue?: string; - okText?: string; - cancelText?: string; - onOK: (value: string) => void; - closeModal?: () => void; -} - -function TextInputModal({ - title, - description, - defaultValue = "", - okText = "OK", - cancelText = "Cancel", - onOK, - closeModal -}: TextInputModalProps) { - const [value, setValue] = useState(defaultValue); - - const handleOK = () => { - if (value.trim()) { - onOK(value); - closeModal?.(); - } - }; - - return ( - -
-

{title}

-

{description}

- -
- - setValue(e?.target?.value || "")} - style={{ width: "100%" }} - /> - -
- - - - {cancelText} - - - {okText} - - -
-
- ); -} - -interface ProfileManagementProps { - currentProfile?: string; - onProfileChange?: (profileName: string) => void; -} - -export function ProfileManagement({ currentProfile, onProfileChange }: ProfileManagementProps) { - const [profiles, setProfiles] = useState([]); - const [selectedProfile, setSelectedProfile] = useState(currentProfile || "decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - const [mainRunningApp, setMainRunningApp] = useState(undefined); - - // Initialize with localStorage value, fallback to false (expanded) if not found - const [profilesCollapsed, setProfilesCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(PROFILES_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - // Persist profiles collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(PROFILES_COLLAPSED_KEY, JSON.stringify(profilesCollapsed)); - } catch (error) { - console.warn('Failed to save profiles collapse state:', error); - } - }, [profilesCollapsed]); - - // Load profiles on component mount - useEffect(() => { - loadProfiles(); - }, []); - - // Update selected profile when prop changes - useEffect(() => { - if (currentProfile) { - setSelectedProfile(currentProfile); - } - }, [currentProfile]); - - // Poll for running app every 2 seconds - useEffect(() => { - const checkRunningApp = () => { - setMainRunningApp(Router.MainRunningApp); - }; - - // Check immediately - checkRunningApp(); - - // Set up polling interval - const interval = setInterval(checkRunningApp, 2000); - - // Cleanup interval on unmount - return () => clearInterval(interval); - }, []); - - const loadProfiles = async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setSelectedProfile(result.current_profile); - } - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - } - }; - - const handleProfileChange = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setSelectedProfile(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - onProfileChange?.(profileName); - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleCreateProfile = () => { - showModal( - { - if (name.trim()) { - createNewProfile(name.trim()); - } - }} - /> - ); - }; - - const createNewProfile = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, selectedProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - // Automatically switch to the newly created profile using the normalized name - await handleProfileChange(actualProfileName); - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDeleteProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_DELETE_TITLE', 'Cannot delete default profile'), t('PROFILE_CANNOT_DELETE_MSG', 'The default profile cannot be deleted')); - return; - } - - showModal( - deleteSelectedProfile()} - /> - ); - }; - - const deleteSelectedProfile = async () => { - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(selectedProfile); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${selectedProfile}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - setSelectedProfile("decky-lsfg-vk"); - onProfileChange?.("decky-lsfg-vk"); - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDropdownChange = (option: DropdownOption) => { - if (option.data === "__NEW_PROFILE__") { - handleCreateProfile(); - } else { - handleProfileChange(option.data); - } - }; - - const handleRenameProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_RENAME_TITLE', 'Cannot rename default profile'), t('PROFILE_CANNOT_RENAME_MSG', 'The default profile cannot be renamed')); - return; - } - - showModal( - { - if (newName.trim() && newName.trim() !== selectedProfile) { - renameSelectedProfile(newName.trim()); - } - }} - /> - ); - }; - - const renameSelectedProfile = async (newName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(selectedProfile, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - setSelectedProfile(actualNewName); - onProfileChange?.(actualNewName); - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const profileOptions: DropdownOption[] = [ - ...profiles.map((profile: string) => ({ - data: profile, - label: profile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : profile - })), - { - data: "__NEW_PROFILE__", - label: t('PROFILE_NEW', 'New Profile') - } - ]; - - return ( - <> - - - {/* Display currently running game info - always visible */} - {mainRunningApp && ( - -
- {mainRunningApp.display_name} running. {t('PROFILE_CLOSE_GAME', 'Close game to change profile.')} -
-
- )} - - -
- {t('PROFILE_SECTION_TITLE', 'Profile:')} {selectedProfile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : selectedProfile} -
-
- - -
- setProfilesCollapsed(!profilesCollapsed)} - > - {profilesCollapsed ? ( - - ) : ( - - )} - -
-
- - {!profilesCollapsed && ( - <> - - - - - - - - - - - - - - - - - - - )} - - ); -} diff --git a/src/components/index.ts b/src/components/index.ts index bec45ae..4284aee 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,4 +8,4 @@ export { SmartClipboardButton } from "./SmartClipboardButton"; export { FgmodClipboardButton } from "./FgmodClipboardButton"; export { NerdStuffModal } from "./NerdStuffModal"; export { FlatpaksModal } from "./FlatpaksModal"; -export { ProfileManagement } from "./ProfileManagement"; +export { GameConfigurationSelector } from "./GameConfigurationSelector"; diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index befbd8d..bd72e8c 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -6,8 +6,6 @@ export { getFieldNames, getDefaults, getFieldTypes, - 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 + DLL, NO_FP16, ACTIVE_IN, PACING_MODE, MULTIPLIER, FLOW_SCALE, + PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from './generatedConfigSchema'; diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index edbde88..71459eb 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -1,182 +1,31 @@ -// src/config/generatedConfigSchema.ts -// Configuration field type enum - matches Python -export enum ConfigFieldType { - BOOLEAN = "boolean", - INTEGER = "integer", - FLOAT = "float", - STRING = "string" -} +export enum ConfigFieldType { BOOLEAN = "boolean", INTEGER = "integer", FLOAT = "float", STRING = "string", ARRAY = "array" } -// Field name constants for type-safe access export const DLL = "dll" as const; export const NO_FP16 = "no_fp16" as const; +export const ACTIVE_IN = "active_in" as const; +export const PACING_MODE = "pacing_mode" as const; export const MULTIPLIER = "multiplier" as const; export const FLOW_SCALE = "flow_scale" as const; export const PERFORMANCE_MODE = "performance_mode" as const; -export const EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const; -export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const; -export const ENABLE_WOW64 = "enable_wow64" as const; -export const DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" as const; -export const MANGOHUD_WORKAROUND = "mangohud_workaround" as const; -export const DISABLE_VKBASALT = "disable_vkbasalt" as const; -export const FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt" as const; -export const ENABLE_WSI = "enable_wsi" as const; -export const ENABLE_ZINK = "enable_zink" as const; +export const OVERRIDE_PRESENT_MODE = "override_present_mode" as const; +export const PRESERVE_SWAPCHAIN_IMAGE_COUNT = "preserve_swapchain_image_count" as const; -// Configuration field definition -export interface ConfigField { - name: string; - fieldType: ConfigFieldType; - default: boolean | number | string; - description: string; +export interface ConfigField { name: string; fieldType: ConfigFieldType; default: boolean | number | string | string[]; description: string; } +export interface ConfigurationData { + dll: string; no_fp16: boolean; active_in: string[]; pacing_mode: string; multiplier: number; + flow_scale: number; performance_mode: boolean; override_present_mode: boolean; preserve_swapchain_image_count: boolean; } - -// Configuration schema - auto-generated from Python export const CONFIG_SCHEMA: Record = { - dll: { - name: "dll", - fieldType: ConfigFieldType.STRING, - default: "", - description: "override the lsfg-vk.dll path" - }, - no_fp16: { - name: "no_fp16", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "disable FP16 acceleration" - }, - multiplier: { - name: "multiplier", - fieldType: ConfigFieldType.INTEGER, - default: 1, - description: "frame generation multiplier" - }, - flow_scale: { - name: "flow_scale", - fieldType: ConfigFieldType.FLOAT, - default: 1, - description: "motion estimation resolution scale" - }, - performance_mode: { - name: "performance_mode", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "use the lighter frame generation model" - }, - experimental_present_mode: { - name: "experimental_present_mode", - fieldType: ConfigFieldType.STRING, - default: "fifo", - description: "control the v2 present mode override" - }, - dxvk_frame_rate: { - name: "dxvk_frame_rate", - fieldType: ConfigFieldType.INTEGER, - default: 0, - description: "base framerate cap for DirectX games before frame multiplier" - }, - enable_wow64: { - name: "enable_wow64", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable PROTON_USE_WOW64=1 for 32-bit games" - }, - disable_steamdeck_mode: { - name: "disable_steamdeck_mode", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "disable Steam Deck mode" - }, - mangohud_workaround: { - name: "mangohud_workaround", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable a transparent MangoHud overlay workaround" - }, - disable_vkbasalt: { - name: "disable_vkbasalt", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "disable vkBasalt for games where it conflicts with lsfg-vk" - }, - force_enable_vkbasalt: { - name: "force_enable_vkbasalt", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "force-enable vkBasalt" - }, - enable_wsi: { - name: "enable_wsi", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable the Gamescope WSI layer" - }, - enable_zink: { - name: "enable_zink", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable Zink for OpenGL games" - }, + dll: { name: "dll", fieldType: ConfigFieldType.STRING, default: "", description: "Override the lsfg-vk.dll path" }, + no_fp16: { name: "no_fp16", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Disable FP16 acceleration" }, + active_in: { name: "active_in", fieldType: ConfigFieldType.ARRAY, default: [], description: "Steam AppID or executable identifiers" }, + pacing_mode: { name: "pacing_mode", fieldType: ConfigFieldType.STRING, default: "vsync", description: "Frame pacing mode" }, + multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 2, description: "Frame generation multiplier" }, + flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 1, description: "Motion estimation resolution scale" }, + performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Use the lighter frame generation model" }, + override_present_mode: { name: "override_present_mode", fieldType: ConfigFieldType.BOOLEAN, default: true, description: "Override present mode" }, + preserve_swapchain_image_count: { name: "preserve_swapchain_image_count", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Preserve the swapchain image count" }, }; - -// Type-safe configuration data structure -export interface ConfigurationData { - dll: string; - no_fp16: boolean; - multiplier: number; - flow_scale: number; - performance_mode: boolean; - experimental_present_mode: string; - dxvk_frame_rate: number; - enable_wow64: boolean; - disable_steamdeck_mode: boolean; - mangohud_workaround: boolean; - disable_vkbasalt: boolean; - force_enable_vkbasalt: boolean; - enable_wsi: boolean; - enable_zink: boolean; -} - -// Helper functions -export function getFieldNames(): string[] { - return Object.keys(CONFIG_SCHEMA); -} - -export function getDefaults(): ConfigurationData { - return { - dll: "", - no_fp16: false, - multiplier: 1, - flow_scale: 1, - performance_mode: false, - experimental_present_mode: "fifo", - dxvk_frame_rate: 0, - enable_wow64: false, - disable_steamdeck_mode: false, - mangohud_workaround: false, - disable_vkbasalt: false, - force_enable_vkbasalt: false, - enable_wsi: false, - enable_zink: false, - }; -} - -export function getFieldTypes(): Record { - return { - dll: ConfigFieldType.STRING, - no_fp16: ConfigFieldType.BOOLEAN, - multiplier: ConfigFieldType.INTEGER, - flow_scale: ConfigFieldType.FLOAT, - performance_mode: ConfigFieldType.BOOLEAN, - experimental_present_mode: ConfigFieldType.STRING, - dxvk_frame_rate: ConfigFieldType.INTEGER, - enable_wow64: ConfigFieldType.BOOLEAN, - disable_steamdeck_mode: ConfigFieldType.BOOLEAN, - mangohud_workaround: ConfigFieldType.BOOLEAN, - disable_vkbasalt: ConfigFieldType.BOOLEAN, - force_enable_vkbasalt: ConfigFieldType.BOOLEAN, - enable_wsi: ConfigFieldType.BOOLEAN, - enable_zink: ConfigFieldType.BOOLEAN, - }; -} - +export function getFieldNames(): string[] { return Object.keys(CONFIG_SCHEMA); } +export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 1, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; } +export function getFieldTypes(): Record { return Object.fromEntries(Object.entries(CONFIG_SCHEMA).map(([key, value]) => [key, value.fieldType])); } diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts new file mode 100644 index 0000000..e0ba360 --- /dev/null +++ b/src/hooks/useGameConfiguration.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Router } from "@decky/ui"; +import { getGameConfigs, getInstalledGames, updateGameConfig, updateLsfgConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type InstalledGame } from "../api/lsfgApi"; +import { ConfigurationData, getDefaults } from "../config/configSchema"; + +export interface GameTarget { appid: string; name: string; configured: boolean; } + +export function useGameConfiguration() { + const [defaultConfig, setDefaultConfig] = useState(getDefaults()); + const [games, setGames] = useState([]); + const [installedGames, setInstalledGames] = useState([]); + const [selectedAppId, setSelectedAppId] = useState(""); + const [runningGame, setRunningGame] = useState(null); + const autoSelected = useRef(false); + + const load = useCallback(async () => { + const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]); + if (result.success) { + setDefaultConfig(result.default || getDefaults()); + setGames(result.games || []); + } + if (installed.success) setInstalledGames(installed.games || []); + }, []); + + useEffect(() => { load(); }, [load]); + useEffect(() => { + const poll = () => { + const app = Router.MainRunningApp as any; + if (app?.appid) setRunningGame({ appid: String(app.appid), name: app.display_name || `App ${app.appid}`, configured: games.some((game) => game.appid === String(app.appid)) }); + else setRunningGame(null); + }; + poll(); + const interval = window.setInterval(poll, 2000); + return () => window.clearInterval(interval); + }, [games]); + useEffect(() => { + if (!autoSelected.current && runningGame) { + autoSelected.current = true; + setSelectedAppId(runningGame.appid); + } + }, [runningGame]); + + const targets = useMemo(() => { + const configured = installedGames.map((game) => ({ appid: game.appid, name: game.name, configured: games.some((item) => item.appid === game.appid) })); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: `App ${game.appid}`, configured: true }); + if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); + return configured; + }, [games, installedGames, runningGame]); + const selected = selectedAppId ? games.find((game) => game.appid === selectedAppId)?.config : defaultConfig; + const config = selected || defaultConfig; + + const save = useCallback(async (next: ConfigurationData) => { + if (!selectedAppId) { + const result = await updateLsfgConfig(next); + if (result.success) setDefaultConfig(next); + return; + } + const result = await updateGameConfig(selectedAppId, next); + if (result.success) await load(); + }, [load, selectedAppId]); + + const resetSelected = useCallback(async () => { + if (selectedAppId) { await resetGameConfig(selectedAppId); setSelectedAppId(""); await load(); } + }, [load, selectedAppId]); + const resetAll = useCallback(async () => { await resetAllGameConfigs(); setSelectedAppId(""); await load(); }, [load]); + + return { config, defaultConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload: load }; +} diff --git a/src/hooks/useProfileManagement.ts b/src/hooks/useProfileManagement.ts deleted file mode 100644 index a5f2a07..0000000 --- a/src/hooks/useProfileManagement.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - updateProfileConfig, - type ProfilesResult, - type ProfileResult, - type ConfigUpdateResult -} from "../api/lsfgApi"; -import { ConfigurationData } from "../config/configSchema"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; - -export function useProfileManagement() { - const [profiles, setProfiles] = useState([]); - const [currentProfile, setCurrentProfileState] = useState("decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - - // Load profiles on hook initialization - const loadProfiles = useCallback(async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setCurrentProfileState(result.current_profile); - } - return result; - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - return { success: false, error: String(error) }; - } - }, []); - - // Create a new profile - const handleCreateProfile = useCallback(async (profileName: string, sourceProfile?: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, sourceProfile || currentProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - return result; - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Delete a profile - const handleDeleteProfile = useCallback(async (profileName: string) => { - if (profileName === "decky-lsfg-vk") { - showErrorToast("Cannot delete default profile", "The default profile cannot be deleted"); - return { success: false, error: "Cannot delete default profile" }; - } - - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(profileName); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${profileName}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - if (currentProfile === profileName) { - setCurrentProfileState("decky-lsfg-vk"); - } - return result; - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Rename a profile - const handleRenameProfile = useCallback(async (oldName: string, newName: string) => { - if (oldName === "decky-lsfg-vk") { - showErrorToast("Cannot rename default profile", "The default profile cannot be renamed"); - return { success: false, error: "Cannot rename default profile" }; - } - - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(oldName, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - // Update current profile if it was renamed - if (currentProfile === oldName) { - setCurrentProfileState(actualNewName); - } - return result; - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Set the current active profile - const handleSetCurrentProfile = useCallback(async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setCurrentProfileState(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - return result; - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, []); - - // Update configuration for a specific profile - const handleUpdateProfileConfig = useCallback(async (profileName: string, config: ConfigurationData) => { - setIsLoading(true); - try { - const result: ConfigUpdateResult = await updateProfileConfig(profileName, config); - if (result.success) { - return result; - } else { - console.error("Failed to update profile config:", result.error); - showErrorToast("Failed to update profile config", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error updating profile config:", error); - showErrorToast("Error updating profile config", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile]); - - // Initialize profiles on mount - useEffect(() => { - loadProfiles(); - }, [loadProfiles]); - - return { - profiles, - currentProfile, - isLoading, - loadProfiles, - createProfile: handleCreateProfile, - deleteProfile: handleDeleteProfile, - renameProfile: handleRenameProfile, - setCurrentProfile: handleSetCurrentProfile, - updateProfileConfig: handleUpdateProfileConfig - }; -} -- 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(+) 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 --- defaults/i18n/ja.json | 13 - defaults/i18n/ko.json | 13 - defaults/i18n/template.json | 13 - 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 ++++- src/api/lsfgApi.ts | 12 +- src/components/ConfigFileTab.tsx | 46 +++ src/components/ConfigurationTab.tsx | 54 ++++ src/components/Content.tsx | 223 ++++++------- src/components/FlatpaksModal.tsx | 460 --------------------------- src/components/FlatpaksTab.tsx | 195 ++++++++++++ src/components/GameConfigurationSelector.tsx | 2 +- src/components/NerdStuffModal.tsx | 148 --------- src/components/SetupTab.tsx | 47 +++ src/components/SmartClipboardButton.tsx | 91 ------ src/components/UsageInstructions.tsx | 69 ---- src/components/index.ts | 8 +- src/hooks/useGameConfiguration.ts | 26 +- src/i18n/languages.json | 51 +-- src/styles.ts | 34 ++ 24 files changed, 633 insertions(+), 1111 deletions(-) create mode 100644 src/components/ConfigFileTab.tsx create mode 100644 src/components/ConfigurationTab.tsx delete mode 100644 src/components/FlatpaksModal.tsx create mode 100644 src/components/FlatpaksTab.tsx delete mode 100644 src/components/NerdStuffModal.tsx create mode 100644 src/components/SetupTab.tsx delete mode 100644 src/components/SmartClipboardButton.tsx delete mode 100644 src/components/UsageInstructions.tsx create mode 100644 src/styles.ts diff --git a/defaults/i18n/ja.json b/defaults/i18n/ja.json index 08c87df..7b97bc5 100644 --- a/defaults/i18n/ja.json +++ b/defaults/i18n/ja.json @@ -57,21 +57,12 @@ "FLATPAK_ERROR": "エラー", "FLATPAK_ERROR_STATUS": "拡張ステータスの確認に失敗しました", "FLATPAK_ERROR_APPS": "Flatpakアプリケーションの読み込みに失敗しました", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam設定", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpakショートカットの設定", - "FLATPAK_STEAM_CONFIG_DESC": "Steamでflatpakゲームを開き、歯車アイコンをクリックしてください。", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "重要: 起動オプションではなくターゲット(TARGET)に設定してください", - "FLATPAK_STEP_TRY_FIRST": "まず試す:", - "FLATPAK_STEP_TRY_FULL_PATH": "うまくいかない場合、フルパスを試す:", - "FLATPAK_STEP_FINAL": "最終的な結果はこのようになります:", "FLATPAK_CLOSE": "閉じる", "NERD_LOADING": "情報を読み込み中...", "NERD_DLL_PATH": "DLLパス", "NERD_NOT_AVAILABLE": "利用不可", "NERD_DLL_HASH": "DLL SHA256ハッシュ", "NERD_DETECTION_SOURCE": "検出ソース", - "NERD_LAUNCH_SCRIPT": "起動スクリプト", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "スクリプトが見つかりません:", "NERD_PATH_PREFIX": "パス:", "NERD_NO_CONTENT": "コンテンツなし", "NERD_CONFIG_FILE": "設定ファイル", @@ -97,11 +88,7 @@ "PROFILE_DELETE_BTN": "削除", "PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません", "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません", - "USAGE_TITLE": "使用方法", - "USAGE_DESC": "「起動オプションをコピー」ボタンをクリックし、Steamゲームの起動オプションに貼り付けてフレーム生成を有効化してください。", - "USAGE_CONFIG_NOTE": "設定は~/.config/lsfg-vk/conf.tomlに保存され、ゲーム実行中もホットリロードされます。", "CLIPBOARD_COPIED": "クリップボードにコピーしました", "CLIPBOARD_COPYING": "コピー中...", - "CLIPBOARD_COPY_LAUNCH": "起動オプションをコピー", "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" } diff --git a/defaults/i18n/ko.json b/defaults/i18n/ko.json index 5e6f2b7..cbc3a8e 100644 --- a/defaults/i18n/ko.json +++ b/defaults/i18n/ko.json @@ -57,21 +57,12 @@ "FLATPAK_ERROR": "오류", "FLATPAK_ERROR_STATUS": "확장 상태 확인 실패", "FLATPAK_ERROR_APPS": "Flatpak 애플리케이션 로드 실패", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam 설정", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpak 단축키 설정", - "FLATPAK_STEAM_CONFIG_DESC": "Steam에서 Flatpak 게임을 열고 톱니바퀴를 클릭하세요.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "중요: 실행 옵션이 아닌 대상(TARGET)에 설정하세요", - "FLATPAK_STEP_TRY_FIRST": "먼저 시도:", - "FLATPAK_STEP_TRY_FULL_PATH": "작동하지 않으면 전체 경로 시도:", - "FLATPAK_STEP_FINAL": "최종 결과는 다음과 같아야 합니다:", "FLATPAK_CLOSE": "닫기", "NERD_LOADING": "정보 불러오는 중...", "NERD_DLL_PATH": "DLL 경로", "NERD_NOT_AVAILABLE": "사용 불가", "NERD_DLL_HASH": "DLL SHA256 해시", "NERD_DETECTION_SOURCE": "감지 소스", - "NERD_LAUNCH_SCRIPT": "실행 스크립트", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "스크립트 없음:", "NERD_PATH_PREFIX": "경로:", "NERD_NO_CONTENT": "내용 없음", "NERD_CONFIG_FILE": "설정 파일", @@ -97,11 +88,7 @@ "PROFILE_DELETE_BTN": "삭제", "PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가", "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다", - "USAGE_TITLE": "사용 방법", - "USAGE_DESC": "\"실행 옵션 복사\" 버튼을 클릭한 후, Steam 게임의 실행 옵션에 붙여넣어 프레임 생성을 활성화하세요.", - "USAGE_CONFIG_NOTE": "설정은 ~/.config/lsfg-vk/conf.toml에 저장되며 게임 실행 중에도 즉시 반영됩니다.", "CLIPBOARD_COPIED": "클립보드에 복사됨", "CLIPBOARD_COPYING": "복사 중...", - "CLIPBOARD_COPY_LAUNCH": "실행 옵션 복사", "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" } diff --git a/defaults/i18n/template.json b/defaults/i18n/template.json index cb53b95..b9dfcc5 100644 --- a/defaults/i18n/template.json +++ b/defaults/i18n/template.json @@ -57,21 +57,12 @@ "FLATPAK_ERROR": "Error", "FLATPAK_ERROR_STATUS": "Failed to check extension status", "FLATPAK_ERROR_APPS": "Failed to load Flatpak applications", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam Configuration", - "FLATPAK_STEAM_CONFIG_HEADER": "Configure Steam Flatpak Shortcuts", - "FLATPAK_STEAM_CONFIG_DESC": "In Steam, open your flatpak game and click the cog wheel.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "IMPORTANT: Set this in TARGET (NOT LAUNCH OPTIONS)", - "FLATPAK_STEP_TRY_FIRST": "Try first:", - "FLATPAK_STEP_TRY_FULL_PATH": "If that doesn't work, try full path:", - "FLATPAK_STEP_FINAL": "Final result should look like:", "FLATPAK_CLOSE": "Close", "NERD_LOADING": "Loading information...", "NERD_DLL_PATH": "DLL Path", "NERD_NOT_AVAILABLE": "Not available", "NERD_DLL_HASH": "DLL SHA256 Hash", "NERD_DETECTION_SOURCE": "Detection Source", - "NERD_LAUNCH_SCRIPT": "Launch Script", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "Script not found:", "NERD_PATH_PREFIX": "Path:", "NERD_NO_CONTENT": "No content", "NERD_CONFIG_FILE": "Configuration File", @@ -97,11 +88,7 @@ "PROFILE_DELETE_BTN": "Delete", "PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile", "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed", - "USAGE_TITLE": "Usage Instructions", - "USAGE_DESC": "Click \"Copy Launch Option\" button, then paste it into your Steam game's launch options to enable frame generation.", - "USAGE_CONFIG_NOTE": "The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.", "CLIPBOARD_COPIED": "Copied to clipboard", "CLIPBOARD_COPYING": "Copying...", - "CLIPBOARD_COPY_LAUNCH": "Copy Launch Option", "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" } 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=[]) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index b38fa3b..04d1309 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -49,7 +49,7 @@ export interface GameConfigEntry { profile: string; config: LsfgConfig; } -export interface InstalledGame { appid: string; name: string; } +export interface InstalledGame { appid: string; name: string; nonSteam: boolean; } export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } export interface GameConfigsResult { @@ -71,12 +71,6 @@ export interface ConfigSchemaResult { defaults: ConfigurationData; } -export interface LaunchOptionResult { - launch_option: string; - instructions: string; - explanation: string; -} - export interface FileContentResult { success: boolean; content?: string; @@ -131,9 +125,7 @@ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config"); export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema"); -export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_option"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); -export const getLaunchScriptContent = callable<[], FileContentResult>("get_launch_script_content"); export const checkFgmodDirectory = callable<[], FgmodCheckResult>("check_fgmod_directory"); // Flatpak management API functions @@ -152,7 +144,7 @@ export const updateLsfgConfig = callable< export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const getGameConfig = callable<[string], GameConfigResult>("get_game_config"); -export const updateGameConfig = callable<[string, LsfgConfig], GameConfigResult>("update_game_config"); +export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx new file mode 100644 index 0000000..60c5c7e --- /dev/null +++ b/src/components/ConfigFileTab.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { Field, Focusable, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; +import { getConfigFileContent, FileContentResult } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +export function ConfigFileTab() { + const [result, setResult] = useState(null); + + const copy = async (content: string) => { + try { + await navigator.clipboard.writeText(content); + } catch { + // Clipboard access is unavailable in some Deck UI contexts. + } + }; + + useEffect(() => { + getConfigFileContent().then(setResult).catch((error) => { + setResult({ success: false, error: String(error) }); + }); + }, []); + + if (!result) { + return ( + + + + ); + } + + return ( + + + + {result.success && result.content && ( + void copy(result.content || "")}> +
+                {result.content}
+              
+
+ )} +
+
+
+ ); +} diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx new file mode 100644 index 0000000..6f3f474 --- /dev/null +++ b/src/components/ConfigurationTab.tsx @@ -0,0 +1,54 @@ +import { PanelSection } from "@decky/ui"; +import { ConfigurationData } from "../config/configSchema"; +import { GameTarget } from "../hooks/useGameConfiguration"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FgmodClipboardButton } from "./FgmodClipboardButton"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { GameConfigurationSelector } from "./GameConfigurationSelector"; +import t from "../i18n/i18n"; + +interface ConfigurationTabProps { + config: ConfigurationData; + targets: GameTarget[]; + runningGame: GameTarget | null; + selectedAppId: string; + onSelect: (appid: string) => void; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; + onReset: () => Promise; + onResetAll: () => Promise; +} + +export function ConfigurationTab({ + config, + targets, + runningGame, + selectedAppId, + onSelect, + onConfigChange, + onReset, + onResetAll, +}: ConfigurationTabProps) { + return ( + <> + + + + + + + + + + + + + + ); +} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index bdb3a04..de8f996 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,20 +1,22 @@ -import { useEffect } from "react"; -import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; -import { useInstallationStatus } from "../hooks/useLsfgHooks"; +import { Tabs } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { FaFileAlt, FaGamepad, FaLayerGroup, FaTools } from "react-icons/fa"; +import { ConfigurationData } from "../config/configSchema"; +import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; -import { StatusDisplay } from "./StatusDisplay"; -import { InstallationButton } from "./InstallationButton"; -import { ConfigurationSection } from "./ConfigurationSection"; -import { GameConfigurationSelector } from "./GameConfigurationSelector"; -import { UsageInstructions } from "./UsageInstructions"; -import { SmartClipboardButton } from "./SmartClipboardButton"; -import { FgmodClipboardButton } from "./FgmodClipboardButton"; -import { FpsMultiplierControl } from "./FpsMultiplierControl"; -import { NerdStuffModal } from "./NerdStuffModal"; -import { FlatpaksModal } from "./FlatpaksModal"; -import { ConfigurationData } from "../config/configSchema"; -import t from '../i18n/i18n'; +import { useInstallationStatus } from "../hooks/useLsfgHooks"; +import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigurationTab } from "./ConfigurationTab"; +import { FlatpaksTab } from "./FlatpaksTab"; +import { SetupTab } from "./SetupTab"; + +const tabIcons = { + configuration: , + flatpak: , + configFile: , + setup: , +}; export function Content() { const { @@ -25,141 +27,98 @@ export function Content() { losslessScalingInstalled, losslessScalingStatus, steamBranchStatus, - checkInstallation + checkInstallation, } = useInstallationStatus(); - - const { config, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload } = useGameConfiguration(); - + const { + config, + targets, + runningGame, + selectedAppId, + setSelectedAppId, + save, + resetSelected, + resetAll, + reload, + } = useGameConfiguration(); const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); + const [tab, setTab] = useState("Setup"); + const setupComplete = + isInstalled && + losslessScalingInstalled && + steamBranchStatus?.success === true && + steamBranchStatus.installed && + !steamBranchStatus.needs_switch; + + useEffect(() => { + setTab(setupComplete ? "Configuration" : "Setup"); + }, [setupComplete]); useEffect(() => { if (isInstalled) void reload(); }, [isInstalled, reload]); - const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => { + const handleConfigChange = async ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + ) => { await save({ ...config, [fieldName]: value }); }; const onInstall = () => { - handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); + void handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); }; const onUninstall = () => { - handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); + void handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); }; - const handleShowNerdStuff = () => { - showModal(); - }; + const setupContent = ( + + ); - const handleShowFlatpaks = () => { - showModal(); - }; + const tabs = setupComplete + ? [ + { + id: "Configuration", + title: tabIcons.configuration, + content: ( + + ), + }, + { id: "Flatpak", title: tabIcons.flatpak, content: }, + { id: "ConfigFile", title: tabIcons.configFile, content: }, + { id: "Setup", title: tabIcons.setup, content: setupContent }, + ] + : [ + { id: "Setup", title: tabIcons.setup, content: setupContent }, + ]; return ( - - {!isInstalled && ( - <> - - - - - )} - - {isInstalled && ( - <> - -
- {t('CONTENT_FPS_MULTIPLIER', 'FPS Multiplier')} -
-
- - - - )} - - {isInstalled && ( - - )} - - {isInstalled && ( - - )} - - {isInstalled && ( - <> - - - - )} - - - - - - {t('CONTENT_NERD_STUFF', 'Nerd Stuff')} - - - - - - {t('CONTENT_FLATPAK_SETUP', 'Flatpak Setup')} - - - - {isInstalled && ( - <> - - - - - )} -
+
+ + +
); } diff --git a/src/components/FlatpaksModal.tsx b/src/components/FlatpaksModal.tsx deleted file mode 100644 index 8245160..0000000 --- a/src/components/FlatpaksModal.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import { FC, useState, useEffect, CSSProperties } from 'react'; -import { - ModalRoot, - DialogBody, - DialogHeader, - DialogControlsSection, - DialogControlsSectionHeader, - ButtonItem, - PanelSectionRow, - Field, - Toggle, - Spinner, - Focusable, - showModal, - ConfirmModal -} from '@decky/ui'; -import { FaCheck, FaTimes, FaDownload, FaTrash, FaCog } from 'react-icons/fa'; -import flatpakTargetImage from '../../assets/flatpak-target.png'; -import { - checkFlatpakExtensionStatus, - installFlatpakExtension, - uninstallFlatpakExtension, - getFlatpakApps, - setFlatpakAppOverride, - removeFlatpakAppOverride, - FlatpakExtensionStatus, - FlatpakApp, - FlatpakAppInfo -} from '../api/lsfgApi'; -import t from '../i18n/i18n'; -import { showErrorToast } from '../utils/toastUtils'; - -interface FlatpaksModalProps { - closeModal?: () => void; -} - -export const FlatpaksModal: FC = ({ closeModal }) => { - const [extensionStatus, setExtensionStatus] = useState(null); - const [flatpakApps, setFlatpakApps] = useState(null); - const [loading, setLoading] = useState(true); - const [operationInProgress, setOperationInProgress] = useState(null); - const [operationError, setOperationError] = useState(null); - - const loadData = async () => { - setLoading(true); - try { - const [statusResult, appsResult] = await Promise.all([ - checkFlatpakExtensionStatus(), - getFlatpakApps() - ]); - - setExtensionStatus(statusResult); - setFlatpakApps(appsResult); - } catch (error) { - console.error('Error loading Flatpak data:', error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - }, []); - - const handleExtensionOperation = async (operation: 'install' | 'uninstall', version: string) => { - const operationId = `${operation}-${version}`; - setOperationInProgress(operationId); - setOperationError(null); - - try { - const result = operation === 'install' - ? await installFlatpakExtension(version) - : await uninstallFlatpakExtension(version); - - if (result.success) { - // Reload status after operation - const newStatus = await checkFlatpakExtensionStatus(); - setExtensionStatus(newStatus); - } else { - const message = result.error || result.message || 'Flatpak operation failed'; - setOperationError(message); - showErrorToast('Flatpak operation failed', message); - } - } catch (error) { - console.error(`Error ${operation}ing extension:`, error); - const message = String(error); - setOperationError(message); - showErrorToast('Flatpak operation failed', message); - } finally { - setOperationInProgress(null); - } - }; - - const handleAppOverrideToggle = async (app: FlatpakApp) => { - const hasOverrides = app.has_filesystem_override && app.has_env_override; - const operationId = `app-${app.app_id}`; - setOperationInProgress(operationId); - setOperationError(null); - - try { - const result = hasOverrides - ? await removeFlatpakAppOverride(app.app_id) - : await setFlatpakAppOverride(app.app_id); - - if (result.success) { - // Reload apps data after operation - const newApps = await getFlatpakApps(); - setFlatpakApps(newApps); - } else { - const message = result.error || result.message || 'Flatpak override failed'; - setOperationError(message); - showErrorToast('Flatpak override failed', message); - } - } catch (error) { - console.error('Error toggling app override:', error); - const message = String(error); - setOperationError(message); - showErrorToast('Flatpak override failed', message); - } finally { - setOperationInProgress(null); - } - }; - - const confirmOperation = (operation: () => void, title: string, description: string) => { - showModal( - {}} - /> - ); - }; - - if (loading) { - return ( - - {t('FLATPAK_MODAL_TITLE', 'Flatpak Extensions')} - -
- -
-
-
- ); - } - - const instructionSteps = [ - { - id: 'try-first', - title: t('FLATPAK_STEP_TRY_FIRST', 'Try first:'), - command: '~/lsfg' - }, - { - id: 'try-full-path', - title: t('FLATPAK_STEP_TRY_FULL_PATH', "If that doesn't work, try full path:"), - command: '/home/(username)/lsfg' - }, - { - id: 'final-result', - title: t('FLATPAK_STEP_FINAL', 'Final result should look like:'), - command: '~/lsfg "usr/bin/flatpak"' - } - ]; - - const focusableInstructionStyle: CSSProperties = { - padding: '10px', - background: 'rgba(0, 0, 0, 0.3)', - borderRadius: '6px', - marginBottom: '12px' - }; - - const commandStyle: CSSProperties = { - fontFamily: 'monospace', - fontSize: '0.85em', - background: 'rgba(0, 0, 0, 0.45)', - padding: '8px', - borderRadius: '4px', - marginTop: '6px' - }; - - return ( - - {t('FLATPAK_MODAL_TITLE', 'Flatpak Extensions')} - - - {/* Extension Status Section */} - - {t('FLATPAK_RUNTIME_INSTALLER', 'Runtime Extension Installer')} - - {operationError && ( - - } - /> - - )} - - {extensionStatus && extensionStatus.success ? ( - <> - - : } - > - { - const operation = extensionStatus.installed_23_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '23.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 23.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} - > - {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( - - ) : extensionStatus.installed_23_08 ? ( - <> - {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - - ) : ( - <> - {t('FLATPAK_INSTALL_BTN', 'Install')} - - )} - - - - - {/* 24.08 Runtime */} - - : } - > - { - const operation = extensionStatus.installed_24_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '24.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 24.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-24.08' || operationInProgress === 'uninstall-24.08'} - > - {operationInProgress === 'install-24.08' || operationInProgress === 'uninstall-24.08' ? ( - - ) : extensionStatus.installed_24_08 ? ( - <> - {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - - ) : ( - <> - {t('FLATPAK_INSTALL_BTN', 'Install')} - - )} - - - - - {/* 25.08 Runtime */} - - : } - > - { - const operation = extensionStatus.installed_25_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '25.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 25.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-25.08' || operationInProgress === 'uninstall-25.08'} - > - {operationInProgress === 'install-25.08' || operationInProgress === 'uninstall-25.08' ? ( - - ) : extensionStatus.installed_25_08 ? ( - <> - {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - - ) : ( - <> - {t('FLATPAK_INSTALL_BTN', 'Install')} - - )} - - - - - ) : ( - - } - /> - - )} - - - {/* Flatpak Apps Section */} - - {t('FLATPAK_APPS_TITLE', 'Flatpak Applications')} - - {flatpakApps && flatpakApps.success ? ( - flatpakApps.apps.length > 0 ? ( - flatpakApps.apps.map((app) => { - const hasOverrides = app.has_filesystem_override && app.has_env_override; - const partialOverrides = app.has_filesystem_override || app.has_env_override; - - let statusColor = 'red'; - let statusText = t('FLATPAK_STATUS_NO_OVERRIDES', 'No overrides'); - - if (hasOverrides) { - statusColor = 'green'; - statusText = t('FLATPAK_STATUS_CONFIGURED', 'Configured'); - } else if (partialOverrides) { - statusColor = 'orange'; - statusText = t('FLATPAK_STATUS_PARTIAL', 'Partial'); - } - - return ( - - } - > - handleAppOverrideToggle(app)} - disabled={operationInProgress === `app-${app.app_id}`} - /> - - - ); - }) - ) : ( - - - - ) - ) : ( - - } - /> - - )} - - - {/* Steam Configuration Instructions */} - - {t('FLATPAK_STEAM_CONFIG_TITLE', 'Steam Configuration')} -
-
- {t('FLATPAK_STEAM_CONFIG_HEADER', 'Configure Steam Flatpak Shortcuts')} -
-
- {t('FLATPAK_STEAM_CONFIG_DESC', 'In Steam, open your flatpak game and click the cog wheel.')} -
-
- IMPORTANT: {t('FLATPAK_STEAM_CONFIG_IMPORTANT', 'Set this in TARGET (NOT LAUNCH OPTIONS)')} -
- - {instructionSteps.map((step) => ( - {}} - style={focusableInstructionStyle} - > -
{step.title}
-
{step.command}
-
- ))} - - {}} - style={{ marginTop: '4px' }} - > -
- Steam Properties Target Field Example -
-
-
-
- - {/* Close Button */} - - - - {t('FLATPAK_CLOSE', 'Close')} - - - -
-
-
- ); -}; diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx new file mode 100644 index 0000000..dc73858 --- /dev/null +++ b/src/components/FlatpaksTab.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from "react"; +import { + ButtonItem, + ConfirmModal, + Field, + PanelSection, + PanelSectionRow, + Spinner, + Toggle, + showModal, +} from "@decky/ui"; +import { FaCheck, FaCog, FaDownload, FaTimes, FaTrash } from "react-icons/fa"; +import { + checkFlatpakExtensionStatus, + FlatpakApp, + FlatpakAppInfo, + FlatpakExtensionStatus, + getFlatpakApps, + installFlatpakExtension, + removeFlatpakAppOverride, + setFlatpakAppOverride, + uninstallFlatpakExtension, +} from "../api/lsfgApi"; +import { showErrorToast } from "../utils/toastUtils"; +import t from "../i18n/i18n"; + +const runtimeVersions = [ + { version: "23.08", key: "installed_23_08" }, + { version: "24.08", key: "installed_24_08" }, + { version: "25.08", key: "installed_25_08" }, +] as const; + +interface RuntimeRowProps { + version: string; + installed: boolean; + busy: boolean; + onAction: () => void; +} + +function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) { + return ( + + : } + > + + {busy ? : installed ? <> {t("FLATPAK_UNINSTALL_BTN", "Uninstall")} : <> {t("FLATPAK_INSTALL_BTN", "Install")}} + + + + ); +} + +interface AppRowProps { + app: FlatpakApp; + busy: boolean; + onToggle: () => void; +} + +function AppRow({ app, busy, onToggle }: AppRowProps) { + const configured = app.has_filesystem_override && app.has_env_override; + const partial = app.has_filesystem_override || app.has_env_override; + const status = configured + ? t("FLATPAK_STATUS_CONFIGURED", "Configured") + : partial + ? t("FLATPAK_STATUS_PARTIAL", "Partial") + : t("FLATPAK_STATUS_NO_OVERRIDES", "No overrides"); + + return ( + + } + > + + + + ); +} + +export function FlatpaksTab() { + const [extensionStatus, setExtensionStatus] = useState(null); + const [apps, setApps] = useState(null); + const [loading, setLoading] = useState(true); + const [operation, setOperation] = useState(null); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + try { + const [nextStatus, nextApps] = await Promise.all([ + checkFlatpakExtensionStatus(), + getFlatpakApps(), + ]); + setExtensionStatus(nextStatus); + setApps(nextApps); + } catch (loadError) { + setError(String(loadError)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const runExtensionOperation = async (version: string, installed: boolean) => { + const action = installed ? "uninstall" : "install"; + setOperation(`${action}-${version}`); + setError(null); + try { + const result = installed + ? await uninstallFlatpakExtension(version) + : await installFlatpakExtension(version); + if (!result.success) throw new Error(result.error || result.message); + setExtensionStatus(await checkFlatpakExtensionStatus()); + } catch (operationError) { + const message = String(operationError); + setError(message); + showErrorToast("Flatpak operation failed", message); + } finally { + setOperation(null); + } + }; + + const confirmExtensionOperation = (version: string, installed: boolean) => { + if (!installed) { + void runExtensionOperation(version, false); + return; + } + showModal( + void runExtensionOperation(version, true)} + onCancel={() => {}} + />, + ); + }; + + const toggleApp = async (app: FlatpakApp) => { + const configured = app.has_filesystem_override && app.has_env_override; + setOperation(`app-${app.app_id}`); + setError(null); + try { + const result = configured + ? await removeFlatpakAppOverride(app.app_id) + : await setFlatpakAppOverride(app.app_id); + if (!result.success) throw new Error(result.error || result.message); + setApps(await getFlatpakApps()); + } catch (operationError) { + const message = String(operationError); + setError(message); + showErrorToast("Flatpak override failed", message); + } finally { + setOperation(null); + } + }; + + if (loading) { + return ; + } + + return ( + <> + + {error && } />} + {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => ( + confirmExtensionOperation(version, extensionStatus[key])} + /> + )) : } + + + + {apps?.success ? apps.apps.length ? apps.apps.map((app) => ( + void toggleApp(app)} + /> + )) : : } + + + ); +} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 8a0df6c..a231477 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -13,7 +13,7 @@ interface Props { export function GameConfigurationSelector({ targets, runningGame, selectedAppId, onSelect, onReset, onResetAll }: Props) { const options: DropdownOption[] = [ { data: "", label: runningGame ? `Default (editing template) · ${runningGame.name}` : "Default" }, - ...targets.map((target) => ({ data: target.appid, label: `${target.name} · ${target.appid}` })), + ...targets.map((target) => ({ data: target.appid, label: `${target.nonSteam ? "Non-Steam · " : ""}${target.name} · ${target.appid}` })), ]; return <> diff --git a/src/components/NerdStuffModal.tsx b/src/components/NerdStuffModal.tsx deleted file mode 100644 index f075ccb..0000000 --- a/src/components/NerdStuffModal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState, useEffect } from "react"; -import { - ModalRoot, - Field, - Focusable, - DialogControlsSection, - PanelSectionRow, - ButtonItem -} from "@decky/ui"; -import { - getConfigFileContent, - getLaunchScriptContent, - FileContentResult, -} from "../api/lsfgApi"; -import t from '../i18n/i18n'; - -interface NerdStuffModalProps { - closeModal?: () => void; -} - -export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { - const [configContent, setConfigContent] = useState(null); - const [scriptContent, setScriptContent] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const loadData = async () => { - try { - setLoading(true); - setError(null); - - // Load all data in parallel - const [configResult, scriptResult] = await Promise.all([ - getConfigFileContent(), - getLaunchScriptContent(), - ]); - - setConfigContent(configResult); - setScriptContent(scriptResult); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load data"); - } finally { - setLoading(false); - } - }; - - loadData(); - }, []); - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - // Could add a toast notification here if desired - } catch (err) { - console.error("Failed to copy to clipboard:", err); - } - }; - - return ( - - {loading && ( -
{t('NERD_LOADING', 'Loading information...')}
- )} - - {error && ( -
Error: {error}
- )} - - {!loading && !error && ( - <> - {/* Launch Script Section */} - {scriptContent && ( - - {!scriptContent.success ? ( -
{t('NERD_SCRIPT_NOT_FOUND_PREFIX', 'Script not found:')} {scriptContent.error}
- ) : ( -
-
- {t('NERD_PATH_PREFIX', 'Path:')} {scriptContent.path} -
- scriptContent.content && copyToClipboard(scriptContent.content)} - onActivate={() => scriptContent.content && copyToClipboard(scriptContent.content)} - > -
-                      {scriptContent.content || t('NERD_NO_CONTENT', 'No content')}
-                    
-
-
- )} -
- )} - - {/* Config File Section */} - {configContent && ( - - {!configContent.success ? ( -
{t('NERD_CONFIG_NOT_FOUND_PREFIX', 'Config not found:')} {configContent.error}
- ) : ( -
-
- {t('NERD_PATH_PREFIX', 'Path:')} {configContent.path} -
- configContent.content && copyToClipboard(configContent.content)} - onActivate={() => configContent.content && copyToClipboard(configContent.content)} - > -
-                      {configContent.content || t('NERD_NO_CONTENT', 'No content')}
-                    
-
-
- )} -
- )} - - {/* Close Button */} - - - - {t('NERD_CLOSE', 'Close')} - - - - - )} -
- ); -} diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx new file mode 100644 index 0000000..34106ed --- /dev/null +++ b/src/components/SetupTab.tsx @@ -0,0 +1,47 @@ +import { PanelSection } from "@decky/ui"; +import type { SteamBranchStatus } from "../api/lsfgApi"; +import { InstallationButton } from "./InstallationButton"; +import { StatusDisplay } from "./StatusDisplay"; + +interface SetupTabProps { + isInstalled: boolean; + installationStatus: string; + losslessScalingInstalled: boolean; + losslessScalingStatus: string; + steamBranchStatus: SteamBranchStatus | null; + isInstalling: boolean; + isUninstalling: boolean; + onInstall: () => void; + onUninstall: () => void; +} + +export function SetupTab({ + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + onInstall, + onUninstall, +}: SetupTabProps) { + return ( + + + + + ); +} diff --git a/src/components/SmartClipboardButton.tsx b/src/components/SmartClipboardButton.tsx deleted file mode 100644 index 8da239a..0000000 --- a/src/components/SmartClipboardButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; -import { getLaunchOption } from "../api/lsfgApi"; -import { showClipboardErrorToast } from "../utils/toastUtils"; -import { copyWithVerification } from "../utils/clipboardUtils"; -import t from '../i18n/i18n'; - -export function SmartClipboardButton() { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - - useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; - }, [showSuccess]); - - const getLaunchOptionText = async (): Promise => { - try { - const result = await getLaunchOption(); - return result.launch_option || "~/lsfg %command%"; - } catch (error) { - return "~/lsfg %command%"; - } - }; - - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - setIsLoading(true); - try { - const text = await getLaunchOptionText(); - const { success, verified } = await copyWithVerification(text); - - if (success) { - setShowSuccess(true); - if (!verified) { - console.log('Copy verification failed but copy likely worked'); - } - } else { - showClipboardErrorToast(); - } - - } catch (error) { - showClipboardErrorToast(); - } finally { - setIsLoading(false); - } - }; - - return ( - - -
- {showSuccess ? ( - - ) : isLoading ? ( - - ) : ( - - )} -
- {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_COPY_LAUNCH', 'Copy Launch Option')} -
-
-
- -
- ); -} diff --git a/src/components/UsageInstructions.tsx b/src/components/UsageInstructions.tsx deleted file mode 100644 index 36e31cf..0000000 --- a/src/components/UsageInstructions.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { PanelSectionRow } from "@decky/ui"; -import t from '../i18n/i18n'; - -export function UsageInstructions() { - return ( - <> - -
- {t('USAGE_TITLE', 'Usage Instructions')} -
-
- - -
- {t('USAGE_DESC', 'Click "Copy Launch Option" button, then paste it into your Steam game\'s launch options to enable frame generation.')} -
-
- - -
- ~/lsfg %command% -
-
- - -
- {t('USAGE_CONFIG_NOTE', 'The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.')} -
-
- - ); -} diff --git a/src/components/index.ts b/src/components/index.ts index 4284aee..5089b6f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,9 +3,9 @@ export { StatusDisplay } from "./StatusDisplay"; export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; -export { UsageInstructions } from "./UsageInstructions"; -export { SmartClipboardButton } from "./SmartClipboardButton"; export { FgmodClipboardButton } from "./FgmodClipboardButton"; -export { NerdStuffModal } from "./NerdStuffModal"; -export { FlatpaksModal } from "./FlatpaksModal"; +export { ConfigurationTab } from "./ConfigurationTab"; +export { SetupTab } from "./SetupTab"; +export { ConfigFileTab } from "./ConfigFileTab"; +export { FlatpaksTab } from "./FlatpaksTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index e0ba360..b177551 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -3,7 +3,7 @@ import { Router } from "@decky/ui"; import { getGameConfigs, getInstalledGames, updateGameConfig, updateLsfgConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type InstalledGame } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -export interface GameTarget { appid: string; name: string; configured: boolean; } +export interface GameTarget extends InstalledGame { configured: boolean; } export function useGameConfiguration() { const [defaultConfig, setDefaultConfig] = useState(getDefaults()); @@ -26,13 +26,21 @@ export function useGameConfiguration() { useEffect(() => { const poll = () => { const app = Router.MainRunningApp as any; - if (app?.appid) setRunningGame({ appid: String(app.appid), name: app.display_name || `App ${app.appid}`, configured: games.some((game) => game.appid === String(app.appid)) }); - else setRunningGame(null); + if (!app?.appid) return setRunningGame(null); + const appid = String(app.appid); + const installed = installedGames.find((game) => game.appid === appid); + const name = app.display_name || installed?.name; + if (!name) return setRunningGame(null); + setRunningGame({ + ...(installed || { appid, name, nonSteam: false }), + name, + configured: games.some((game) => game.appid === appid), + }); }; poll(); const interval = window.setInterval(poll, 2000); return () => window.clearInterval(interval); - }, [games]); + }, [games, installedGames]); useEffect(() => { if (!autoSelected.current && runningGame) { autoSelected.current = true; @@ -41,8 +49,8 @@ export function useGameConfiguration() { }, [runningGame]); const targets = useMemo(() => { - const configured = installedGames.map((game) => ({ appid: game.appid, name: game.name, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: `App ${game.appid}`, configured: true }); + const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true }); if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); return configured; }, [games, installedGames, runningGame]); @@ -55,9 +63,11 @@ export function useGameConfiguration() { if (result.success) setDefaultConfig(next); return; } - const result = await updateGameConfig(selectedAppId, next); + const selectedTarget = targets.find((target) => target.appid === selectedAppId); + if (!selectedTarget?.name) return; + const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); - }, [load, selectedAppId]); + }, [load, selectedAppId, targets]); const resetSelected = useCallback(async () => { if (selectedAppId) { await resetGameConfig(selectedAppId); setSelectedAppId(""); await load(); } diff --git a/src/i18n/languages.json b/src/i18n/languages.json index 46b4cd9..0528341 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -58,17 +58,12 @@ "FLATPAK_ERROR": "エラー", "FLATPAK_ERROR_STATUS": "拡張ステータスの確認に失敗しました", "FLATPAK_ERROR_APPS": "Flatpakアプリケーションの読み込みに失敗しました", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam設定", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpakショートカットの設定", - "FLATPAK_STEAM_CONFIG_DESC": "Steamでflatpakゲームを開き、歯車アイコンをクリックしてください。", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "重要: 起動オプションではなくターゲット(TARGET)に設定してください", - "FLATPAK_STEP_TRY_FIRST": "まず試す:", - "FLATPAK_STEP_TRY_FULL_PATH": "うまくいかない場合、フルパスを試す:", - "FLATPAK_STEP_FINAL": "最終的な結果はこのようになります:", "FLATPAK_CLOSE": "閉じる", "NERD_LOADING": "情報を読み込み中...", - "NERD_LAUNCH_SCRIPT": "起動スクリプト", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "スクリプトが見つかりません:", + "NERD_DLL_PATH": "DLLパス", + "NERD_NOT_AVAILABLE": "利用不可", + "NERD_DLL_HASH": "DLL SHA256ハッシュ", + "NERD_DETECTION_SOURCE": "検出ソース", "NERD_PATH_PREFIX": "パス:", "NERD_NO_CONTENT": "コンテンツなし", "NERD_CONFIG_FILE": "設定ファイル", @@ -94,12 +89,8 @@ "PROFILE_DELETE_BTN": "削除", "PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません", "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません", - "USAGE_TITLE": "使用方法", - "USAGE_DESC": "「起動オプションをコピー」ボタンをクリックし、Steamゲームの起動オプションに貼り付けてフレーム生成を有効化してください。", - "USAGE_CONFIG_NOTE": "設定は~/.config/lsfg-vk/conf.tomlに保存され、ゲーム実行中もホットリロードされます。", "CLIPBOARD_COPIED": "クリップボードにコピーしました", "CLIPBOARD_COPYING": "コピー中...", - "CLIPBOARD_COPY_LAUNCH": "起動オプションをコピー", "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" }, "ko": { @@ -161,17 +152,12 @@ "FLATPAK_ERROR": "오류", "FLATPAK_ERROR_STATUS": "확장 상태 확인 실패", "FLATPAK_ERROR_APPS": "Flatpak 애플리케이션 로드 실패", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam 설정", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpak 단축키 설정", - "FLATPAK_STEAM_CONFIG_DESC": "Steam에서 Flatpak 게임을 열고 톱니바퀴를 클릭하세요.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "중요: 실행 옵션이 아닌 대상(TARGET)에 설정하세요", - "FLATPAK_STEP_TRY_FIRST": "먼저 시도:", - "FLATPAK_STEP_TRY_FULL_PATH": "작동하지 않으면 전체 경로 시도:", - "FLATPAK_STEP_FINAL": "최종 결과는 다음과 같아야 합니다:", "FLATPAK_CLOSE": "닫기", "NERD_LOADING": "정보 불러오는 중...", - "NERD_LAUNCH_SCRIPT": "실행 스크립트", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "스크립트 없음:", + "NERD_DLL_PATH": "DLL 경로", + "NERD_NOT_AVAILABLE": "사용 불가", + "NERD_DLL_HASH": "DLL SHA256 해시", + "NERD_DETECTION_SOURCE": "감지 소스", "NERD_PATH_PREFIX": "경로:", "NERD_NO_CONTENT": "내용 없음", "NERD_CONFIG_FILE": "설정 파일", @@ -197,12 +183,8 @@ "PROFILE_DELETE_BTN": "삭제", "PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가", "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다", - "USAGE_TITLE": "사용 방법", - "USAGE_DESC": "\"실행 옵션 복사\" 버튼을 클릭한 후, Steam 게임의 실행 옵션에 붙여넣어 프레임 생성을 활성화하세요.", - "USAGE_CONFIG_NOTE": "설정은 ~/.config/lsfg-vk/conf.toml에 저장되며 게임 실행 중에도 즉시 반영됩니다.", "CLIPBOARD_COPIED": "클립보드에 복사됨", "CLIPBOARD_COPYING": "복사 중...", - "CLIPBOARD_COPY_LAUNCH": "실행 옵션 복사", "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" }, "language_metadata": { @@ -292,17 +274,12 @@ "FLATPAK_ERROR": "Error", "FLATPAK_ERROR_STATUS": "Failed to check extension status", "FLATPAK_ERROR_APPS": "Failed to load Flatpak applications", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam Configuration", - "FLATPAK_STEAM_CONFIG_HEADER": "Configure Steam Flatpak Shortcuts", - "FLATPAK_STEAM_CONFIG_DESC": "In Steam, open your flatpak game and click the cog wheel.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "IMPORTANT: Set this in TARGET (NOT LAUNCH OPTIONS)", - "FLATPAK_STEP_TRY_FIRST": "Try first:", - "FLATPAK_STEP_TRY_FULL_PATH": "If that doesn't work, try full path:", - "FLATPAK_STEP_FINAL": "Final result should look like:", "FLATPAK_CLOSE": "Close", "NERD_LOADING": "Loading information...", - "NERD_LAUNCH_SCRIPT": "Launch Script", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "Script not found:", + "NERD_DLL_PATH": "DLL Path", + "NERD_NOT_AVAILABLE": "Not available", + "NERD_DLL_HASH": "DLL SHA256 Hash", + "NERD_DETECTION_SOURCE": "Detection Source", "NERD_PATH_PREFIX": "Path:", "NERD_NO_CONTENT": "No content", "NERD_CONFIG_FILE": "Configuration File", @@ -328,12 +305,8 @@ "PROFILE_DELETE_BTN": "Delete", "PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile", "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed", - "USAGE_TITLE": "Usage Instructions", - "USAGE_DESC": "Click \"Copy Launch Option\" button, then paste it into your Steam game's launch options to enable frame generation.", - "USAGE_CONFIG_NOTE": "The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.", "CLIPBOARD_COPIED": "Copied to clipboard", "CLIPBOARD_COPYING": "Copying...", - "CLIPBOARD_COPY_LAUNCH": "Copy Launch Option", "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" } } diff --git a/src/styles.ts b/src/styles.ts new file mode 100644 index 0000000..fe40a59 --- /dev/null +++ b/src/styles.ts @@ -0,0 +1,34 @@ +export const tabStyles = ` + .lsfg-vk-tabs > div > div:first-child::before { + background: #0D141C; + box-shadow: none; + backdrop-filter: none; + } + + .lsfg-vk-tabs [role="tabpanel"] { + padding-left: 8px !important; + padding-right: 8px !important; + } + + .lsfg-vk-tabs [role="tablist"] { + display: flex; + flex-wrap: nowrap; + justify-content: center; + } + + .lsfg-vk-tabs [role="tab"] { + flex: 0 1 auto; + min-width: 0; + box-sizing: border-box; + padding-left: 6px !important; + padding-right: 6px !important; + display: flex !important; + align-items: center; + justify-content: center; + } + + .lsfg-vk-tabs [role="tab"] svg { + display: block; + margin: 0; + } +`; -- cgit v1.2.3 From 170d183fdafc1ec1a686c006fd6a2431c1f30c71 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 19:06:52 -0400 Subject: refactor: align secondary tabs with Decky UI --- src/components/ConfigFileTab.tsx | 42 ++++++++++++------------ src/components/FlatpaksTab.tsx | 37 +++++++++------------ src/components/InstallationButton.tsx | 43 +++++------------------- src/components/SetupTab.tsx | 1 - src/components/StatusDisplay.tsx | 61 +++++++---------------------------- 5 files changed, 57 insertions(+), 127 deletions(-) diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx index 60c5c7e..e3cc09d 100644 --- a/src/components/ConfigFileTab.tsx +++ b/src/components/ConfigFileTab.tsx @@ -1,19 +1,11 @@ import { useEffect, useState } from "react"; -import { Field, Focusable, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; +import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; import { getConfigFileContent, FileContentResult } from "../api/lsfgApi"; import t from "../i18n/i18n"; export function ConfigFileTab() { const [result, setResult] = useState(null); - const copy = async (content: string) => { - try { - await navigator.clipboard.writeText(content); - } catch { - // Clipboard access is unavailable in some Deck UI contexts. - } - }; - useEffect(() => { getConfigFileContent().then(setResult).catch((error) => { setResult({ success: false, error: String(error) }); @@ -23,24 +15,32 @@ export function ConfigFileTab() { if (!result) { return ( - + + + ); } return ( - - - {result.success && result.content && ( - void copy(result.content || "")}> -
-                {result.content}
-              
-
- )} -
-
+ {result.error && ( + + + + )} + {result.success && result.content && ( + <> + + + + +
+              {result.content}
+            
+
+ + )}
); } diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx index dc73858..b4d5e4d 100644 --- a/src/components/FlatpaksTab.tsx +++ b/src/components/FlatpaksTab.tsx @@ -1,15 +1,12 @@ import { useEffect, useState } from "react"; import { - ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, - Spinner, - Toggle, + ToggleField, showModal, } from "@decky/ui"; -import { FaCheck, FaCog, FaDownload, FaTimes, FaTrash } from "react-icons/fa"; import { checkFlatpakExtensionStatus, FlatpakApp, @@ -40,15 +37,13 @@ interface RuntimeRowProps { function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) { return ( - : } - > - - {busy ? : installed ? <> {t("FLATPAK_UNINSTALL_BTN", "Uninstall")} : <> {t("FLATPAK_INSTALL_BTN", "Install")}} - - + description={busy ? "Updating..." : installed ? t("FLATPAK_INSTALLED", "Installed") : t("FLATPAK_NOT_INSTALLED", "Not installed")} + checked={installed} + onChange={() => onAction()} + disabled={busy} + /> ); } @@ -70,13 +65,13 @@ function AppRow({ app, busy, onToggle }: AppRowProps) { return ( - } - > - - + checked={configured} + onChange={onToggle} + disabled={busy} + /> ); } @@ -162,13 +157,13 @@ export function FlatpaksTab() { }; if (loading) { - return ; + return ; } return ( <> - - {error && } />} + + {error && } {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => (
} - + {apps?.success ? apps.apps.length ? apps.apps.map((app) => ( { - if (isInstalling) { - return ( -
-
{t('INSTALL_INSTALLING', 'Installing...')}
-
- ); - } - - if (isUninstalling) { - return ( -
-
{t('INSTALL_UNINSTALLING', 'Uninstalling...')}
-
- ); - } - - if (isInstalled) { - return ( -
- -
{t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK')}
-
- ); - } - - return ( -
- -
{t('INSTALL_INSTALL_BTN', 'Install LSFG-VK')}
-
- ); - }; + const label = isInstalling + ? t('INSTALL_INSTALLING', 'Installing...') + : isUninstalling + ? t('INSTALL_UNINSTALLING', 'Uninstalling...') + : isInstalled + ? t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK') + : t('INSTALL_INSTALL_BTN', 'Install LSFG-VK'); return ( @@ -58,7 +31,7 @@ export function InstallationButton({ onClick={isInstalled ? onUninstall : onInstall} disabled={isInstalling || isUninstalling} > - {renderButtonContent()} + {label} ); diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 34106ed..98d6e78 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -29,7 +29,6 @@ export function SetupTab({ return ( -
-
- {losslessScalingAppInstalled ? "Lossless Scaling Installed" : "Lossless Scaling Not Installed"} -
- {!losslessScalingAppInstalled && losslessScalingStatus && ( -
- {losslessScalingStatus} -
- )} -
- {installationStatus} -
-
+ +
+ + {steamBranchStatus?.installed && ( -
-
- Steam branch: {steamBranchStatus.current_branch || "public"} - {steamBranchStatus.needs_switch && ( -
- {steamBranchStatus.message} -
- )} -
-
+
)} -- 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 --- README.md | 41 +-------- defaults/i18n/ja.json | 5 +- defaults/i18n/ko.json | 5 +- defaults/i18n/template.json | 5 +- 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 +------ src/api/lsfgApi.ts | 36 +------- src/components/ConfigurationSection.tsx | 10 +-- src/components/ConfigurationTab.tsx | 86 +++++++++++++----- src/components/Content.tsx | 45 ++++++++-- src/components/FgmodClipboardButton.tsx | 110 ----------------------- src/components/FpsMultiplierControl.tsx | 70 ++++----------- src/components/GameConfigurationControls.tsx | 17 ++++ src/components/GameConfigurationSelector.tsx | 67 ++++++++++---- src/components/NowPlayingTab.tsx | 32 +++++++ src/components/index.ts | 3 +- src/hooks/useGameConfiguration.ts | 61 ++++++++----- src/hooks/useLsfgHooks.ts | 61 +------------ src/i18n/languages.json | 15 +--- src/styles.ts | 2 +- src/utils/clipboardUtils.ts | 64 -------------- src/utils/toastUtils.ts | 22 ----- 25 files changed, 302 insertions(+), 728 deletions(-) delete mode 100644 src/components/FgmodClipboardButton.tsx create mode 100644 src/components/GameConfigurationControls.tsx create mode 100644 src/components/NowPlayingTab.tsx delete mode 100644 src/utils/clipboardUtils.ts diff --git a/README.md b/README.md index 4be9cca..13918fd 100644 --- a/README.md +++ b/README.md @@ -34,56 +34,19 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer 4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings -5. **Apply launch option** to games you want to use frame generation with: - - Add `~/lsfg %command%` to your game's launch options in Steam Properties - - Or use the "Launch Option Clipboard" button in the plugin to copy the command 6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile -## Configuration Options - -The plugin edits upstream lsfg-vk v2 profiles directly. Unassigned games leave the layer unloaded. - ### Core Settings + - **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) -- **Performance Mode**: Uses a lighter processing model - recommended for most games +- **Performance Mode**: Uses a lighter processing model (recommended for most games) - **FP16 Acceleration**: Use half-precision acceleration when supported ## Feedback and Support For per-game feedback and community support, please join the [decky-lsfg-vk Discord Channel](https://discord.gg/TwvHdVucC3) -## Troubleshooting - -**Frame generation not working?** -- Ensure you've added `~/lsfg %command%` to your game's launch options -- Ensure the Lossless Scaling installation is visible to the upstream lsfg-vk search paths -- Try enabling Performance Mode if you're experiencing crashes -- Make sure your game is running in fullscreen mode for best results - -**Performance issues?** -- Lower the Flow Scale setting for better performance -- Enable Performance Mode (recommended for most games) -- Try reducing the FPS multiplier from 4x to 2x or 3x -- Consider using the experimental FPS limit feature for DirectX games - -## What it does - -The plugin: -- Installs the pinned lsfg-vk 2.0.0 x86_64 and x86 Vulkan layers from the official upstream build -- Configures the Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` -- Creates and migrates the v2 TOML configuration in `~/.config/lsfg-vk/conf.toml`, preserving a one-time v1 backup during upgrade -- Delegates Lossless Scaling DLL discovery to lsfg-vk's upstream runtime search -- Provides an easy-to-use interface to configure frame generation settings: - - **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation - - **Flow Scale**: Adjust motion estimation quality vs performance - - **Performance Mode**: Use lighter processing for better performance - - **FP16 Acceleration**: Use half-precision acceleration when supported -- **Present Mode**: Override FIFO/VSync behavior -- **Swapchain Image Count**: Preserve the application's swapchain image count -- **Hot-reloading**: Upstream reloads settings for the active profile; profile assignment itself applies on the next launch -- Easy uninstallation that removes all installed files when no longer needed - ## Credits - **[PancakeTAS](https://lsfg-vk.dev/)** for creating the lsfg-vk Vulkan compatibility layer diff --git a/defaults/i18n/ja.json b/defaults/i18n/ja.json index 7b97bc5..92a8701 100644 --- a/defaults/i18n/ja.json +++ b/defaults/i18n/ja.json @@ -87,8 +87,5 @@ "PROFILE_DELETE_DESC_SUFFIX": "この操作は取り消せません。", "PROFILE_DELETE_BTN": "削除", "PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません", - "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません", - "CLIPBOARD_COPIED": "クリップボードにコピーしました", - "CLIPBOARD_COPYING": "コピー中...", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません" } diff --git a/defaults/i18n/ko.json b/defaults/i18n/ko.json index cbc3a8e..947640d 100644 --- a/defaults/i18n/ko.json +++ b/defaults/i18n/ko.json @@ -87,8 +87,5 @@ "PROFILE_DELETE_DESC_SUFFIX": "이 작업은 취소할 수 없습니다.", "PROFILE_DELETE_BTN": "삭제", "PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가", - "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다", - "CLIPBOARD_COPIED": "클립보드에 복사됨", - "CLIPBOARD_COPYING": "복사 중...", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다" } diff --git a/defaults/i18n/template.json b/defaults/i18n/template.json index b9dfcc5..fce97b4 100644 --- a/defaults/i18n/template.json +++ b/defaults/i18n/template.json @@ -87,8 +87,5 @@ "PROFILE_DELETE_DESC_SUFFIX": "? This action cannot be undone.", "PROFILE_DELETE_BTN": "Delete", "PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile", - "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed", - "CLIPBOARD_COPIED": "Copied to clipboard", - "CLIPBOARD_COPYING": "Copying...", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed" } 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] diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 04d1309..8eaad98 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -32,12 +32,6 @@ export interface SteamBranchStatus { // Use centralized configuration data type export type LsfgConfig = ConfigurationData; -export interface ConfigResult { - success: boolean; - config?: LsfgConfig; - error?: string; -} - export interface ConfigUpdateResult { success: boolean; message?: string; @@ -51,10 +45,11 @@ export interface GameConfigEntry { } export interface InstalledGame { appid: string; name: string; nonSteam: boolean; } export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } +export interface GlobalConfig { dll: string; no_fp16: boolean; } export interface GameConfigsResult { success: boolean; - default?: LsfgConfig; + global_config?: GlobalConfig; games?: GameConfigEntry[]; error?: string; } @@ -65,12 +60,6 @@ export interface GameConfigResult extends ConfigUpdateResult { config?: LsfgConfig; } -export interface ConfigSchemaResult { - field_names: string[]; - field_types: Record; - defaults: ConfigurationData; -} - export interface FileContentResult { success: boolean; content?: string; @@ -78,13 +67,6 @@ export interface FileContentResult { error?: string; } -export interface FgmodCheckResult { - success: boolean; - exists: boolean; - path?: string; - error?: string; -} - // Flatpak management interfaces export interface FlatpakExtensionStatus { success: boolean; @@ -123,10 +105,7 @@ export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk") export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); -export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config"); -export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); -export const checkFgmodDirectory = callable<[], FgmodCheckResult>("check_fgmod_directory"); // Flatpak management API functions export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status"); @@ -136,19 +115,8 @@ export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps"); export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override"); export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override"); -// Updated config function using object-based configuration (single source of truth) -export const updateLsfgConfig = callable< - [ConfigurationData], - ConfigUpdateResult ->("update_lsfg_config"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); -export const getGameConfig = callable<[string], GameConfigResult>("get_game_config"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); - -// Legacy helper function for backward compatibility -export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise => { - return updateLsfgConfig(config); -}; diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index c2bba7e..1f17264 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -10,19 +10,19 @@ interface ConfigurationSectionProps { export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) { return <> - onConfigChange(FLOW_SCALE, value)} /> + onConfigChange(FLOW_SCALE, value)} /> - onConfigChange(NO_FP16, !value)} /> + onConfigChange(NO_FP16, !value)} /> - onConfigChange(PERFORMANCE_MODE, value)} /> + onConfigChange(PERFORMANCE_MODE, value)} /> - onConfigChange(OVERRIDE_PRESENT_MODE, value)} /> + onConfigChange(OVERRIDE_PRESENT_MODE, value)} /> - onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} /> + onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} /> ; } diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 6f3f474..dab6f19 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,19 +1,17 @@ -import { PanelSection } from "@decky/ui"; +import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; -import { ConfigurationSection } from "./ConfigurationSection"; -import { FgmodClipboardButton } from "./FgmodClipboardButton"; -import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { GameConfigurationControls } from "./GameConfigurationControls"; import { GameConfigurationSelector } from "./GameConfigurationSelector"; -import t from "../i18n/i18n"; interface ConfigurationTabProps { config: ConfigurationData; targets: GameTarget[]; runningGame: GameTarget | null; - selectedAppId: string; onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; + onEnable: (appid: string) => Promise; onReset: () => Promise; onResetAll: () => Promise; } @@ -22,33 +20,77 @@ export function ConfigurationTab({ config, targets, runningGame, - selectedAppId, onSelect, onConfigChange, + onEnable, onReset, onResetAll, }: ConfigurationTabProps) { - return ( - <> - - - - + const [detailAppId, setDetailAppId] = useState(null); + const promptedRunningAppId = useRef(null); + + useEffect(() => { + if (!runningGame || runningGame.configured) { + promptedRunningAppId.current = null; + return; + } + if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) { + promptedRunningAppId.current = runningGame.appid; + setDetailAppId(runningGame.appid); + } + }, [detailAppId, runningGame?.appid, runningGame?.configured]); + + const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null; + + if (detailAppId === null) { + return ( + { + onSelect(appid); + setDetailAppId(appid); + }} onResetAll={onResetAll} /> - - - - - + ); + } + + const profileLabel = selectedTarget?.name || "Game profile"; + const running = selectedTarget?.appid === runningGame?.appid; + const profileDescription = selectedTarget + ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? running && !runningGame?.configured ? "Profile saved · applies next launch" : "Profile active" : "Not configured · changes apply next launch"}` + : "Game is no longer available"; + + return ( + setDetailAppId(null)}> + + + + + + setDetailAppId(null)}>Back to games + - + + + { + if (selectedTarget?.configured) { + promptedRunningAppId.current = detailAppId; + await onReset(); + setDetailAppId(null); + } else if (detailAppId) { + await onEnable(detailAppId); + } + }} + > + {selectedTarget?.configured ? "Remove profile" : "Enable for next launch"} + + + ); } diff --git a/src/components/Content.tsx b/src/components/Content.tsx index de8f996..9281d39 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,6 +1,6 @@ import { Tabs } from "@decky/ui"; -import { useEffect, useState } from "react"; -import { FaFileAlt, FaGamepad, FaLayerGroup, FaTools } from "react-icons/fa"; +import { useEffect, useRef, useState } from "react"; +import { FaFileAlt, FaGamepad, FaLayerGroup, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; @@ -9,10 +9,12 @@ import { useInstallationStatus } from "../hooks/useLsfgHooks"; import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; import { FlatpaksTab } from "./FlatpaksTab"; +import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; const tabIcons = { - configuration: , + nowPlaying: , + configuration: , flatpak: , configFile: , setup: , @@ -33,9 +35,9 @@ export function Content() { config, targets, runningGame, - selectedAppId, setSelectedAppId, save, + enable, resetSelected, resetAll, reload, @@ -48,10 +50,27 @@ export function Content() { steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; + const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null); useEffect(() => { - setTab(setupComplete ? "Configuration" : "Setup"); - }, [setupComplete]); + if (!setupComplete) { + setTab("Setup"); + return; + } + setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current); + }, [runningGame?.configured, setupComplete]); + + useEffect(() => { + if (!setupComplete) return; + const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null; + const previous = previousRunningState.current; + previousRunningState.current = current; + if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) { + setTab(current.configured ? "NowPlaying" : "Configuration"); + } else if (!current && previous) { + setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab); + } + }, [runningGame?.appid, runningGame?.configured, setupComplete]); useEffect(() => { if (isInstalled) void reload(); @@ -88,6 +107,18 @@ export function Content() { const tabs = setupComplete ? [ + ...(runningGame?.configured ? [{ + id: "NowPlaying", + title: tabIcons.nowPlaying, + content: ( + + ), + }] : []), { id: "Configuration", title: tabIcons.configuration, @@ -96,9 +127,9 @@ export function Content() { config={config} targets={targets} runningGame={runningGame} - selectedAppId={selectedAppId} onSelect={setSelectedAppId} onConfigChange={handleConfigChange} + onEnable={enable} onReset={resetSelected} onResetAll={resetAll} /> diff --git a/src/components/FgmodClipboardButton.tsx b/src/components/FgmodClipboardButton.tsx deleted file mode 100644 index efc5490..0000000 --- a/src/components/FgmodClipboardButton.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; -import { checkFgmodDirectory } from "../api/lsfgApi"; -import { showClipboardErrorToast } from "../utils/toastUtils"; -import { copyWithVerification } from "../utils/clipboardUtils"; -import t from '../i18n/i18n'; - -export function FgmodClipboardButton() { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - const [fgmodExists, setFgmodExists] = useState(false); - const [checkingFgmod, setCheckingFgmod] = useState(true); - - // Check for fgmod directory on component mount - useEffect(() => { - const checkFgmod = async () => { - try { - const result = await checkFgmodDirectory(); - setFgmodExists(result.exists); - } catch (error) { - console.error("Error checking fgmod directory:", error); - setFgmodExists(false); - } finally { - setCheckingFgmod(false); - } - }; - - checkFgmod(); - }, []); - - // Reset success state after 3 seconds - useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; - }, [showSuccess]); - - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - setIsLoading(true); - try { - const text = "~/fgmod/fgmod ~/lsfg %command%"; - const { success, verified } = await copyWithVerification(text); - - if (success) { - // Show success feedback in the button instead of toast - setShowSuccess(true); - if (!verified) { - // Copy worked but verification failed - still show success - console.log('Copy verification failed but copy likely worked'); - } - } else { - showClipboardErrorToast(); - } - } catch (error) { - showClipboardErrorToast(); - } finally { - setIsLoading(false); - } - }; - - // Don't render if fgmod directory doesn't exist or we're still checking - if (checkingFgmod || !fgmodExists) { - return null; - } - - return ( - - -
- {showSuccess ? ( - - ) : isLoading ? ( - - ) : ( - - )} -
- {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_LSFG_FGMOD', 'LSFG + DeckyFG')} -
-
-
- -
- ); -} diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 5069c9a..e197dc3 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,4 +1,4 @@ -import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui"; +import { PanelSectionRow, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; import t from "../i18n/i18n"; @@ -12,62 +12,22 @@ export function FpsMultiplierControl({ config, onConfigChange }: FpsMultiplierControlProps) { + const multiplierLabel = config.multiplier === 1 + ? t("MULTIPLIER_OFF", "Off") + : `${config.multiplier}x`; + return ( - - onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} - disabled={config.multiplier <= 1} - > - − - -
4 ? "red" : "white", - minWidth: "60px", - textAlign: "center" - }} - > - {config.multiplier === 1 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} -
- onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))} - disabled={config.multiplier >= 4} - > - + - -
+ void onConfigChange(MULTIPLIER, value)} + />
); } diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx new file mode 100644 index 0000000..bdf9985 --- /dev/null +++ b/src/components/GameConfigurationControls.tsx @@ -0,0 +1,17 @@ +import { ConfigurationData } from "../config/configSchema"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; + +interface Props { + config: ConfigurationData; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; +} + +export function GameConfigurationControls({ config, onConfigChange }: Props) { + return ( + <> + + + + ); +} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index a231477..fcdd0aa 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,29 +1,58 @@ -import { Dropdown, DropdownOption, PanelSectionRow, ButtonItem } from "@decky/ui"; +import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; import { GameTarget } from "../hooks/useGameConfiguration"; interface Props { targets: GameTarget[]; runningGame: GameTarget | null; - selectedAppId: string; onSelect: (appid: string) => void; - onReset: () => Promise; onResetAll: () => Promise; } -export function GameConfigurationSelector({ targets, runningGame, selectedAppId, onSelect, onReset, onResetAll }: Props) { - const options: DropdownOption[] = [ - { data: "", label: runningGame ? `Default (editing template) · ${runningGame.name}` : "Default" }, - ...targets.map((target) => ({ data: target.appid, label: `${target.nonSteam ? "Non-Steam · " : ""}${target.name} · ${target.appid}` })), - ]; - return <> - - onSelect(String(option.data))} /> - - - void onReset()} disabled={!selectedAppId}>Reset selected game - - - void onResetAll()} disabled={!targets.some((target) => target.configured)}>Reset all game profiles - - ; +const profileDescription = (target: GameTarget, running: boolean, active: boolean) => [ + running ? "Now playing" : "", + target.nonSteam ? "Non-Steam" : "Steam", + target.configured + ? active ? "Profile active" : "Profile saved · applies next launch" + : "Not configured · changes apply next launch", +].filter(Boolean).join(" · "); + +export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: Props) { + const games = [...targets].sort((a, b) => { + if (a.appid === runningGame?.appid) return -1; + if (b.appid === runningGame?.appid) return 1; + return a.name.localeCompare(b.name); + }); + + return ( + <> + {games.length === 0 && ( + + + + )} + {games.map((game) => ( + + onSelect(game.appid)} + highlightOnFocus + /> + + ))} + + void onResetAll()} + disabled={!targets.some((target) => target.configured)} + > + Remove all profiles + + + + ); } diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx new file mode 100644 index 0000000..956db4a --- /dev/null +++ b/src/components/NowPlayingTab.tsx @@ -0,0 +1,32 @@ +import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import { ConfigurationData } from "../config/configSchema"; +import { GameTarget } from "../hooks/useGameConfiguration"; +import { GameConfigurationControls } from "./GameConfigurationControls"; + +interface Props { + game: GameTarget; + config: ConfigurationData; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; + onRemove: () => Promise; +} + +export function NowPlayingTab({ game, config, onConfigChange, onRemove }: Props) { + return ( + + + + + + + + + void onRemove()}> + Remove profile + + + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 5089b6f..424a360 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,9 +3,10 @@ export { StatusDisplay } from "./StatusDisplay"; export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; -export { FgmodClipboardButton } from "./FgmodClipboardButton"; export { ConfigurationTab } from "./ConfigurationTab"; export { SetupTab } from "./SetupTab"; export { ConfigFileTab } from "./ConfigFileTab"; export { FlatpaksTab } from "./FlatpaksTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; +export { GameConfigurationControls } from "./GameConfigurationControls"; +export { NowPlayingTab } from "./NowPlayingTab"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index b177551..d279a8c 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,37 +1,40 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Router } from "@decky/ui"; -import { getGameConfigs, getInstalledGames, updateGameConfig, updateLsfgConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type InstalledGame } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; export interface GameTarget extends InstalledGame { configured: boolean; } export function useGameConfiguration() { - const [defaultConfig, setDefaultConfig] = useState(getDefaults()); const [games, setGames] = useState([]); + const [globalConfig, setGlobalConfig] = useState({ dll: "", no_fp16: false }); const [installedGames, setInstalledGames] = useState([]); + const [configsLoaded, setConfigsLoaded] = useState(false); const [selectedAppId, setSelectedAppId] = useState(""); const [runningGame, setRunningGame] = useState(null); - const autoSelected = useRef(false); + const previousRunningAppId = useRef(null); const load = useCallback(async () => { const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]); if (result.success) { - setDefaultConfig(result.default || getDefaults()); + setGlobalConfig(result.global_config || { dll: "", no_fp16: false }); setGames(result.games || []); } if (installed.success) setInstalledGames(installed.games || []); + setConfigsLoaded(true); }, []); useEffect(() => { load(); }, [load]); useEffect(() => { const poll = () => { + if (!configsLoaded) return; const app = Router.MainRunningApp as any; if (!app?.appid) return setRunningGame(null); const appid = String(app.appid); const installed = installedGames.find((game) => game.appid === appid); const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); - setRunningGame({ + setRunningGame((current) => current?.appid === appid ? current : { ...(installed || { appid, name, nonSteam: false }), name, configured: games.some((game) => game.appid === appid), @@ -40,13 +43,14 @@ export function useGameConfiguration() { poll(); const interval = window.setInterval(poll, 2000); return () => window.clearInterval(interval); - }, [games, installedGames]); + }, [configsLoaded, games, installedGames]); useEffect(() => { - if (!autoSelected.current && runningGame) { - autoSelected.current = true; - setSelectedAppId(runningGame.appid); + const appid = runningGame?.appid || null; + if (appid !== previousRunningAppId.current) { + previousRunningAppId.current = appid; + setSelectedAppId(appid || ""); } - }, [runningGame]); + }, [runningGame?.appid]); const targets = useMemo(() => { const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); @@ -54,25 +58,42 @@ export function useGameConfiguration() { if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); return configured; }, [games, installedGames, runningGame]); - const selected = selectedAppId ? games.find((game) => game.appid === selectedAppId)?.config : defaultConfig; - const config = selected || defaultConfig; + const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); + const config = games.find((game) => game.appid === selectedAppId)?.config || template; const save = useCallback(async (next: ConfigurationData) => { - if (!selectedAppId) { - const result = await updateLsfgConfig(next); - if (result.success) setDefaultConfig(next); - return; - } const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); }, [load, selectedAppId, targets]); + const enable = useCallback(async (appid: string) => { + const target = targets.find((item) => item.appid === appid); + if (!target?.name) return false; + const result = await updateGameConfig(appid, target.name, template); + if (result.success) await load(); + return result.success; + }, [load, targets, template]); + const resetSelected = useCallback(async () => { - if (selectedAppId) { await resetGameConfig(selectedAppId); setSelectedAppId(""); await load(); } + if (selectedAppId) { + const result = await resetGameConfig(selectedAppId); + if (result.success) { + setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); + setSelectedAppId(""); + await load(); + } + } }, [load, selectedAppId]); - const resetAll = useCallback(async () => { await resetAllGameConfigs(); setSelectedAppId(""); await load(); }, [load]); + const resetAll = useCallback(async () => { + const result = await resetAllGameConfigs(); + if (result.success) { + setRunningGame((current) => current ? { ...current, configured: false } : current); + setSelectedAppId(""); + await load(); + } + }, [load]); - return { config, defaultConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index ea8b3d0..0b71ee9 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -1,14 +1,9 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { checkLsfgVkInstalled, - getLsfgConfig, getLosslessScalingBranchStatus, - updateLsfgConfigFromObject, - type ConfigUpdateResult, type SteamBranchStatus } from "../api/lsfgApi"; -import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { showErrorToast, ToastMessages } from "../utils/toastUtils"; export function useInstallationStatus() { const [isInstalled, setIsInstalled] = useState(false); @@ -60,57 +55,3 @@ export function useInstallationStatus() { checkInstallation }; } - -export function useLsfgConfig() { - const [config, setConfig] = useState(() => getDefaults()); - - const loadLsfgConfig = useCallback(async () => { - try { - const result = await getLsfgConfig(); - if (result.success && result.config) { - setConfig(result.config); - } else { - console.log("lsfg config not available, using defaults:", result.error); - setConfig(getDefaults()); - } - } catch (error) { - console.error("Error loading lsfg config:", error); - setConfig(getDefaults()); - } - }, []); - - const updateConfig = useCallback(async (newConfig: ConfigurationData): Promise => { - try { - const result = await updateLsfgConfigFromObject(newConfig); - if (result.success) { - setConfig(newConfig); - } else { - showErrorToast( - ToastMessages.CONFIG_UPDATE_ERROR.title, - result.error || ToastMessages.CONFIG_UPDATE_ERROR.body - ); - } - return result; - } catch (error) { - showErrorToast(ToastMessages.CONFIG_UPDATE_ERROR.title, String(error)); - return { success: false, error: String(error) }; - } - }, []); - - const updateField = useCallback(async (fieldName: keyof ConfigurationData, value: boolean | number | string): Promise => { - const newConfig = { ...config, [fieldName]: value }; - return updateConfig(newConfig); - }, [config, updateConfig]); - - useEffect(() => { - loadLsfgConfig(); - }, []); - - return { - config, - setConfig, - loadLsfgConfig, - updateConfig, - updateField - }; -} diff --git a/src/i18n/languages.json b/src/i18n/languages.json index 0528341..3132083 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -88,10 +88,7 @@ "PROFILE_DELETE_DESC_SUFFIX": "この操作は取り消せません。", "PROFILE_DELETE_BTN": "削除", "PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません", - "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません", - "CLIPBOARD_COPIED": "クリップボードにコピーしました", - "CLIPBOARD_COPYING": "コピー中...", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません" }, "ko": { "CONTENT_FPS_MULTIPLIER": "FPS 배율", @@ -182,10 +179,7 @@ "PROFILE_DELETE_DESC_SUFFIX": "이 작업은 취소할 수 없습니다.", "PROFILE_DELETE_BTN": "삭제", "PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가", - "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다", - "CLIPBOARD_COPIED": "클립보드에 복사됨", - "CLIPBOARD_COPYING": "복사 중...", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다" }, "language_metadata": { "ko": { @@ -304,9 +298,6 @@ "PROFILE_DELETE_DESC_SUFFIX": "? This action cannot be undone.", "PROFILE_DELETE_BTN": "Delete", "PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile", - "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed", - "CLIPBOARD_COPIED": "Copied to clipboard", - "CLIPBOARD_COPYING": "Copying...", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed" } } diff --git a/src/styles.ts b/src/styles.ts index fe40a59..1bc089b 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,5 +1,5 @@ export const tabStyles = ` - .lsfg-vk-tabs > div > div:first-child::before { + .lsfg-vk-tabs > div > div:first-child { background: #0D141C; box-shadow: none; backdrop-filter: none; diff --git a/src/utils/clipboardUtils.ts b/src/utils/clipboardUtils.ts deleted file mode 100644 index 8a04caa..0000000 --- a/src/utils/clipboardUtils.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Clipboard utilities for reliable copy operations across different environments - */ - -/** - * Reliably copy text to clipboard using multiple fallback methods - * This is especially important in gaming mode where clipboard APIs may behave differently - */ -export async function copyToClipboard(text: string): Promise { - const tempInput = document.createElement('input'); - tempInput.value = text; - tempInput.style.position = 'absolute'; - tempInput.style.left = '-9999px'; - document.body.appendChild(tempInput); - - try { - tempInput.focus(); - tempInput.select(); - - let copySuccess = false; - try { - if (document.execCommand('copy')) { - copySuccess = true; - } - } catch (e) { - try { - await navigator.clipboard.writeText(text); - copySuccess = true; - } catch (clipboardError) { - console.error('Both copy methods failed:', e, clipboardError); - } - } - - return copySuccess; - } finally { - document.body.removeChild(tempInput); - } -} - -/** - * Verify that text was successfully copied to clipboard - */ -export async function verifyCopy(expectedText: string): Promise { - try { - const readBack = await navigator.clipboard.readText(); - return readBack === expectedText; - } catch (e) { - return true; - } -} - -/** - * Copy text with verification and return success status - */ -export async function copyWithVerification(text: string): Promise<{ success: boolean; verified: boolean }> { - const copySuccess = await copyToClipboard(text); - - if (!copySuccess) { - return { success: false, verified: false }; - } - - const verified = await verifyCopy(text); - return { success: true, verified }; -} diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index dce0a59..cbbbc55 100644 --- a/src/utils/toastUtils.ts +++ b/src/utils/toastUtils.ts @@ -53,14 +53,6 @@ export const ToastMessages = { CONFIG_UPDATE_ERROR: { title: "Update Failed", body: "Failed to update configuration" - }, - CLIPBOARD_SUCCESS: { - title: "Copied to Clipboard!", - body: "Launch option ready to paste" - }, - CLIPBOARD_ERROR: { - title: "Copy Failed", - body: "Unable to copy to clipboard" } } as const; @@ -99,17 +91,3 @@ export function showUninstallSuccessToast(): void { export function showUninstallErrorToast(error?: string): void { showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body); } - -/** - * Show clipboard success toast - */ -export function showClipboardSuccessToast(): void { - showSuccessToast(ToastMessages.CLIPBOARD_SUCCESS.title, ToastMessages.CLIPBOARD_SUCCESS.body); -} - -/** - * Show clipboard error toast - */ -export function showClipboardErrorToast(): void { - showErrorToast(ToastMessages.CLIPBOARD_ERROR.title, ToastMessages.CLIPBOARD_ERROR.body); -} -- cgit v1.2.3 From d1f8263bf9f22753ad53e1b0105172a3bd0faf5f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 23:12:28 -0400 Subject: feat: organize game profiles and focus enabled settings --- src/components/ConfigurationTab.tsx | 28 +++++-- src/components/FpsMultiplierControl.tsx | 45 ++++++++--- src/components/GameConfigurationControls.tsx | 16 +++- src/components/GameConfigurationSelector.tsx | 107 +++++++++++++++++++++------ src/components/NowPlayingTab.tsx | 2 +- 5 files changed, 152 insertions(+), 46 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index dab6f19..6c4a99a 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,5 +1,5 @@ import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; @@ -27,7 +27,13 @@ export function ConfigurationTab({ onResetAll, }: ConfigurationTabProps) { const [detailAppId, setDetailAppId] = useState(null); + const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); const promptedRunningAppId = useRef(null); + const closeDetails = useCallback(() => { + setFocusFpsMultiplier(false); + setDetailAppId(null); + }, []); + const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); useEffect(() => { if (!runningGame || runningGame.configured) { @@ -59,22 +65,28 @@ export function ConfigurationTab({ } const profileLabel = selectedTarget?.name || "Game profile"; - const running = selectedTarget?.appid === runningGame?.appid; const profileDescription = selectedTarget - ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? running && !runningGame?.configured ? "Profile saved · applies next launch" : "Profile active" : "Not configured · changes apply next launch"}` + ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "Configured" : "Not configured"}` : "Game is no longer available"; return ( - setDetailAppId(null)}> + - setDetailAppId(null)}>Back to games + Back to games - + {selectedTarget?.configured && ( + + )} diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index e197dc3..1fd0e78 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,4 +1,5 @@ -import { PanelSectionRow, SliderField } from "@decky/ui"; +import { Focusable, PanelSectionRow, SliderField } from "@decky/ui"; +import { useEffect, useRef } from "react"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; import t from "../i18n/i18n"; @@ -6,28 +7,48 @@ import t from "../i18n/i18n"; interface FpsMultiplierControlProps { config: ConfigurationData; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; + autoFocus?: boolean; + onAutoFocus?: () => void; } export function FpsMultiplierControl({ config, - onConfigChange + onConfigChange, + autoFocus = false, + onAutoFocus, }: FpsMultiplierControlProps) { + const focusableRef = useRef(null); + + useEffect(() => { + if (!autoFocus) return; + const frame = requestAnimationFrame(() => { + const target = focusableRef.current?.querySelector( + '[role="button"], [role="slider"]', + ); + target?.focus(); + onAutoFocus?.(); + }); + return () => cancelAnimationFrame(frame); + }, [autoFocus, onAutoFocus]); + const multiplierLabel = config.multiplier === 1 ? t("MULTIPLIER_OFF", "Off") : `${config.multiplier}x`; return ( - void onConfigChange(MULTIPLIER, value)} - /> + + void onConfigChange(MULTIPLIER, value)} + /> + ); } diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index bdf9985..34b82c5 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -5,12 +5,24 @@ import { FpsMultiplierControl } from "./FpsMultiplierControl"; interface Props { config: ConfigurationData; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; + autoFocusFpsMultiplier?: boolean; + onFpsMultiplierFocused?: () => void; } -export function GameConfigurationControls({ config, onConfigChange }: Props) { +export function GameConfigurationControls({ + config, + onConfigChange, + autoFocusFpsMultiplier, + onFpsMultiplierFocused, +}: Props) { return ( <> - + ); diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index fcdd0aa..982fcef 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,4 +1,6 @@ import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { GameTarget } from "../hooks/useGameConfiguration"; interface Props { @@ -8,42 +10,101 @@ interface Props { onResetAll: () => Promise; } -const profileDescription = (target: GameTarget, running: boolean, active: boolean) => [ - running ? "Now playing" : "", - target.nonSteam ? "Non-Steam" : "Steam", - target.configured - ? active ? "Profile active" : "Profile saved · applies next launch" - : "Not configured · changes apply next launch", -].filter(Boolean).join(" · "); +const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed"; -export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: Props) { - const games = [...targets].sort((a, b) => { - if (a.appid === runningGame?.appid) return -1; - if (b.appid === runningGame?.appid) return 1; - return a.name.localeCompare(b.name); +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) === "true"; + } catch { + return false; + } }); + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch { + // Persisting the view preference is optional. + } + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +function GameGroup({ + title, + games, + collapsed, + onToggle, + onSelect, +}: { + title: string; + games: GameTarget[]; + collapsed: boolean; + onToggle: () => void; + onSelect: (appid: string) => void; +}) { + if (games.length === 0) return null; + return ( <> - {games.length === 0 && ( - - - - )} - {games.map((game) => ( + + + {collapsed ? : } {title} ({games.length}) + + + {!collapsed && games.map((game) => ( onSelect(game.appid)} highlightOnFocus /> ))} + + ); +} + +export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: Props) { + const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => { + if (a.appid === runningGame?.appid) return -1; + if (b.appid === runningGame?.appid) return 1; + return a.name.localeCompare(b.name); + }); + const configuredGames = sortGames(targets.filter((game) => game.configured)); + const availableGames = sortGames(targets.filter((game) => !game.configured)); + const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + + return ( + <> + {targets.length === 0 && ( + + + + )} + +
-- 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(-) 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 50810a08e5a767ec6d774996ce13244121b9f3c3 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 00:39:16 -0400 Subject: fix: improve profile management focus and reset confirmation --- src/components/ConfigurationTab.tsx | 17 ++++++++++++++++- src/components/GameConfigurationSelector.tsx | 15 +++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 6c4a99a..0d8553c 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -28,13 +28,25 @@ export function ConfigurationTab({ }: ConfigurationTabProps) { const [detailAppId, setDetailAppId] = useState(null); const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); + const [focusBackToGames, setFocusBackToGames] = useState(false); + const backToGamesRef = useRef(null); const promptedRunningAppId = useRef(null); const closeDetails = useCallback(() => { setFocusFpsMultiplier(false); + setFocusBackToGames(false); setDetailAppId(null); }, []); const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); + useEffect(() => { + if (!focusBackToGames) return; + const frame = requestAnimationFrame(() => { + backToGamesRef.current?.querySelector('[role="button"]')?.focus(); + setFocusBackToGames(false); + }); + return () => cancelAnimationFrame(frame); + }, [focusBackToGames]); + useEffect(() => { if (!runningGame || runningGame.configured) { promptedRunningAppId.current = null; @@ -55,6 +67,7 @@ export function ConfigurationTab({ targets={targets} runningGame={runningGame} onSelect={(appid) => { + setFocusBackToGames(true); onSelect(appid); setDetailAppId(appid); }} @@ -76,7 +89,9 @@ export function ConfigurationTab({
- Back to games + + Back to games + {selectedTarget?.configured && ( diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 982fcef..91fdc39 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; +import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { GameTarget } from "../hooks/useGameConfiguration"; @@ -83,6 +83,17 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe const availableGames = sortGames(targets.filter((game) => !game.configured)); const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + const confirmResetAll = () => { + showModal( + void onResetAll()} + onCancel={() => {}} + />, + ); + }; return ( <> @@ -108,7 +119,7 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe void onResetAll()} + onClick={confirmResetAll} disabled={!targets.some((target) => target.configured)} > Remove all profiles -- cgit v1.2.3 From 62531dd08e5f12f1f0533ccf8fa6b84c81eb63d4 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 00:50:43 -0400 Subject: chore: hide config file tab and clarify flatpak runtimes --- src/components/Content.tsx | 5 +++-- src/components/FlatpaksTab.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 9281d39..1bc6e95 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -6,7 +6,7 @@ import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { useInstallationStatus } from "../hooks/useLsfgHooks"; -import { ConfigFileTab } from "./ConfigFileTab"; +// import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; import { FlatpaksTab } from "./FlatpaksTab"; import { NowPlayingTab } from "./NowPlayingTab"; @@ -136,7 +136,8 @@ export function Content() { ), }, { id: "Flatpak", title: tabIcons.flatpak, content: }, - { id: "ConfigFile", title: tabIcons.configFile, content: }, + // Keep the configuration-file view available for future use without exposing it in the UI. + // { id: "ConfigFile", title: tabIcons.configFile, content: }, { id: "Setup", title: tabIcons.setup, content: setupContent }, ] : [ diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx index b4d5e4d..27204f4 100644 --- a/src/components/FlatpaksTab.tsx +++ b/src/components/FlatpaksTab.tsx @@ -157,12 +157,12 @@ export function FlatpaksTab() { }; if (loading) { - return ; + return ; } return ( <> - + {error && } {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => ( Date: Mon, 7 Sep 2026 09:30:54 -0400 Subject: feat: restore multiplier buttons --- src/components/FpsMultiplierControl.tsx | 72 +++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 1fd0e78..2c50be1 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,4 +1,4 @@ -import { Focusable, PanelSectionRow, SliderField } from "@decky/ui"; +import { DialogButton, Focusable, PanelSectionRow } from "@decky/ui"; import { useEffect, useRef } from "react"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; @@ -31,23 +31,63 @@ export function FpsMultiplierControl({ return () => cancelAnimationFrame(frame); }, [autoFocus, onAutoFocus]); - const multiplierLabel = config.multiplier === 1 - ? t("MULTIPLIER_OFF", "Off") - : `${config.multiplier}x`; - return ( - - void onConfigChange(MULTIPLIER, value)} - /> + + void onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} + disabled={config.multiplier <= 1} + > + − + +
4 ? "red" : "white", + minWidth: "60px", + textAlign: "center", + }} + > + {config.multiplier < 2 ? t("MULTIPLIER_OFF", "OFF") : `${config.multiplier}X`} +
+ void onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))} + disabled={config.multiplier >= 4} + > + + +
); -- 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 +++++++----- src/components/ConfigurationTab.tsx | 59 +++++++++++++++++----------- src/components/Content.tsx | 8 ++-- src/components/GameConfigurationSelector.tsx | 50 ++++++++++++++++++++--- src/components/NowPlayingTab.tsx | 14 ++----- src/config/generatedConfigSchema.ts | 4 +- src/hooks/useGameConfiguration.ts | 53 +++++++++++++++++++++++-- 8 files changed, 158 insertions(+), 60 deletions(-) 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( diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 0d8553c..83f46c1 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -12,6 +12,7 @@ interface ConfigurationTabProps { onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; onEnable: (appid: string) => Promise; + onEnableAll: () => Promise; onReset: () => Promise; onResetAll: () => Promise; } @@ -23,29 +24,32 @@ export function ConfigurationTab({ onSelect, onConfigChange, onEnable, + onEnableAll, onReset, onResetAll, }: ConfigurationTabProps) { const [detailAppId, setDetailAppId] = useState(null); const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); - const [focusBackToGames, setFocusBackToGames] = useState(false); + const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "back" | null>(null); const backToGamesRef = useRef(null); + const enableRef = useRef(null); const promptedRunningAppId = useRef(null); const closeDetails = useCallback(() => { setFocusFpsMultiplier(false); - setFocusBackToGames(false); + setFocusDetailAction(null); setDetailAppId(null); }, []); const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); useEffect(() => { - if (!focusBackToGames) return; + if (!focusDetailAction) return; const frame = requestAnimationFrame(() => { - backToGamesRef.current?.querySelector('[role="button"]')?.focus(); - setFocusBackToGames(false); + const ref = focusDetailAction === "enable" ? enableRef : backToGamesRef; + ref.current?.querySelector('[role="button"]')?.focus(); + setFocusDetailAction(null); }); return () => cancelAnimationFrame(frame); - }, [focusBackToGames]); + }, [focusDetailAction]); useEffect(() => { if (!runningGame || runningGame.configured) { @@ -54,6 +58,7 @@ export function ConfigurationTab({ } if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) { promptedRunningAppId.current = runningGame.appid; + setFocusDetailAction(runningGame.configured ? "back" : "enable"); setDetailAppId(runningGame.appid); } }, [detailAppId, runningGame?.appid, runningGame?.configured]); @@ -67,10 +72,11 @@ export function ConfigurationTab({ targets={targets} runningGame={runningGame} onSelect={(appid) => { - setFocusBackToGames(true); + setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "back" : "enable"); onSelect(appid); setDetailAppId(appid); }} + onEnableAll={onEnableAll} onResetAll={onResetAll} />
@@ -79,8 +85,17 @@ export function ConfigurationTab({ const profileLabel = selectedTarget?.name || "Game profile"; const profileDescription = selectedTarget - ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "Configured" : "Not configured"}` + ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` : "Game is no longer available"; + const handleProfileAction = async () => { + if (selectedTarget?.configured) { + promptedRunningAppId.current = detailAppId; + await onReset(); + closeDetails(); + } else if (detailAppId && await onEnable(detailAppId)) { + setFocusFpsMultiplier(true); + } + }; return ( @@ -88,6 +103,13 @@ export function ConfigurationTab({ + {!selectedTarget?.configured && selectedTarget && ( + + + Enable for next launch + + + )} Back to games @@ -102,22 +124,11 @@ export function ConfigurationTab({ onFpsMultiplierFocused={clearFpsFocusRequest} /> )} - - { - if (selectedTarget?.configured) { - promptedRunningAppId.current = detailAppId; - await onReset(); - closeDetails(); - } else if (detailAppId) { - if (await onEnable(detailAppId)) setFocusFpsMultiplier(true); - } - }} - > - {selectedTarget?.configured ? "Remove profile" : "Enable for next launch"} - - + {selectedTarget?.configured && ( + + Remove profile + + )} ); } diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 1bc6e95..f1c8c11 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -6,7 +6,7 @@ import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { useInstallationStatus } from "../hooks/useLsfgHooks"; -// import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; import { FlatpaksTab } from "./FlatpaksTab"; import { NowPlayingTab } from "./NowPlayingTab"; @@ -38,6 +38,7 @@ export function Content() { setSelectedAppId, save, enable, + enableAll, resetSelected, resetAll, reload, @@ -115,7 +116,6 @@ export function Content() { game={runningGame} config={config} onConfigChange={handleConfigChange} - onRemove={resetSelected} /> ), }] : []), @@ -130,14 +130,14 @@ export function Content() { onSelect={setSelectedAppId} onConfigChange={handleConfigChange} onEnable={enable} + onEnableAll={enableAll} onReset={resetSelected} onResetAll={resetAll} /> ), }, { id: "Flatpak", title: tabIcons.flatpak, content: }, - // Keep the configuration-file view available for future use without exposing it in the UI. - // { id: "ConfigFile", title: tabIcons.configFile, content: }, + { id: "ConfigFile", title: tabIcons.configFile, content: }, // comment out for prod { id: "Setup", title: tabIcons.setup, content: setupContent }, ] : [ diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 91fdc39..a046594 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -7,10 +7,11 @@ interface Props { targets: GameTarget[]; runningGame: GameTarget | null; onSelect: (appid: string) => void; + onEnableAll: () => Promise; onResetAll: () => Promise; } -const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed"; +const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v2"; const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed"; function usePersistentCollapsed(key: string) { @@ -73,7 +74,7 @@ function GameGroup({ ); } -export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: Props) { +export function GameConfigurationSelector({ targets, runningGame, onSelect, onEnableAll, onResetAll }: Props) { const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => { if (a.appid === runningGame?.appid) return -1; if (b.appid === runningGame?.appid) return 1; @@ -81,8 +82,14 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe }); const configuredGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); + const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam); + const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam); + const availableSteamGames = availableGames.filter((game) => !game.nonSteam); + const availableNonSteamGames = availableGames.filter((game) => game.nonSteam); const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY); + const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`); const confirmResetAll = () => { showModal( , ); }; + const confirmEnableAll = () => { + showModal( + void onEnableAll()} + onCancel={() => {}} + />, + ); + }; return ( <> @@ -102,20 +121,41 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe )} + {availableGames.length > 0 && ( + + + Enable all available games + + + )} + + Promise; - onRemove: () => Promise; } -export function NowPlayingTab({ game, config, onConfigChange, onRemove }: Props) { +export function NowPlayingTab({ game, config, onConfigChange }: Props) { return ( - + - - void onRemove()}> - Remove profile - - ); } diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 71459eb..3668b5b 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -21,11 +21,11 @@ export const CONFIG_SCHEMA: Record = { active_in: { name: "active_in", fieldType: ConfigFieldType.ARRAY, default: [], description: "Steam AppID or executable identifiers" }, pacing_mode: { name: "pacing_mode", fieldType: ConfigFieldType.STRING, default: "vsync", description: "Frame pacing mode" }, multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 2, description: "Frame generation multiplier" }, - flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 1, description: "Motion estimation resolution scale" }, + flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 0.8, description: "Motion estimation resolution scale" }, performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Use the lighter frame generation model" }, override_present_mode: { name: "override_present_mode", fieldType: ConfigFieldType.BOOLEAN, default: true, description: "Override present mode" }, preserve_swapchain_image_count: { name: "preserve_swapchain_image_count", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Preserve the swapchain image count" }, }; export function getFieldNames(): string[] { return Object.keys(CONFIG_SCHEMA); } -export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 1, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; } +export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 0.8, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; } export function getFieldTypes(): Record { return Object.fromEntries(Object.entries(CONFIG_SCHEMA).map(([key, value]) => [key, value.fieldType])); } diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index d279a8c..597edca 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,10 +1,36 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; +import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } +async function getSteamShortcuts(): Promise { + const apps = (globalThis as any).SteamClient?.Apps; + if (typeof apps?.GetAllShortcuts !== "function") return []; + + try { + const shortcuts = await apps.GetAllShortcuts(); + if (!Array.isArray(shortcuts)) return []; + return shortcuts.flatMap((shortcut: any) => { + const appid = Number(shortcut?.appid); + const name = shortcut?.data?.strAppName; + if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return []; + return [{ appid: String(appid >>> 0), name, nonSteam: true }]; + }); + } catch { + return []; + } +} + +function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) { + const games = new Map(backendGames.map((game) => [game.appid, game])); + for (const game of shortcutGames) games.set(game.appid, game); + return Array.from(games.values()); +} + export function useGameConfiguration() { const [games, setGames] = useState([]); const [globalConfig, setGlobalConfig] = useState({ dll: "", no_fp16: false }); @@ -13,18 +39,25 @@ export function useGameConfiguration() { const [selectedAppId, setSelectedAppId] = useState(""); const [runningGame, setRunningGame] = useState(null); const previousRunningAppId = useRef(null); + const previousQuickAccessVisible = useRef(null); + const quickAccessVisible = useQuickAccessVisible(); const load = useCallback(async () => { - const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]); + const [result, installed, shortcuts] = await Promise.all([getGameConfigs(), getInstalledGames(), getSteamShortcuts()]); if (result.success) { setGlobalConfig(result.global_config || { dll: "", no_fp16: false }); setGames(result.games || []); } - if (installed.success) setInstalledGames(installed.games || []); + setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts)); setConfigsLoaded(true); }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + const initialLoad = previousQuickAccessVisible.current === null; + const becameVisible = quickAccessVisible && previousQuickAccessVisible.current === false; + previousQuickAccessVisible.current = quickAccessVisible; + if (initialLoad || becameVisible) void load(); + }, [load, quickAccessVisible]); useEffect(() => { const poll = () => { if (!configsLoaded) return; @@ -75,6 +108,18 @@ export function useGameConfiguration() { if (result.success) await load(); return result.success; }, [load, targets, template]); + const enableAll = useCallback(async (): Promise => { + const available = targets.filter((target) => !target.configured && target.name); + if (available.length === 0) return; + for (const target of available) { + const result = await updateGameConfig(target.appid, target.name, template); + if (!result.success) { + showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); + return; + } + } + await load(); + }, [load, targets, template]); const resetSelected = useCallback(async () => { if (selectedAppId) { @@ -95,5 +140,5 @@ export function useGameConfiguration() { } }, [load]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; } -- 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 +- src/components/ConfigurationTab.tsx | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) 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} diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 83f46c1..2175eb9 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,5 +1,6 @@ import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; @@ -29,6 +30,7 @@ export function ConfigurationTab({ onResetAll, }: ConfigurationTabProps) { const [detailAppId, setDetailAppId] = useState(null); + const [detailsExpanded, setDetailsExpanded] = useState(false); const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "back" | null>(null); const backToGamesRef = useRef(null); @@ -41,6 +43,8 @@ export function ConfigurationTab({ }, []); const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); + useEffect(() => setDetailsExpanded(false), [detailAppId]); + useEffect(() => { if (!focusDetailAction) return; const frame = requestAnimationFrame(() => { @@ -101,7 +105,7 @@ export function ConfigurationTab({ - + {!selectedTarget?.configured && selectedTarget && ( @@ -129,6 +133,20 @@ export function ConfigurationTab({ Remove profile )} + + setDetailsExpanded((expanded) => !expanded)} + > + {detailsExpanded ? : } Details + + + {detailsExpanded && ( + + + + )} ); } -- 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 ------- src/components/ConfigurationTab.tsx | 11 ++++++-- src/i18n/i18n.ts | 25 ++++++++++-------- tests/test_plugin_migration.py | 50 +++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 7 files changed, 74 insertions(+), 76 deletions(-) create mode 100644 tests/test_plugin_migration.py 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") diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 2175eb9..b3c0281 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,5 +1,6 @@ -import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; @@ -116,7 +117,13 @@ export function ConfigurationTab({ )} - Back to games + + +
diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index dc8de8b..31a55cb 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -2,8 +2,16 @@ // to generate for localhost/dev, run `build_i18n_json.sh` script import * as languages from "./languages.json"; -const steamLanguageMap: Record = - languages.steam_language_map as Record; +type LanguageStrings = Record; +type Language = { name: string; strings?: LanguageStrings }; +type LanguageData = { + language_metadata: Record; + steam_language_map: Record; + [language: string]: LanguageStrings | Record | Record; +}; + +const languageData = languages as unknown as LanguageData; +const steamLanguageMap = languageData.steam_language_map; const normalizeLanguage = (language: string): string => { const normalized = language.trim().toLowerCase(); @@ -11,13 +19,13 @@ const normalizeLanguage = (language: string): string => { }; function getLangs() { - const langs = languages.language_metadata; + const langs = languageData.language_metadata; - Object.keys(languages).map((lang) => { + Object.keys(languageData).forEach((lang) => { if (lang === "language_metadata" || lang == "steam_language_map") { return; } - const strs = languages[lang]; + const strs = languageData[lang] as LanguageStrings; if (lang && strs && langs[lang]?.name) { langs[lang].strings = strs; } @@ -27,12 +35,7 @@ function getLangs() { } export const LANGS: { - [key: string]: { - name: string; - strings: { - [key: string]: string; - }; - }; + [key: string]: Language; } = getLangs(); let cachedLang: string | undefined; diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py new file mode 100644 index 0000000..a392f85 --- /dev/null +++ b/tests/test_plugin_migration.py @@ -0,0 +1,50 @@ +import asyncio +import sys +import types +import unittest +from unittest.mock import Mock + + +class PluginMigrationTests(unittest.TestCase): + def test_migration_only_runs_decky_path_migrations(self): + decky = types.SimpleNamespace( + DECKY_HOME="/decky", + DECKY_USER_HOME="/home/deck", + migrate_logs=Mock(), + migrate_settings=Mock(), + migrate_runtime=Mock(), + logger=Mock(), + ) + previous_decky = sys.modules.get("decky") + previous_tomllib = sys.modules.get("tomllib") + sys.modules["decky"] = decky + sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock()) + try: + sys.path.insert(0, "py_modules") + from lsfg_vk.plugin import Plugin + + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + + asyncio.run(plugin._migration()) + + decky.migrate_logs.assert_called_once() + decky.migrate_settings.assert_called_once() + decky.migrate_runtime.assert_called_once() + plugin.installation_service.install.assert_not_called() + plugin.flatpak_service.migrate_v2.assert_not_called() + finally: + sys.path.remove("py_modules") + if previous_decky is None: + sys.modules.pop("decky", None) + else: + sys.modules["decky"] = previous_decky + if previous_tomllib is None: + sys.modules.pop("tomllib", None) + else: + sys.modules["tomllib"] = previous_tomllib + + +if __name__ == "__main__": + unittest.main() diff --git a/tsconfig.json b/tsconfig.json index 626c60f..f4b795f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "jsxFragmentFactory": "window.SP_REACT.Fragment", "declaration": false, "moduleResolution": "node", + "resolveJsonModule": true, "noUnusedLocals": true, "noUnusedParameters": true, "esModuleInterop": true, -- cgit v1.2.3 From 5b84a7ab2782ce672b51dc9d608c63c9ac8ff3ab Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 21:04:26 -0400 Subject: feat: show Flatpak runtime readiness --- src/components/FlatpaksTab.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx index 27204f4..8aab940 100644 --- a/src/components/FlatpaksTab.tsx +++ b/src/components/FlatpaksTab.tsx @@ -50,18 +50,21 @@ function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) { interface AppRowProps { app: FlatpakApp; + runtimeReady: boolean; busy: boolean; onToggle: () => void; } -function AppRow({ app, busy, onToggle }: AppRowProps) { +function AppRow({ app, runtimeReady, busy, onToggle }: AppRowProps) { const configured = app.has_filesystem_override && app.has_env_override; const partial = app.has_filesystem_override || app.has_env_override; const status = configured - ? t("FLATPAK_STATUS_CONFIGURED", "Configured") + ? runtimeReady + ? t("FLATPAK_STATUS_READY", "Ready") + : t("FLATPAK_STATUS_RUNTIME_MISSING", "Runtime missing") : partial ? t("FLATPAK_STATUS_PARTIAL", "Partial") - : t("FLATPAK_STATUS_NO_OVERRIDES", "No overrides"); + : t("FLATPAK_STATUS_NOT_ENABLED", "Not enabled"); return ( @@ -82,6 +85,8 @@ export function FlatpaksTab() { const [loading, setLoading] = useState(true); const [operation, setOperation] = useState(null); const [error, setError] = useState(null); + const runtimeReady = extensionStatus?.success === true + && runtimeVersions.some(({ key }) => extensionStatus[key]); const load = async () => { setLoading(true); @@ -180,6 +185,7 @@ export function FlatpaksTab() { void toggleApp(app)} /> -- cgit v1.2.3 From 85bb5c9da5bb5ea64ddca1aeca1dc6ba656bb86c Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 23:40:11 -0400 Subject: fix: focus configured game controls --- src/components/ConfigurationTab.tsx | 58 +++++++++++++++---------------------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index b3c0281..b94522a 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,7 +1,6 @@ import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; @@ -31,10 +30,8 @@ export function ConfigurationTab({ onResetAll, }: ConfigurationTabProps) { const [detailAppId, setDetailAppId] = useState(null); - const [detailsExpanded, setDetailsExpanded] = useState(false); const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); - const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "back" | null>(null); - const backToGamesRef = useRef(null); + const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null); const enableRef = useRef(null); const promptedRunningAppId = useRef(null); const closeDetails = useCallback(() => { @@ -44,13 +41,15 @@ export function ConfigurationTab({ }, []); const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); - useEffect(() => setDetailsExpanded(false), [detailAppId]); - useEffect(() => { if (!focusDetailAction) return; + if (focusDetailAction === "fps") { + setFocusFpsMultiplier(true); + setFocusDetailAction(null); + return; + } const frame = requestAnimationFrame(() => { - const ref = focusDetailAction === "enable" ? enableRef : backToGamesRef; - ref.current?.querySelector('[role="button"]')?.focus(); + enableRef.current?.querySelector('[role="button"]')?.focus(); setFocusDetailAction(null); }); return () => cancelAnimationFrame(frame); @@ -63,7 +62,7 @@ export function ConfigurationTab({ } if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) { promptedRunningAppId.current = runningGame.appid; - setFocusDetailAction(runningGame.configured ? "back" : "enable"); + setFocusDetailAction("enable"); setDetailAppId(runningGame.appid); } }, [detailAppId, runningGame?.appid, runningGame?.configured]); @@ -77,7 +76,7 @@ export function ConfigurationTab({ targets={targets} runningGame={runningGame} onSelect={(appid) => { - setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "back" : "enable"); + setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); onSelect(appid); setDetailAppId(appid); }} @@ -104,7 +103,20 @@ export function ConfigurationTab({ return ( - + + + + + + + + + + @@ -115,17 +127,6 @@ export function ConfigurationTab({ )} - - - - - - -
{selectedTarget?.configured && ( )} - setDetailsExpanded((expanded) => !expanded)} - > - {detailsExpanded ? : } Details - + - {detailsExpanded && ( - - - - )} ); } -- cgit v1.2.3 From c6c24524b46e237bee796cb57b9a27c437c458ae Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 09:07:51 -0400 Subject: fix: make game group collapse controls compact --- src/components/GameConfigurationSelector.tsx | 32 ++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index a046594..15f2465 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -52,13 +52,25 @@ function GameGroup({ return ( <> - + + +
- {collapsed ? : } {title} ({games.length}) - + + {collapsed ? ( + + ) : ( + + )} + +
{!collapsed && games.map((game) => ( @@ -116,6 +128,14 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn return ( <> + {targets.length === 0 && ( -- cgit v1.2.3 From b3749ecc4b094a46d2ab9f638ee810f70840f67a Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 11:31:46 -0400 Subject: update layout and config and hash --- package.json | 2 +- src/components/ConfigurationTab.tsx | 33 +++++++++++++++++-------- src/components/GameConfigurationSelector.tsx | 23 +++++++++++------ src/components/NowPlayingTab.tsx | 1 - src/components/ProfileDetails.tsx | 37 ++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 src/components/ProfileDetails.tsx diff --git a/package.json b/package.json index 6cd5ebc..8deb8a7 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ { "name": "lsfg-vk-2.0.0.tar.xz", "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", - "sha256hash": "d8378b45d378150ea9aba803a0ba855d8ce91ad9b3366ee0eb2036b06b08380c" + "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" }, { "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index b94522a..6ba8f5d 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,10 +1,11 @@ -import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import { ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; import { GameConfigurationSelector } from "./GameConfigurationSelector"; +import { ProfileDetails } from "./ProfileDetails"; interface ConfigurationTabProps { config: ConfigurationData; @@ -105,21 +106,35 @@ export function ConfigurationTab({ - +
+ - + +
+ {profileLabel} +
+
- - - {!selectedTarget?.configured && selectedTarget && ( @@ -141,9 +156,7 @@ export function ConfigurationTab({ Remove profile )} - - - +
); } diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 15f2465..5f23b91 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -11,15 +11,15 @@ interface Props { onResetAll: () => Promise; } -const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v2"; -const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed"; +const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v2"; function usePersistentCollapsed(key: string) { const [collapsed, setCollapsed] = useState(() => { try { - return localStorage.getItem(key) === "true"; + return localStorage.getItem(key) !== "false"; } catch { - return false; + return true; } }); @@ -65,9 +65,9 @@ function GameGroup({ onClick={onToggle} > {collapsed ? ( - + ) : ( - + )}
@@ -132,7 +132,16 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn {` .LSFG_GameGroupCollapseButton_Container > div > div > div > button, .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { - height: 10px !important; + height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_GameGroupCollapseButton_Container svg { + display: block; + margin: 0; } `} diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 8d2d779..2ec12ca 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -16,7 +16,6 @@ export function NowPlayingTab({ game, config, onConfigChange }: Props) { diff --git a/src/components/ProfileDetails.tsx b/src/components/ProfileDetails.tsx new file mode 100644 index 0000000..056abbb --- /dev/null +++ b/src/components/ProfileDetails.tsx @@ -0,0 +1,37 @@ +import { ButtonItem, Field, Focusable, PanelSectionRow } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +interface ProfileDetailsProps { + description: string; +} + +export function ProfileDetails({ description }: ProfileDetailsProps) { + const [expanded, setExpanded] = useState(false); + const detailsRef = useRef(null); + + useEffect(() => { + if (!expanded) return; + const frame = requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ block: "nearest" })); + return () => cancelAnimationFrame(frame); + }, [expanded]); + + return ( + + + setExpanded((value) => !value)} + > + {expanded ? : } Details + + + {expanded && ( + + + + )} + + ); +} -- cgit v1.2.3 From 8352ee74e8a437c1f4a4dadb75d63049f45b164b Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 16:25:11 -0400 Subject: add back workarounds sections, scope out of now playing --- defaults/i18n/ja.json | 16 +- defaults/i18n/ko.json | 16 +- defaults/i18n/template.json | 16 +- package.json | 2 +- src/components/ConfigurationTab.tsx | 2 + src/components/GameConfigurationControls.tsx | 9 + src/components/NowPlayingTab.tsx | 2 +- src/components/WorkaroundsSection.tsx | 190 +++++++++++ src/components/index.ts | 1 + src/hooks/useGameConfiguration.ts | 27 +- src/hooks/usePerAppWorkarounds.ts | 173 ++++++++++ src/i18n/languages.json | 48 +-- src/types.d.ts | 24 ++ src/utils/steamLaunchOptionParser.ts | 489 +++++++++++++++++++++++++++ src/utils/steamLaunchOptions.ts | 211 ++++++++++++ tests/steamLaunchOptions.test.ts | 288 ++++++++++++++++ 16 files changed, 1448 insertions(+), 66 deletions(-) create mode 100644 src/components/WorkaroundsSection.tsx create mode 100644 src/hooks/usePerAppWorkarounds.ts create mode 100644 src/utils/steamLaunchOptionParser.ts create mode 100644 src/utils/steamLaunchOptions.ts create mode 100644 tests/steamLaunchOptions.test.ts diff --git a/defaults/i18n/ja.json b/defaults/i18n/ja.json index 92a8701..fb4a3ad 100644 --- a/defaults/i18n/ja.json +++ b/defaults/i18n/ja.json @@ -9,7 +9,7 @@ "CONFIG_FLOW_SCALE_DESC": "内部モーション推定解像度を下げて、パフォーマンスをわずかに向上させます", "CONFIG_BASE_FPS_CAP": "基本FPS上限", "CONFIG_BASE_FPS_CAP_OFF": "オフ", - "CONFIG_BASE_FPS_CAP_DESC": "フレーム倍率適用前のDirectXゲームの基本フレームレート上限。(ゲームの再起動が必要)", + "CONFIG_BASE_FPS_CAP_DESC": "フレーム生成前のDXVKゲームの基本上限。0で無効。ゲームの再起動が必要です。", "CONFIG_PRESENT_MODE": "プレゼンテーションモード", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -20,18 +20,14 @@ "CONFIG_HDR_MODE_DESC": "HDRモードを有効化します(HDRをサポートするゲームのみ)", "CONFIG_ENABLE_WSI": "WSIを有効化", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。", - "CONFIG_ENABLE_WOW64": "32ビットゲーム用WOW64を有効化", - "CONFIG_ENABLE_WOW64_DESC": "32ビットゲームにPROTON_USE_WOW64=1を有効化します(ProtonGEと併用してクラッシュを修正)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deckモードを無効化します(一部ゲームの隠し設定を解放)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHudワークアラウンド", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "透明なMangoHudオーバーレイを有効化します。ゲームモードでの2X倍率問題を修正することがあります", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。", "CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化", "CONFIG_DISABLE_VKBASALT_DESC": "LSFGと競合する可能性のあるvkBasaltレイヤーを無効化します(Reshade、一部のDeckyプラグイン)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasaltを強制有効化", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "ゲームモードのフレームペーシング問題を修正するためにvkBasaltを強制有効化します", - "CONFIG_ENABLE_ZINK": "OpenGLゲーム用Zinkを有効化", - "CONFIG_ENABLE_ZINK_DESC": "OpenGLゲームにVulkanベースのOpenGL実装を使用します(一部のゲームでクラッシュやフリーズが発生する場合があります)", + "CONFIG_ENABLE_ZINK": "OpenGLゲームでZinkを強制", + "CONFIG_ENABLE_ZINK_DESC": "MesaのZink OpenGL-to-Vulkanドライバーを使用します。一部のゲームでクラッシュやフリーズが発生する可能性があります。ゲームの再起動が必要です。", "INSTALL_INSTALLING": "インストール中...", "INSTALL_UNINSTALLING": "アンインストール中...", "INSTALL_UNINSTALL_BTN": "LSFG-VKをアンインストール", diff --git a/defaults/i18n/ko.json b/defaults/i18n/ko.json index 947640d..29783c4 100644 --- a/defaults/i18n/ko.json +++ b/defaults/i18n/ko.json @@ -9,7 +9,7 @@ "CONFIG_FLOW_SCALE_DESC": "내부 모션 추정 해상도를 낮춰 성능을 약간 향상시킵니다", "CONFIG_BASE_FPS_CAP": "기본 FPS 상한", "CONFIG_BASE_FPS_CAP_OFF": "끄기", - "CONFIG_BASE_FPS_CAP_DESC": "프레임 배율 적용 전 DirectX 게임의 기본 프레임 상한. (게임 재시작 필요)", + "CONFIG_BASE_FPS_CAP_DESC": "프레임 생성 전 DXVK 게임의 기본 제한입니다. 0은 비활성화합니다. 게임 재시작 필요.", "CONFIG_PRESENT_MODE": "프레젠테이션 모드", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -20,18 +20,14 @@ "CONFIG_HDR_MODE_DESC": "HDR 모드를 활성화합니다 (HDR을 지원하는 게임에만 해당)", "CONFIG_ENABLE_WSI": "WSI 활성화", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.", - "CONFIG_ENABLE_WOW64": "32비트 게임용 WOW64 활성화", - "CONFIG_ENABLE_WOW64_DESC": "32비트 게임에 PROTON_USE_WOW64=1을 활성화합니다 (크래시 수정을 위해 ProtonGE와 함께 사용)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deck 모드를 비활성화합니다 (일부 게임의 숨겨진 설정 잠금 해제)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud 우회", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "투명한 MangoHud 오버레이를 활성화합니다. 게임 모드에서 2X 배율 문제를 수정하는 데 도움이 될 수 있습니다", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화", "CONFIG_DISABLE_VKBASALT_DESC": "LSFG와 충돌할 수 있는 vkBasalt 레이어를 비활성화합니다 (Reshade, 일부 Decky 플러그인)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasalt 강제 활성화", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "게임 모드에서 프레임 페이싱 문제 수정을 위해 vkBasalt를 강제 활성화합니다", - "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 활성화", - "CONFIG_ENABLE_ZINK_DESC": "OpenGL 게임에 Vulkan 기반 OpenGL 구현을 사용합니다 (일부 게임에서 크래시나 멈춤이 발생할 수 있습니다)", + "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 강제", + "CONFIG_ENABLE_ZINK_DESC": "Mesa의 Zink OpenGL-to-Vulkan 드라이버를 사용합니다. 일부 게임에서 충돌 또는 멈춤이 발생할 수 있으며 게임 재시작이 필요합니다.", "INSTALL_INSTALLING": "설치 중...", "INSTALL_UNINSTALLING": "제거 중...", "INSTALL_UNINSTALL_BTN": "LSFG-VK 제거", diff --git a/defaults/i18n/template.json b/defaults/i18n/template.json index fce97b4..2a5b06e 100644 --- a/defaults/i18n/template.json +++ b/defaults/i18n/template.json @@ -9,7 +9,7 @@ "CONFIG_FLOW_SCALE_DESC": "Lowers internal motion estimation resolution, improving performance slightly", "CONFIG_BASE_FPS_CAP": "Base FPS Cap", "CONFIG_BASE_FPS_CAP_OFF": "Off", - "CONFIG_BASE_FPS_CAP_DESC": "Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)", + "CONFIG_BASE_FPS_CAP_DESC": "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.", "CONFIG_PRESENT_MODE": "Present Mode", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -20,18 +20,14 @@ "CONFIG_HDR_MODE_DESC": "Enables HDR mode (only for games that support HDR)", "CONFIG_ENABLE_WSI": "Enable WSI", "CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.", - "CONFIG_ENABLE_WOW64": "Enable WOW64 for 32-bit games", - "CONFIG_ENABLE_WOW64_DESC": "Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", "CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables Steam Deck mode (Unlocks hidden settings in some games)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud Workaround", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", "CONFIG_DISABLE_VKBASALT": "Disable vkBasalt", "CONFIG_DISABLE_VKBASALT_DESC": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", - "CONFIG_FORCE_ENABLE_VKBASALT": "Force Enable vkBasalt", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "CONFIG_ENABLE_ZINK": "Enable Zink for OpenGL Games", - "CONFIG_ENABLE_ZINK_DESC": "Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)", + "CONFIG_ENABLE_ZINK": "Force Zink for OpenGL Games", + "CONFIG_ENABLE_ZINK_DESC": "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", "INSTALL_INSTALLING": "Installing...", "INSTALL_UNINSTALLING": "Uninstalling...", "INSTALL_UNINSTALL_BTN": "Uninstall LSFG-VK", diff --git a/package.json b/package.json index 8deb8a7..b5e716c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'" }, "repository": { "type": "git", diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 6ba8f5d..2bb0f26 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -149,6 +149,8 @@ export function ConfigurationTab({ onConfigChange={onConfigChange} autoFocusFpsMultiplier={focusFpsMultiplier} onFpsMultiplierFocused={clearFpsFocusRequest} + showWorkarounds + workaroundTarget={selectedTarget || undefined} /> )} {selectedTarget?.configured && ( diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 34b82c5..58ccd02 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -1,12 +1,16 @@ import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget } from "../hooks/useGameConfiguration"; import { ConfigurationSection } from "./ConfigurationSection"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { WorkaroundsSection } from "./WorkaroundsSection"; interface Props { config: ConfigurationData; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; + showWorkarounds?: boolean; + workaroundTarget?: Pick; } export function GameConfigurationControls({ @@ -14,6 +18,8 @@ export function GameConfigurationControls({ onConfigChange, autoFocusFpsMultiplier, onFpsMultiplierFocused, + showWorkarounds = false, + workaroundTarget, }: Props) { return ( <> @@ -24,6 +30,9 @@ export function GameConfigurationControls({ onAutoFocus={onFpsMultiplierFocused} /> + {showWorkarounds && workaroundTarget && ( + + )} ); } diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 2ec12ca..188c56a 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -19,7 +19,7 @@ export function NowPlayingTab({ game, config, onConfigChange }: Props) { />
- + ); } diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx new file mode 100644 index 0000000..e990784 --- /dev/null +++ b/src/components/WorkaroundsSection.tsx @@ -0,0 +1,190 @@ +import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; +import t from "../i18n/i18n"; +import type { WorkaroundField } from "../utils/steamLaunchOptions"; + +interface WorkaroundsSectionProps { + appId: string; + nonSteam: boolean; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; +type ToggleWorkaroundField = Exclude; + +const TOGGLE_ROWS: readonly { + field: ToggleWorkaroundField; + labelKey: string; + label: string; + descriptionKey: string; + description: string; +}[] = [ + { + field: "disableGamescopeWsi", + labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI", + label: "Disable Gamescope WSI", + descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC", + description: "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", + }, + { + field: "disableSteamdeckMode", + labelKey: "CONFIG_DISABLE_STEAMDECK_MODE", + label: "Disable Steam Deck Mode", + descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC", + description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", + }, + { + field: "disableVkbasalt", + labelKey: "CONFIG_DISABLE_VKBASALT", + label: "Disable vkBasalt", + descriptionKey: "CONFIG_DISABLE_VKBASALT_DESC", + description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", + }, + { + field: "enableZink", + labelKey: "CONFIG_ENABLE_ZINK", + label: "Force Zink for OpenGL Games", + descriptionKey: "CONFIG_ENABLE_ZINK_DESC", + description: "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", + }, +]; + +function usePersistentCollapsed() { + const [collapsed, setCollapsed] = useState(() => { + try { + const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); + return saved !== null ? JSON.parse(saved) === true : true; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); + } catch { + // Persisting the view preference is optional. + } + }, [collapsed]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); + const state = snapshot?.parsed.state; + const issues = snapshot?.parsed.issues || []; + const controlsDisabled = status !== "ready" || state === undefined; + const [fpsValue, setFpsValue] = useState(null); + const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0; + const fpsLabel = effectiveFpsValue > 0 + ? `${effectiveFpsValue} FPS` + : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + useEffect(() => { + setFpsValue(state?.dxvkFrameRate ?? null); + }, [state?.dxvkFrameRate, status]); + + return ( + <> + + +
+ {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} +
+
+ +
+ + {collapsed ? : } + +
+
+ + {!collapsed && ( + <> + {status === "loading" && ( + + + + )} + {status === "error" && ( + <> + + + + + void refresh()}>Retry + + + )} + {status === "ready" && issues.length > 0 && ( + + + + )} + + + { + setFpsValue(value); + void update("dxvkFrameRate", value); + }} + disabled={controlsDisabled} + /> + + {TOGGLE_ROWS.map((row) => ( + + void update(row.field, value)} + disabled={controlsDisabled} + /> + + ))} + + )} + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 424a360..37a8edb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,3 +10,4 @@ export { FlatpaksTab } from "./FlatpaksTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; export { NowPlayingTab } from "./NowPlayingTab"; +export { WorkaroundsSection } from "./WorkaroundsSection"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 597edca..7a572f9 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -3,6 +3,7 @@ import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; +import { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -94,6 +95,17 @@ export function useGameConfiguration() { const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; + const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); + return true; + } catch (error) { + showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + const save = useCallback(async (next: ConfigurationData) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; @@ -104,14 +116,16 @@ export function useGameConfiguration() { const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; + if (!(await cleanupTargetLaunchOptions(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); return result.success; - }, [load, targets, template]); + }, [cleanupTargetLaunchOptions, load, targets, template]); const enableAll = useCallback(async (): Promise => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; for (const target of available) { + if (!(await cleanupTargetLaunchOptions(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); @@ -119,10 +133,12 @@ export function useGameConfiguration() { } } await load(); - }, [load, targets, template]); + }, [cleanupTargetLaunchOptions, load, targets, template]); const resetSelected = useCallback(async () => { if (selectedAppId) { + const selectedTarget = targets.find((target) => target.appid === selectedAppId); + if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return; const result = await resetGameConfig(selectedAppId); if (result.success) { setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); @@ -130,15 +146,18 @@ export function useGameConfiguration() { await load(); } } - }, [load, selectedAppId]); + }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); const resetAll = useCallback(async () => { + for (const target of targets.filter((item) => item.configured)) { + if (!(await cleanupTargetLaunchOptions(target))) return; + } const result = await resetAllGameConfigs(); if (result.success) { setRunningGame((current) => current ? { ...current, configured: false } : current); setSelectedAppId(""); await load(); } - }, [load]); + }, [cleanupTargetLaunchOptions, load, targets]); return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts new file mode 100644 index 0000000..a937780 --- /dev/null +++ b/src/hooks/usePerAppWorkarounds.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + applyWorkaroundChange, + parseWorkaroundOptions, + readSteamLaunchOptions, + subscribeSteamLaunchOptions, + updateSteamLaunchOptions, + type ParsedWorkaroundOptions, + type SteamLaunchOptionsSnapshot, + type WorkaroundField, +} from "../utils/steamLaunchOptions"; +import { showErrorToast } from "../utils/toastUtils"; + +export type WorkaroundLoadStatus = "loading" | "ready" | "error"; + +const SLIDER_DEBOUNCE_MS = 250; + +interface PendingSliderUpdate { + timer: number; + value: number; + waiters: Array<(success: boolean) => void>; +} + +interface WorkaroundSnapshot { + steam: SteamLaunchOptionsSnapshot; + parsed: ParsedWorkaroundOptions; +} + +interface PerAppWorkarounds { + status: WorkaroundLoadStatus; + snapshot: WorkaroundSnapshot | null; + refresh: () => Promise; + update: (field: WorkaroundField, value: boolean | number) => Promise; + error: string | null; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function makeSnapshot(steam: SteamLaunchOptionsSnapshot): WorkaroundSnapshot { + return { steam, parsed: parseWorkaroundOptions(steam.options) }; +} + +export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { + const [status, setStatus] = useState("loading"); + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const pendingSliderUpdate = useRef(null); + const numericAppId = Number(appId); + + const applySnapshot = useCallback((steam: SteamLaunchOptionsSnapshot) => { + setSnapshot(makeSnapshot(steam)); + setStatus("ready"); + setError(null); + }, []); + + const refresh = useCallback(async () => { + setStatus("loading"); + setError(null); + try { + applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam)); + } catch (refreshError) { + const nextError = asError(refreshError); + setStatus("error"); + setError(nextError.message); + } + }, [applySnapshot, nonSteam, numericAppId]); + + useEffect(() => { + let active = true; + setStatus("loading"); + setSnapshot(null); + setError(null); + + const handleSnapshot = (nextSnapshot: SteamLaunchOptionsSnapshot) => { + if (!active) return; + applySnapshot(nextSnapshot); + }; + const handleSubscriptionError = (subscriptionError: Error) => { + if (!active) return; + setStatus("error"); + setError(subscriptionError.message); + }; + + let unsubscribe = () => {}; + try { + unsubscribe = subscribeSteamLaunchOptions( + numericAppId, + nonSteam, + handleSnapshot, + handleSubscriptionError, + ); + } catch (subscriptionError) { + handleSubscriptionError(asError(subscriptionError)); + } + + void readSteamLaunchOptions(numericAppId, nonSteam) + .then((nextSnapshot) => { + if (active) applySnapshot(nextSnapshot); + }) + .catch((readError) => { + if (active) handleSubscriptionError(asError(readError)); + }); + + return () => { + active = false; + unsubscribe(); + }; + }, [applySnapshot, nonSteam, numericAppId]); + + const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise => { + setError(null); + try { + const nextSnapshot = await updateSteamLaunchOptions( + numericAppId, + nonSteam, + (options) => applyWorkaroundChange(options, field, value), + ); + applySnapshot(nextSnapshot); + return true; + } catch (updateError) { + const nextError = asError(updateError); + setStatus("error"); + setError(nextError.message); + showErrorToast("Workaround update failed", nextError.message); + return false; + } + }, [applySnapshot, nonSteam, numericAppId]); + + const flushSliderUpdate = useCallback(async (): Promise => { + const pending = pendingSliderUpdate.current; + if (!pending) return true; + + pendingSliderUpdate.current = null; + window.clearTimeout(pending.timer); + const success = await persistUpdate("dxvkFrameRate", pending.value); + pending.waiters.forEach((resolve) => resolve(success)); + return success; + }, [persistUpdate]); + + const update = useCallback(async (field: WorkaroundField, value: boolean | number): Promise => { + if (field === "dxvkFrameRate") { + setError(null); + return new Promise((resolve) => { + const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] }; + window.clearTimeout(pending.timer); + pending.value = Number(value); + pending.waiters.push(resolve); + pending.timer = window.setTimeout(() => { + void flushSliderUpdate(); + }, SLIDER_DEBOUNCE_MS); + pendingSliderUpdate.current = pending; + }); + } + + const sliderSuccess = await flushSliderUpdate(); + if (!sliderSuccess) return false; + return persistUpdate(field, value); + }, [flushSliderUpdate, persistUpdate]); + + useEffect(() => { + return () => { + const pending = pendingSliderUpdate.current; + if (!pending) return; + window.clearTimeout(pending.timer); + pendingSliderUpdate.current = null; + pending.waiters.forEach((resolve) => resolve(false)); + }; + }, [numericAppId, nonSteam]); + + return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]); +} diff --git a/src/i18n/languages.json b/src/i18n/languages.json index 3132083..3f1992c 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -10,7 +10,7 @@ "CONFIG_FLOW_SCALE_DESC": "内部モーション推定解像度を下げて、パフォーマンスをわずかに向上させます", "CONFIG_BASE_FPS_CAP": "基本FPS上限", "CONFIG_BASE_FPS_CAP_OFF": "オフ", - "CONFIG_BASE_FPS_CAP_DESC": "フレーム倍率適用前のDirectXゲームの基本フレームレート上限。(ゲームの再起動が必要)", + "CONFIG_BASE_FPS_CAP_DESC": "フレーム生成前のDXVKゲームの基本上限。0で無効。ゲームの再起動が必要です。", "CONFIG_PRESENT_MODE": "プレゼンテーションモード", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -21,18 +21,14 @@ "CONFIG_HDR_MODE_DESC": "HDRモードを有効化します(HDRをサポートするゲームのみ)", "CONFIG_ENABLE_WSI": "WSIを有効化", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。", - "CONFIG_ENABLE_WOW64": "32ビットゲーム用WOW64を有効化", - "CONFIG_ENABLE_WOW64_DESC": "32ビットゲームにPROTON_USE_WOW64=1を有効化します(ProtonGEと併用してクラッシュを修正)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deckモードを無効化します(一部ゲームの隠し設定を解放)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHudワークアラウンド", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "透明なMangoHudオーバーレイを有効化します。ゲームモードでの2X倍率問題を修正することがあります", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。", "CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化", "CONFIG_DISABLE_VKBASALT_DESC": "LSFGと競合する可能性のあるvkBasaltレイヤーを無効化します(Reshade、一部のDeckyプラグイン)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasaltを強制有効化", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "ゲームモードのフレームペーシング問題を修正するためにvkBasaltを強制有効化します", - "CONFIG_ENABLE_ZINK": "OpenGLゲーム用Zinkを有効化", - "CONFIG_ENABLE_ZINK_DESC": "OpenGLゲームにVulkanベースのOpenGL実装を使用します(一部のゲームでクラッシュやフリーズが発生する場合があります)", + "CONFIG_ENABLE_ZINK": "OpenGLゲームでZinkを強制", + "CONFIG_ENABLE_ZINK_DESC": "MesaのZink OpenGL-to-Vulkanドライバーを使用します。一部のゲームでクラッシュやフリーズが発生する可能性があります。ゲームの再起動が必要です。", "INSTALL_INSTALLING": "インストール中...", "INSTALL_UNINSTALLING": "アンインストール中...", "INSTALL_UNINSTALL_BTN": "LSFG-VKをアンインストール", @@ -101,7 +97,7 @@ "CONFIG_FLOW_SCALE_DESC": "내부 모션 추정 해상도를 낮춰 성능을 약간 향상시킵니다", "CONFIG_BASE_FPS_CAP": "기본 FPS 상한", "CONFIG_BASE_FPS_CAP_OFF": "끄기", - "CONFIG_BASE_FPS_CAP_DESC": "프레임 배율 적용 전 DirectX 게임의 기본 프레임 상한. (게임 재시작 필요)", + "CONFIG_BASE_FPS_CAP_DESC": "프레임 생성 전 DXVK 게임의 기본 제한입니다. 0은 비활성화합니다. 게임 재시작 필요.", "CONFIG_PRESENT_MODE": "프레젠테이션 모드", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -112,18 +108,14 @@ "CONFIG_HDR_MODE_DESC": "HDR 모드를 활성화합니다 (HDR을 지원하는 게임에만 해당)", "CONFIG_ENABLE_WSI": "WSI 활성화", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.", - "CONFIG_ENABLE_WOW64": "32비트 게임용 WOW64 활성화", - "CONFIG_ENABLE_WOW64_DESC": "32비트 게임에 PROTON_USE_WOW64=1을 활성화합니다 (크래시 수정을 위해 ProtonGE와 함께 사용)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deck 모드를 비활성화합니다 (일부 게임의 숨겨진 설정 잠금 해제)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud 우회", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "투명한 MangoHud 오버레이를 활성화합니다. 게임 모드에서 2X 배율 문제를 수정하는 데 도움이 될 수 있습니다", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화", "CONFIG_DISABLE_VKBASALT_DESC": "LSFG와 충돌할 수 있는 vkBasalt 레이어를 비활성화합니다 (Reshade, 일부 Decky 플러그인)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasalt 강제 활성화", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "게임 모드에서 프레임 페이싱 문제 수정을 위해 vkBasalt를 강제 활성화합니다", - "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 활성화", - "CONFIG_ENABLE_ZINK_DESC": "OpenGL 게임에 Vulkan 기반 OpenGL 구현을 사용합니다 (일부 게임에서 크래시나 멈춤이 발생할 수 있습니다)", + "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 강제", + "CONFIG_ENABLE_ZINK_DESC": "Mesa의 Zink OpenGL-to-Vulkan 드라이버를 사용합니다. 일부 게임에서 충돌 또는 멈춤이 발생할 수 있으며 게임 재시작이 필요합니다.", "INSTALL_INSTALLING": "설치 중...", "INSTALL_UNINSTALLING": "제거 중...", "INSTALL_UNINSTALL_BTN": "LSFG-VK 제거", @@ -220,7 +212,7 @@ "CONFIG_FLOW_SCALE_DESC": "Lowers internal motion estimation resolution, improving performance slightly", "CONFIG_BASE_FPS_CAP": "Base FPS Cap", "CONFIG_BASE_FPS_CAP_OFF": "Off", - "CONFIG_BASE_FPS_CAP_DESC": "Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)", + "CONFIG_BASE_FPS_CAP_DESC": "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.", "CONFIG_PRESENT_MODE": "Present Mode", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -231,18 +223,14 @@ "CONFIG_HDR_MODE_DESC": "Enables HDR mode (only for games that support HDR)", "CONFIG_ENABLE_WSI": "Enable WSI", "CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.", - "CONFIG_ENABLE_WOW64": "Enable WOW64 for 32-bit games", - "CONFIG_ENABLE_WOW64_DESC": "Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", "CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables Steam Deck mode (Unlocks hidden settings in some games)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud Workaround", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", "CONFIG_DISABLE_VKBASALT": "Disable vkBasalt", "CONFIG_DISABLE_VKBASALT_DESC": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", - "CONFIG_FORCE_ENABLE_VKBASALT": "Force Enable vkBasalt", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "CONFIG_ENABLE_ZINK": "Enable Zink for OpenGL Games", - "CONFIG_ENABLE_ZINK_DESC": "Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)", + "CONFIG_ENABLE_ZINK": "Force Zink for OpenGL Games", + "CONFIG_ENABLE_ZINK_DESC": "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", "INSTALL_INSTALLING": "Installing...", "INSTALL_UNINSTALLING": "Uninstalling...", "INSTALL_UNINSTALL_BTN": "Uninstall LSFG-VK", diff --git a/src/types.d.ts b/src/types.d.ts index dfc0472..4b88d3d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -12,3 +12,27 @@ declare module "*.jpg" { const content: string; export default content; } + +interface SteamAppDetails { + strLaunchOptions?: string; + strShortcutLaunchOptions?: string; + strShortcutExe?: string; +} + +interface SteamAppDetailsRegistration { + unregister: () => void; +} + +interface SteamApps { + RegisterForAppDetails( + appId: number, + callback: (details: SteamAppDetails) => void, + ): SteamAppDetailsRegistration; + SetAppLaunchOptions(appId: number, options: string): void | Promise; + SetShortcutLaunchOptions(appId: number, options: string): void | Promise; + GetAllShortcuts?(): Promise; +} + +declare const SteamClient: { + Apps: SteamApps; +}; diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts new file mode 100644 index 0000000..2fd7b67 --- /dev/null +++ b/src/utils/steamLaunchOptionParser.ts @@ -0,0 +1,489 @@ +export interface WorkaroundState { + dxvkFrameRate: number; + disableGamescopeWsi: boolean; + disableSteamdeckMode: boolean; + disableVkbasalt: boolean; + enableZink: boolean; +} + +export type WorkaroundField = keyof WorkaroundState; + +export interface ParsedWorkaroundOptions { + state: WorkaroundState; + issues: string[]; +} + +interface LaunchToken { + raw: string; + value: string; +} + +interface EnvironmentEntry { + value: string; + count: number; +} + +type BooleanWorkaroundField = Exclude; +type EnvironmentSpec = readonly [key: string, value: string]; +type DxvkFrameRateKey = "dxvk.maxFrameRate" | "dxgi.maxFrameRate" | "d3d9.maxFrameRate"; + +interface WorkaroundDefinition { + spec: EnvironmentSpec; + clear: readonly string[]; + label?: string; +} + +const COMMAND_TOKEN = "%command%"; +const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [ + "dxvk.maxFrameRate", + "dxgi.maxFrameRate", + "d3d9.maxFrameRate", +]; +const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]); +const WORKAROUND_DEFINITIONS = { + disableGamescopeWsi: { + spec: ["ENABLE_GAMESCOPE_WSI", "0"], + clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"], + }, + disableSteamdeckMode: { + spec: ["SteamDeck", "0"], + clear: ["SteamDeck"], + label: "Steam Deck mode", + }, + disableVkbasalt: { + spec: ["DISABLE_VKBASALT", "1"], + clear: ["DISABLE_VKBASALT"], + label: "Disable vkBasalt", + }, + enableZink: { + spec: ["MESA_LOADER_DRIVER_OVERRIDE", "zink"], + clear: ["__GLX_VENDOR_LIBRARY_NAME", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER"], + }, +} as const satisfies Record; +const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [ + "disableGamescopeWsi", + "disableSteamdeckMode", + "disableVkbasalt", + "enableZink", +]; +const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI"; +const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI"; +const MANAGED_ENV_KEYS = new Set([ + ...DXVK_MANAGED_KEYS, + ...BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), +]); + +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: false, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + +export function getDefaultWorkaroundState(): WorkaroundState { + return { ...DEFAULT_WORKAROUND_STATE }; +} + +function decodeToken(raw: string): string { + let value = ""; + let quote: "'" | '"' | null = null; + + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === "\\" && quote !== "'" && index + 1 < raw.length) { + value += raw[index + 1]; + index += 1; + } else if (quote !== null) { + if (character === quote) quote = null; + else value += character; + } else if (character === "'" || character === '"') { + quote = character; + } else { + value += character; + } + } + + return value; +} + +// Steam stores one shell-like line. Keep each token's raw spelling beside its +// decoded value so managed edits leave unrelated quoting and arguments alone. +function tokenize(options: string): LaunchToken[] { + const tokens: LaunchToken[] = []; + let start = -1; + let quote: "'" | '"' | null = null; + let escaped = false; + + for (let index = 0; index < options.length; index += 1) { + const character = options[index]; + if (start < 0) { + if (/\s/.test(character)) continue; + start = index; + } + + if (escaped) { + escaped = false; + } else if (character === "\\" && quote !== "'") { + escaped = true; + } else if (quote !== null) { + if (character === quote) quote = null; + } else if (character === "'" || character === '"') { + quote = character; + } else if (/\s/.test(character)) { + const raw = options.slice(start, index); + tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + } + } + + if (start >= 0) { + const raw = options.slice(start); + tokens.push({ raw, value: decodeToken(raw) }); + } + return tokens; +} + +function serialize(tokens: readonly LaunchToken[]): string { + return tokens.map((token) => token.raw).join(" "); +} + +export function normalizeLaunchOptions(options: string): string { + return serialize(tokenize(options)); +} + +function parseEnvironmentToken(token: LaunchToken): [string, string] | null { + const separator = token.value.indexOf("="); + if (separator < 1) return null; + const key = token.value.slice(0, separator); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null; + return [key, token.value.slice(separator + 1)]; +} + +function findCommandIndex(tokens: readonly LaunchToken[]): number { + return tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +} + +function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number { + let count = 0; + while (count < tokens.length && parseEnvironmentToken(tokens[count]) !== null) count += 1; + return count; +} + +function effectivePrefixLimit(tokens: readonly LaunchToken[]): number { + // Only assignment words before the first command affect the launched game. + // Anything after a wrapper command is that command's argument, even when it + // happens to look like KEY=value. + return leadingEnvironmentCount(tokens); +} + +function effectiveEnvironmentEntries(tokens: readonly LaunchToken[]): Map { + const entries = new Map(); + for (let index = 0; index < effectivePrefixLimit(tokens); index += 1) { + const parsed = parseEnvironmentToken(tokens[index]); + if (!parsed) continue; + const [key, value] = parsed; + const previous = entries.get(key); + entries.set(key, { value, count: (previous?.count || 0) + 1 }); + } + return entries; +} + +function removePrefixAssignments(tokens: LaunchToken[], predicate: (token: LaunchToken) => boolean): boolean { + const limit = effectivePrefixLimit(tokens); + const retained = tokens.filter((token, index) => index >= limit || !predicate(token)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function removeAllAssignments(tokens: LaunchToken[], keys: ReadonlySet): boolean { + return removePrefixAssignments(tokens, (token) => { + const parsed = parseEnvironmentToken(token); + return parsed !== null && keys.has(parsed[0]); + }); +} + +function encodeEnvironmentValue(value: string): string { + if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function insertEnvironmentSpecs(tokens: LaunchToken[], specs: readonly EnvironmentSpec[]): void { + tokens.unshift(...specs.map(([key, value]) => ({ + raw: `${key}=${encodeEnvironmentValue(value)}`, + value: `${key}=${value}`, + }))); +} + +function ensureCommandToken(tokens: LaunchToken[]): void { + if (findCommandIndex(tokens) >= 0) return; + tokens.splice(leadingEnvironmentCount(tokens), 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); +} + +export function isLegacyWrapperToken(value: string): boolean { + const path = decodeToken(value); + return path === "~/lsfg" || path === "/home/deck/lsfg"; +} + +function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean { + const commandIndex = findCommandIndex(tokens); + const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length; + const wrapperIndex = leadingEnvironmentCount(tokens); + if (wrapperIndex >= prefixEnd || !isLegacyWrapperToken(tokens[wrapperIndex].raw)) return false; + tokens.splice(wrapperIndex, 1); + return true; +} + +interface DxvkConfigAssignment { + values: string[]; + malformed: number; +} + +interface ParsedDxvkConfig { + segments: string[]; + assignments: Map; +} + +function splitDxvkConfig(value: string): string[] { + const segments: string[] = []; + let start = 0; + let quote: "'" | '"' | null = null; + let escaped = false; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (escaped) escaped = false; + else if (character === "\\" && quote !== "'") escaped = true; + else if (quote !== null) { + if (character === quote) quote = null; + } else if (character === "'" || character === '"') quote = character; + else if (character === ";") { + segments.push(value.slice(start, index)); + start = index + 1; + } + } + + segments.push(value.slice(start)); + return segments; +} + +function knownDxvkKey(value: string): DxvkFrameRateKey | null { + const key = value.match(/^([A-Za-z][A-Za-z0-9.]*)/)?.[1]; + return key && DXVK_FRAME_RATE_KEYS.includes(key as DxvkFrameRateKey) + ? key as DxvkFrameRateKey + : null; +} + +function parseDxvkConfig(value: string): ParsedDxvkConfig { + const segments = splitDxvkConfig(value); + const assignments = new Map(); + for (const segment of segments) { + const trimmed = segment.trim(); + const key = knownDxvkKey(trimmed); + if (!key) continue; + const match = trimmed.match(/^[A-Za-z][A-Za-z0-9.]*\s*=\s*(.*?)\s*$/); + const entry = assignments.get(key) || { values: [], malformed: 0 }; + if (match) entry.values.push(match[1]); + else entry.malformed += 1; + assignments.set(key, entry); + } + return { segments, assignments }; +} + +function isDxvkFrameRateSegment(segment: string): boolean { + return knownDxvkKey(segment.trim()) !== null; +} + +function parseSupportedFrameRate(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const numericValue = Number(value); + return Number.isSafeInteger(numericValue) && numericValue <= 60 ? numericValue : null; +} + +function rewriteDxvkFrameRate(tokens: LaunchToken[], frameRate: number): void { + const config = effectiveEnvironmentEntries(tokens).get("DXVK_CONFIG"); + const parsed = parseDxvkConfig(config?.value || ""); + const retained = parsed.segments + .filter((segment) => !isDxvkFrameRateSegment(segment)) + .filter((segment) => segment.trim().length > 0) + .join(";"); + const nextConfig = frameRate > 0 + ? [`dxvk.maxFrameRate = ${frameRate}`, ...(retained ? [retained] : [])].join(";") + : retained; + + removeAllAssignments(tokens, DXVK_MANAGED_KEYS); + if (nextConfig) { + ensureCommandToken(tokens); + insertEnvironmentSpecs(tokens, [["DXVK_CONFIG", nextConfig]]); + } +} + +function environmentSpecsForState(state: WorkaroundState): EnvironmentSpec[] { + return BOOLEAN_WORKAROUND_FIELDS + .filter((field) => state[field]) + .map((field) => WORKAROUND_DEFINITIONS[field].spec); +} + +function validateFrameRate(frameRate: number): void { + if (!Number.isInteger(frameRate) || frameRate < 0 || frameRate > 60) { + throw new Error("Base FPS Cap must be an integer from 0 to 60"); + } +} + +function readBoolean( + entries: Map, + key: string, + label: string, + trueValue: string, + issues: string[], +): boolean { + const entry = entries.get(key); + if (!entry) return false; + const falseValue = trueValue === "1" ? "0" : "1"; + if (entry.value === trueValue) return true; + if (entry.value === falseValue) return false; + issues.push(`${label} has an unsupported value.`); + return false; +} + +export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions { + const tokens = tokenize(options); + const entries = effectiveEnvironmentEntries(tokens); + const state = getDefaultWorkaroundState(); + const issues: string[] = []; + + for (const [key, entry] of entries) { + if (MANAGED_ENV_KEYS.has(key) && entry.count > 1) { + issues.push(`${key} appears more than once; Steam uses the last value.`); + } + } + + const dxvkConfig = parseDxvkConfig(entries.get("DXVK_CONFIG")?.value || ""); + const effectiveDxvkValues = new Map(); + for (const key of DXVK_FRAME_RATE_KEYS) { + const assignment = dxvkConfig.assignments.get(key); + if (!assignment) continue; + if (assignment.malformed > 0) issues.push(`${key} in DXVK_CONFIG is malformed.`); + if (assignment.values.length > 1) { + issues.push(`${key} appears more than once in DXVK_CONFIG; DXVK uses the last value.`); + } + if (assignment.values.length === 0) continue; + const value = parseSupportedFrameRate(assignment.values[assignment.values.length - 1]); + effectiveDxvkValues.set(key, value); + if (value === null) issues.push(`${key} in DXVK_CONFIG is outside the supported 0-60 range.`); + } + + const unifiedFrameRate = effectiveDxvkValues.get("dxvk.maxFrameRate"); + const dxgiFrameRate = effectiveDxvkValues.get("dxgi.maxFrameRate"); + const d3d9FrameRate = effectiveDxvkValues.get("d3d9.maxFrameRate"); + if (unifiedFrameRate !== undefined) { + if (unifiedFrameRate !== null) state.dxvkFrameRate = unifiedFrameRate; + } else if (dxgiFrameRate !== undefined && d3d9FrameRate !== undefined) { + if (dxgiFrameRate !== null && dxgiFrameRate === d3d9FrameRate) state.dxvkFrameRate = dxgiFrameRate; + else issues.push("DXVK_CONFIG has conflicting or invalid DirectX frame caps."); + } else if (dxgiFrameRate !== undefined || d3d9FrameRate !== undefined) { + const partial = dxgiFrameRate ?? d3d9FrameRate; + if (partial !== null && partial !== undefined) state.dxvkFrameRate = partial; + issues.push("DXVK_CONFIG only caps one DirectX API; adjust the cap to normalize it."); + } + + if (entries.has("DXVK_FRAME_RATE")) { + issues.push("DXVK_FRAME_RATE is obsolete on current DXVK; adjust the cap to migrate it."); + } + + const wsiSignals: boolean[] = []; + const wsiDisable = entries.get(WSI_DISABLE_KEY); + if (wsiDisable) { + if (wsiDisable.value !== "0" && wsiDisable.value !== "1") issues.push("Disable Gamescope WSI has an unsupported value."); + else wsiSignals.push(wsiDisable.value === "1"); + } + const wsiEnable = entries.get(WSI_ENABLE_KEY); + if (wsiEnable) { + if (wsiEnable.value !== "0" && wsiEnable.value !== "1") issues.push("Enable Gamescope WSI has an unsupported value."); + else wsiSignals.push(wsiEnable.value === "0"); + } + if (wsiSignals.length > 0) { + if (wsiSignals.length === 2 && wsiSignals[0] !== wsiSignals[1]) { + issues.push("Gamescope WSI has conflicting enable and disable assignments."); + } + state.disableGamescopeWsi = wsiSignals.some(Boolean); + } + + for (const field of ["disableSteamdeckMode", "disableVkbasalt"] as const) { + const { spec, label } = WORKAROUND_DEFINITIONS[field]; + state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues); + } + + const vkBasaltEnable = entries.get("ENABLE_VKBASALT"); + const vkBasaltDisable = entries.get("DISABLE_VKBASALT"); + if (vkBasaltEnable?.value === "1" && vkBasaltDisable?.value === "1") { + issues.push("vkBasalt has conflicting enable and disable assignments."); + } + + const zink = entries.get(WORKAROUND_DEFINITIONS.enableZink.spec[0]); + const glxVendor = entries.get("__GLX_VENDOR_LIBRARY_NAME"); + const galliumDriver = entries.get("GALLIUM_DRIVER"); + const hasLegacyZink = glxVendor !== undefined || galliumDriver !== undefined; + if (zink || hasLegacyZink) { + state.enableZink = zink?.value === WORKAROUND_DEFINITIONS.enableZink.spec[1]; + if (hasLegacyZink && ( + glxVendor?.value !== "mesa" || + zink?.value !== WORKAROUND_DEFINITIONS.enableZink.spec[1] || + galliumDriver?.value !== "zink" + )) { + issues.push("Zink workaround is only partially configured."); + } else if (!state.enableZink) { + issues.push("Zink workaround has an unsupported driver value."); + } + } + + return { state, issues }; +} + +export function applyWorkaroundState(options: string, state: WorkaroundState): string { + validateFrameRate(state.dxvkFrameRate); + const tokens = tokenize(options); + removeLegacyWrapperFromTokens(tokens); + rewriteDxvkFrameRate(tokens, state.dxvkFrameRate); + const keysToClear = new Set( + BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), + ); + if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT"); + removeAllAssignments(tokens, keysToClear); + const specs = environmentSpecsForState(state); + if (specs.length > 0) { + ensureCommandToken(tokens); + insertEnvironmentSpecs(tokens, specs); + } + return serialize(tokens); +} + +export function applyWorkaroundChange(options: string, field: WorkaroundField, value: boolean | number): string { + const tokens = tokenize(options); + removeLegacyWrapperFromTokens(tokens); + + if (field === "dxvkFrameRate") { + if (typeof value !== "number") throw new Error("Base FPS Cap must be an integer from 0 to 60"); + validateFrameRate(value); + rewriteDxvkFrameRate(tokens, value); + return serialize(tokens); + } + + if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`); + const definition = WORKAROUND_DEFINITIONS[field]; + const keysToClear = new Set(definition.clear); + if (value && field === "disableVkbasalt") keysToClear.add("ENABLE_VKBASALT"); + removeAllAssignments(tokens, keysToClear); + if (value) { + ensureCommandToken(tokens); + insertEnvironmentSpecs(tokens, [definition.spec]); + } + return serialize(tokens); +} + +export function cleanupLegacyWrapper(options: string): string { + const tokens = tokenize(options); + removeLegacyWrapperFromTokens(tokens); + return serialize(tokens); +} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts new file mode 100644 index 0000000..9c7b5eb --- /dev/null +++ b/src/utils/steamLaunchOptions.ts @@ -0,0 +1,211 @@ +// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. +import { cleanupLegacyWrapper, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; + +// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. +export * from "./steamLaunchOptionParser.ts"; + +export interface SteamLaunchOptionsSnapshot { + appId: number; + nonSteam: boolean; + options: string; + details: SteamAppDetails; +} + +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); +} + +function getSteamApps(): Partial | undefined { + return (globalThis as typeof globalThis & { + SteamClient?: { Apps?: Partial }; + }).SteamClient?.Apps; +} + +function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { + if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) { + throw new Error("The shortcut Target still points to the legacy ~/lsfg wrapper; restore its original executable first"); + } + return { + appId, + nonSteam, + options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", + details, + }; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function registerSteamAppDetails( + appId: number, + onDetails: (details: SteamAppDetails) => boolean | void, +): () => void { + validateAppId(appId); + const apps = getSteamApps(); + const registerForAppDetails = apps?.RegisterForAppDetails; + if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable"); + + let active = true; + let unregisterPending = false; + let registration: SteamAppDetailsRegistration | undefined; + const unsubscribe = () => { + active = false; + if (!registration) { + unregisterPending = true; + return; + } + try { + registration.unregister(); + } catch { + // Steam may invalidate registrations during a details refresh. + } + }; + + try { + registration = registerForAppDetails.call(apps, appId, (details) => { + if (!active) return; + if (onDetails(details || {}) === false && active) unsubscribe(); + }); + if (unregisterPending) { + try { + registration.unregister(); + } catch { + // The registration can be invalidated before a synchronous callback returns. + } + } + } catch (error) { + throw asError(error); + } + return unsubscribe; +} + +export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let timeout = 0; + let unsubscribe = () => {}; + const finish = (error?: unknown, details?: SteamAppDetails) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + unsubscribe(); + if (error) { + reject(asError(error)); + return; + } + try { + resolve(snapshotFromDetails(appId, nonSteam, details || {})); + } catch (snapshotError) { + reject(asError(snapshotError)); + } + }; + + timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000); + try { + unsubscribe = registerSteamAppDetails(appId, (details) => { + finish(undefined, details); + return false; + }); + } catch (error) { + finish(error); + } + }); +} + +export function subscribeSteamLaunchOptions( + appId: number, + nonSteam: boolean, + onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void, + onError: (error: Error) => void, +): () => void { + return registerSteamAppDetails(appId, (details) => { + try { + onSnapshot(snapshotFromDetails(appId, nonSteam, details)); + } catch (error) { + onError(asError(error)); + } + }); +} + +async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise { + const apps = getSteamApps(); + const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; + if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`); + await Promise.resolve(setter.call(apps, appId, options)); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} + +async function waitForLaunchOptions( + appId: number, + nonSteam: boolean, + expected: string, +): Promise { + const deadline = Date.now() + 5000; + let lastError: Error | null = null; + while (Date.now() <= deadline) { + try { + const snapshot = await readSteamLaunchOptions(appId, nonSteam); + if (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot; + } catch (error) { + lastError = asError(error); + } + if (Date.now() >= deadline) break; + await delay(100); + } + if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`); + throw new Error("Steam did not accept the launch options before the readback timeout"); +} + +const operationQueues = new Map>(); + +function queueKey(appId: number, nonSteam: boolean): string { + return `${nonSteam ? "shortcut" : "app"}:${appId}`; +} + +function queueSteamAppOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { + const key = queueKey(appId, nonSteam); + const previous = operationQueues.get(key) || Promise.resolve(); + const queued = previous.catch(() => undefined).then(operation); + let cleanup: Promise; + cleanup = queued.then( + () => { + if (operationQueues.get(key) === cleanup) operationQueues.delete(key); + }, + () => { + if (operationQueues.get(key) === cleanup) operationQueues.delete(key); + }, + ); + operationQueues.set(key, cleanup); + return queued; +} + +export function updateSteamLaunchOptions( + appId: number, + nonSteam: boolean, + transform: (options: string) => string, +): Promise { + return queueSteamAppOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = transform(current.options); + if (next === current.options) return current; + await setSteamLaunchOptions(appId, nonSteam, next); + return waitForLaunchOptions(appId, nonSteam, next); + }); +} + +export function cleanupSteamLaunchOptions( + appId: number, + nonSteam: boolean, +): Promise { + return queueSteamAppOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = cleanupLegacyWrapper(current.options); + if (next === current.options) return current; + await setSteamLaunchOptions(appId, nonSteam, next); + return waitForLaunchOptions(appId, nonSteam, next); + }); +} diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts new file mode 100644 index 0000000..8ecfa77 --- /dev/null +++ b/tests/steamLaunchOptions.test.ts @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + applyWorkaroundChange, + applyWorkaroundState, + cleanupLegacyWrapper, + getDefaultWorkaroundState, + isLegacyWrapperToken, + parseWorkaroundOptions, + readSteamLaunchOptions, + updateSteamLaunchOptions, +} from "../src/utils/steamLaunchOptions.ts"; + +test("maps the supported workarounds to current launch variables", () => { + const options = applyWorkaroundState('gamemoderun %command% --profile "high quality"', { + dxvkFrameRate: 30, + disableGamescopeWsi: true, + disableSteamdeckMode: true, + disableVkbasalt: true, + enableZink: true, + }); + + assert.equal( + options, + 'ENABLE_GAMESCOPE_WSI=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"', + ); + assert.deepEqual(parseWorkaroundOptions(options), { + state: { + dxvkFrameRate: 30, + disableGamescopeWsi: true, + disableSteamdeckMode: true, + disableVkbasalt: true, + enableZink: true, + }, + issues: [], + }); +}); + +test("uses SteamDeck=0 before %command% without a wrapper", () => { + assert.equal( + applyWorkaroundChange("", "disableSteamdeckMode", true), + "SteamDeck=0 %command%", + ); +}); + +test("keeps WSI disable opt-in and does not add HDR assignments", () => { + const defaults = getDefaultWorkaroundState(); + assert.equal(applyWorkaroundState("%command%", defaults), "%command%"); + assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false); + assert.equal( + applyWorkaroundChange("%command%", "disableGamescopeWsi", true), + "ENABLE_GAMESCOPE_WSI=0 %command%", + ); + assert.equal( + applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 %command%", "disableGamescopeWsi", false), + "%command%", + ); + + const legacy = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%"); + assert.equal(legacy.state.disableGamescopeWsi, true); + assert.deepEqual(legacy.issues, []); + assert.equal( + applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", "disableGamescopeWsi", false), + "%command%", + ); + + const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%"); + assert.equal(invalid.state.disableGamescopeWsi, false); + assert.equal(invalid.issues.length, 1); + const conflicting = parseWorkaroundOptions("DISABLE_GAMESCOPE_WSI=1 ENABLE_GAMESCOPE_WSI=1 %command%"); + assert.equal(conflicting.state.disableGamescopeWsi, true); + assert.match(conflicting.issues.join(" "), /conflicting/); +}); + +test("preserves unrelated prefixes, quoted tokens, suffix arguments, and dropped variables", () => { + const options = applyWorkaroundChange( + 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"', + "disableSteamdeckMode", + true, + ); + assert.equal( + options, + 'SteamDeck=0 PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"', + ); + assert.deepEqual(parseWorkaroundOptions(options).issues, []); + + assert.equal( + applyWorkaroundChange("FOO=bar --flag", "disableSteamdeckMode", true), + "SteamDeck=0 FOO=bar %command% --flag", + ); + assert.equal( + applyWorkaroundChange("FOO=1 %command% MANGOHUD=1", "disableSteamdeckMode", false), + "FOO=1 %command% MANGOHUD=1", + ); + assert.equal( + applyWorkaroundChange('FOO=bar --literal "%command%"', "disableSteamdeckMode", true), + 'SteamDeck=0 FOO=bar %command% --literal "%command%"', + ); + assert.equal( + applyWorkaroundChange("gamemoderun SteamDeck=1 %command%", "disableSteamdeckMode", true), + "SteamDeck=0 gamemoderun SteamDeck=1 %command%", + ); + assert.equal(parseWorkaroundOptions("gamemoderun SteamDeck=0 %command%").state.disableSteamdeckMode, false); +}); + +test("uses DXVK_CONFIG for the base cap and preserves other DXVK settings", () => { + assert.equal( + applyWorkaroundChange("%command%", "dxvkFrameRate", 60), + 'DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%', + ); + assert.equal(parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%').state.dxvkFrameRate, 60); + assert.equal( + applyWorkaroundChange( + 'DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%', + "dxvkFrameRate", + 0, + ), + 'DXVK_CONFIG="dxgi.syncInterval = 0" %command%', + ); + assert.equal( + applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 45), + 'DXVK_CONFIG="dxvk.maxFrameRate = 45" %command%', + ); + assert.equal( + applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 0), + "%command%", + ); + + const apiSpecific = parseWorkaroundOptions( + 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 30" %command%', + ); + assert.equal(apiSpecific.state.dxvkFrameRate, 30); + assert.deepEqual(apiSpecific.issues, []); + const partial = parseWorkaroundOptions('DXVK_CONFIG="dxgi.maxFrameRate = 30" %command%'); + assert.equal(partial.state.dxvkFrameRate, 30); + assert.match(partial.issues.join(" "), /only caps one DirectX API/); + const conflicting = parseWorkaroundOptions( + 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 60" %command%', + ); + assert.equal(conflicting.state.dxvkFrameRate, 0); + assert.match(conflicting.issues.join(" "), /conflicting/); +}); + +test("reports invalid and malformed FPS values instead of treating them as off", () => { + const invalid = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 61" %command%'); + assert.equal(invalid.state.dxvkFrameRate, 0); + assert.match(invalid.issues.join(" "), /outside the supported 0-60 range/); + const malformed = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate" %command%'); + assert.equal(malformed.state.dxvkFrameRate, 0); + assert.match(malformed.issues.join(" "), /malformed/); + const obsolete = parseWorkaroundOptions("DXVK_FRAME_RATE=wat %command%"); + assert.equal(obsolete.state.dxvkFrameRate, 0); + assert.match(obsolete.issues.join(" "), /obsolete/); + assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 61), /0 to 60/); + assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 1.5), /0 to 60/); +}); + +test("keeps vkBasalt disable mutually exclusive while preserving the dropped enable flag otherwise", () => { + assert.equal( + applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableSteamdeckMode", true), + "SteamDeck=0 ENABLE_VKBASALT=1 %command%", + ); + const disabled = applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableVkbasalt", true); + assert.equal(disabled, "DISABLE_VKBASALT=1 %command%"); + assert.equal( + applyWorkaroundChange(disabled, "disableVkbasalt", false), + "%command%", + ); + const conflict = parseWorkaroundOptions("ENABLE_VKBASALT=1 DISABLE_VKBASALT=1 %command%"); + assert.equal(conflict.state.disableVkbasalt, true); + assert.match(conflict.issues.join(" "), /conflicting/); +}); + +test("handles current and legacy Zink forms and reports partial state", () => { + const enabled = applyWorkaroundChange("%command%", "enableZink", true); + assert.equal(enabled, "MESA_LOADER_DRIVER_OVERRIDE=zink %command%"); + assert.equal(parseWorkaroundOptions(enabled).state.enableZink, true); + + const legacy = parseWorkaroundOptions( + "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%", + ); + assert.equal(legacy.state.enableZink, true); + assert.deepEqual(legacy.issues, []); + + const partial = parseWorkaroundOptions("__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink %command%"); + assert.equal(partial.state.enableZink, true); + assert.match(partial.issues.join(" "), /partially configured/); + assert.equal( + applyWorkaroundChange( + "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%", + "enableZink", + false, + ), + "%command%", + ); +}); + +test("cleans only the known legacy wrapper and preserves launch options", () => { + assert.equal( + cleanupLegacyWrapper('FOO=bar ~/lsfg %command% --arg "~/lsfg"'), + 'FOO=bar %command% --arg "~/lsfg"', + ); + assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), "%command%"); + assert.equal( + cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"), + "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%", + ); + assert.equal( + cleanupLegacyWrapper("LSFG_PROCESS=decky-lsfg-vk %command%"), + "LSFG_PROCESS=decky-lsfg-vk %command%", + ); + assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false); +}); + +test("is idempotent", () => { + const first = applyWorkaroundChange("gamemoderun %command%", "enableZink", true); + assert.equal(applyWorkaroundState(first, parseWorkaroundOptions(first).state), first); + assert.equal(applyWorkaroundChange(first, "enableZink", true), first); + const capped = applyWorkaroundChange(first, "dxvkFrameRate", 30); + assert.equal(applyWorkaroundChange(capped, "dxvkFrameRate", 30), capped); +}); + +test("reads and writes the matching Steam app-details launch-option field", async () => { + const previousWindow = (globalThis as Record).window; + const previousSteamClient = (globalThis as Record).SteamClient; + let normalOptions = "FOO=bar %command%"; + let shortcutOptions = "--windowed"; + const normalWrites: string[] = []; + const shortcutWrites: string[] = []; + const unregisters: number[] = []; + + const windowShim = { setTimeout, clearTimeout }; + const apps = { + RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { + if (appId === 42) { + callback({ strLaunchOptions: normalOptions, strShortcutLaunchOptions: "must-not-be-read" }); + } else { + callback({ + strShortcutExe: "/usr/bin/example-game", + strShortcutLaunchOptions: shortcutOptions, + strLaunchOptions: "must-not-be-read", + }); + } + return { unregister: () => unregisters.push(appId) }; + }, + SetAppLaunchOptions(appId: number, options: string) { + assert.equal(appId, 42); + normalWrites.push(options); + normalOptions = options.replaceAll(" ", " "); + }, + SetShortcutLaunchOptions(appId: number, options: string) { + assert.equal(appId, 43); + shortcutWrites.push(options); + shortcutOptions = options; + }, + }; + + (globalThis as Record).window = windowShim; + (globalThis as Record).SteamClient = { Apps: apps }; + try { + const normalBefore = await readSteamLaunchOptions(42, false); + assert.equal(normalBefore.options, "FOO=bar %command%"); + const normalAfter = await updateSteamLaunchOptions( + 42, + false, + (options) => applyWorkaroundChange(options, "disableSteamdeckMode", true), + ); + assert.equal(normalWrites.length, 1); + assert.equal(shortcutWrites.length, 0); + assert.equal(normalAfter.options, "SteamDeck=0 FOO=bar %command%"); + + const shortcutAfter = await updateSteamLaunchOptions( + 43, + true, + (options) => applyWorkaroundChange(options, "disableGamescopeWsi", true), + ); + assert.equal(shortcutWrites.length, 1); + assert.equal(shortcutWrites[0], "ENABLE_GAMESCOPE_WSI=0 %command% --windowed"); + assert.equal(shortcutAfter.options, shortcutWrites[0]); + assert.ok(unregisters.includes(42)); + assert.ok(unregisters.includes(43)); + } finally { + if (previousWindow === undefined) delete (globalThis as Record).window; + else (globalThis as Record).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record).SteamClient; + else (globalThis as Record).SteamClient = previousSteamClient; + } +}); -- cgit v1.2.3 From bec26fe025c97c00d398e7a4fb571195706b9e76 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Tue, 8 Sep 2026 16:56:57 -0400 Subject: feat: more launch arg janitoring --- src/components/Content.tsx | 7 ++++--- src/hooks/useGameConfiguration.ts | 5 +++-- src/utils/steamLaunchOptionParser.ts | 28 ++++++++++++++++++++-------- src/utils/steamLaunchOptions.ts | 6 +++--- tests/steamLaunchOptions.test.ts | 23 ++++++++++++++++------- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index f1c8c11..1d7d2d1 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -80,8 +80,9 @@ export function Content() { const handleConfigChange = async ( fieldName: keyof ConfigurationData, value: boolean | number | string | string[], + cleanupLaunchOptions = false, ) => { - await save({ ...config, [fieldName]: value }); + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); }; const onInstall = () => { @@ -115,7 +116,7 @@ export function Content() { handleConfigChange(fieldName, value)} /> ), }] : []), @@ -128,7 +129,7 @@ export function Content() { targets={targets} runningGame={runningGame} onSelect={setSelectedAppId} - onConfigChange={handleConfigChange} + onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} onEnable={enable} onEnableAll={enableAll} onReset={resetSelected} diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 7a572f9..46607cb 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -106,12 +106,13 @@ export function useGameConfiguration() { } }, [installedGames]); - const save = useCallback(async (next: ConfigurationData) => { + const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; + if (cleanupLaunchOptions && !(await cleanupTargetLaunchOptions(selectedTarget))) return; const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); - }, [load, selectedAppId, targets]); + }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts index 2fd7b67..5b8156c 100644 --- a/src/utils/steamLaunchOptionParser.ts +++ b/src/utils/steamLaunchOptionParser.ts @@ -34,6 +34,16 @@ interface WorkaroundDefinition { } const COMMAND_TOKEN = "%command%"; +const LEGACY_WRAPPER_TOKENS = new Set([ + "~/lsfg", + "/home/deck/lsfg", + "~/.local/bin/lsfg-vk-experimental", + "/home/deck/.local/bin/lsfg-vk-experimental", + "~/.local/bin/mako-run", + "/home/deck/.local/bin/mako-run", + "~/.local/bin/mako-launch", + "/home/deck/.local/bin/mako-launch", +]); const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [ "dxvk.maxFrameRate", "dxgi.maxFrameRate", @@ -145,6 +155,7 @@ function tokenize(options: string): LaunchToken[] { } function serialize(tokens: readonly LaunchToken[]): string { + if (tokens.length === 1 && tokens[0].raw.toLowerCase() === COMMAND_TOKEN) return ""; return tokens.map((token) => token.raw).join(" "); } @@ -171,9 +182,6 @@ function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number { } function effectivePrefixLimit(tokens: readonly LaunchToken[]): number { - // Only assignment words before the first command affect the launched game. - // Anything after a wrapper command is that command's argument, even when it - // happens to look like KEY=value. return leadingEnvironmentCount(tokens); } @@ -223,15 +231,15 @@ function ensureCommandToken(tokens: LaunchToken[]): void { export function isLegacyWrapperToken(value: string): boolean { const path = decodeToken(value); - return path === "~/lsfg" || path === "/home/deck/lsfg"; + return LEGACY_WRAPPER_TOKENS.has(path); } function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean { const commandIndex = findCommandIndex(tokens); const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length; - const wrapperIndex = leadingEnvironmentCount(tokens); - if (wrapperIndex >= prefixEnd || !isLegacyWrapperToken(tokens[wrapperIndex].raw)) return false; - tokens.splice(wrapperIndex, 1); + const retained = tokens.filter((token, index) => index >= prefixEnd || !isLegacyWrapperToken(token.raw)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); return true; } @@ -482,8 +490,12 @@ export function applyWorkaroundChange(options: string, field: WorkaroundField, v return serialize(tokens); } -export function cleanupLegacyWrapper(options: string): string { +export function cleanupLegacyLaunchOptions(options: string): string { const tokens = tokenize(options); removeLegacyWrapperFromTokens(tokens); return serialize(tokens); } + +export function cleanupLegacyWrapper(options: string): string { + return cleanupLegacyLaunchOptions(options); +} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 9c7b5eb..39125bd 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,5 @@ // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyWrapper, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. export * from "./steamLaunchOptionParser.ts"; @@ -23,7 +23,7 @@ function getSteamApps(): Partial | undefined { function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) { - throw new Error("The shortcut Target still points to the legacy ~/lsfg wrapper; restore its original executable first"); + throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first"); } return { appId, @@ -203,7 +203,7 @@ export function cleanupSteamLaunchOptions( ): Promise { return queueSteamAppOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupLegacyWrapper(current.options); + const next = cleanupLegacyLaunchOptions(current.options); if (next === current.options) return current; await setSteamLaunchOptions(appId, nonSteam, next); return waitForLaunchOptions(appId, nonSteam, next); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 8ecfa77..0662fbb 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -3,9 +3,11 @@ import test from "node:test"; import { applyWorkaroundChange, applyWorkaroundState, + cleanupLegacyLaunchOptions, cleanupLegacyWrapper, getDefaultWorkaroundState, isLegacyWrapperToken, + normalizeLaunchOptions, parseWorkaroundOptions, readSteamLaunchOptions, updateSteamLaunchOptions, @@ -45,7 +47,7 @@ test("uses SteamDeck=0 before %command% without a wrapper", () => { test("keeps WSI disable opt-in and does not add HDR assignments", () => { const defaults = getDefaultWorkaroundState(); - assert.equal(applyWorkaroundState("%command%", defaults), "%command%"); + assert.equal(applyWorkaroundState("%command%", defaults), ""); assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false); assert.equal( applyWorkaroundChange("%command%", "disableGamescopeWsi", true), @@ -53,7 +55,7 @@ test("keeps WSI disable opt-in and does not add HDR assignments", () => { ); assert.equal( applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 %command%", "disableGamescopeWsi", false), - "%command%", + "", ); const legacy = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%"); @@ -61,7 +63,7 @@ test("keeps WSI disable opt-in and does not add HDR assignments", () => { assert.deepEqual(legacy.issues, []); assert.equal( applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", "disableGamescopeWsi", false), - "%command%", + "", ); const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%"); @@ -123,7 +125,7 @@ test("uses DXVK_CONFIG for the base cap and preserves other DXVK settings", () = ); assert.equal( applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 0), - "%command%", + "", ); const apiSpecific = parseWorkaroundOptions( @@ -164,7 +166,7 @@ test("keeps vkBasalt disable mutually exclusive while preserving the dropped ena assert.equal(disabled, "DISABLE_VKBASALT=1 %command%"); assert.equal( applyWorkaroundChange(disabled, "disableVkbasalt", false), - "%command%", + "", ); const conflict = parseWorkaroundOptions("ENABLE_VKBASALT=1 DISABLE_VKBASALT=1 %command%"); assert.equal(conflict.state.disableVkbasalt, true); @@ -191,7 +193,7 @@ test("handles current and legacy Zink forms and reports partial state", () => { "enableZink", false, ), - "%command%", + "", ); }); @@ -200,7 +202,7 @@ test("cleans only the known legacy wrapper and preserves launch options", () => cleanupLegacyWrapper('FOO=bar ~/lsfg %command% --arg "~/lsfg"'), 'FOO=bar %command% --arg "~/lsfg"', ); - assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), "%command%"); + assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), ""); assert.equal( cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"), "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%", @@ -212,6 +214,13 @@ test("cleans only the known legacy wrapper and preserves launch options", () => assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false); }); +test("canonicalizes a bare command token without removing real arguments", () => { + assert.equal(normalizeLaunchOptions("%command%"), ""); + assert.equal(normalizeLaunchOptions("%COMMAND%"), ""); + assert.equal(normalizeLaunchOptions("FOO=bar %command%"), "FOO=bar %command%"); + assert.equal(normalizeLaunchOptions("%command% --windowed"), "%command% --windowed"); +}); + test("is idempotent", () => { const first = applyWorkaroundChange("gamemoderun %command%", "enableZink", true); assert.equal(applyWorkaroundState(first, parseWorkaroundOptions(first).state), first); -- 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 ++++++++++++++++++----- src/components/ConfigurationTab.tsx | 6 + src/components/GameConfigurationSelector.tsx | 29 ++++- src/components/ProfileDetails.tsx | 4 +- src/components/WorkaroundsSection.tsx | 21 ++-- src/hooks/useGameConfiguration.ts | 46 ++++++-- src/i18n/languages.json | 12 +- src/utils/steamLaunchOptionParser.ts | 40 +++++-- src/utils/steamLaunchOptions.ts | 15 ++- tests/steamLaunchOptions.test.ts | 73 +++++++++++-- tests/test_flatpak_overrides.py | 158 +++++++++++++++++++++++++++ 11 files changed, 465 insertions(+), 73 deletions(-) create mode 100644 tests/test_flatpak_overrides.py 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}", diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 2bb0f26..c41c941 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -33,6 +33,7 @@ export function ConfigurationTab({ const [detailAppId, setDetailAppId] = useState(null); const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null); + const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false); const enableRef = useRef(null); const promptedRunningAppId = useRef(null); const closeDetails = useCallback(() => { @@ -41,6 +42,7 @@ export function ConfigurationTab({ setDetailAppId(null); }, []); const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); + const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []); useEffect(() => { if (!focusDetailAction) return; @@ -77,12 +79,15 @@ export function ConfigurationTab({ targets={targets} runningGame={runningGame} onSelect={(appid) => { + setFocusConfiguredToggle(false); setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); onSelect(appid); setDetailAppId(appid); }} onEnableAll={onEnableAll} onResetAll={onResetAll} + focusConfiguredToggle={focusConfiguredToggle} + onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} /> ); @@ -96,6 +101,7 @@ export function ConfigurationTab({ if (selectedTarget?.configured) { promptedRunningAppId.current = detailAppId; await onReset(); + setFocusConfiguredToggle(true); closeDetails(); } else if (detailAppId && await onEnable(detailAppId)) { setFocusFpsMultiplier(true); diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 5f23b91..50b2cf8 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,5 +1,5 @@ import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { GameTarget } from "../hooks/useGameConfiguration"; @@ -9,6 +9,8 @@ interface Props { onSelect: (appid: string) => void; onEnableAll: () => Promise; onResetAll: () => Promise; + focusConfiguredToggle?: boolean; + onConfiguredToggleFocused?: () => void; } const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3"; @@ -40,12 +42,14 @@ function GameGroup({ collapsed, onToggle, onSelect, + toggleRef, }: { title: string; games: GameTarget[]; collapsed: boolean; onToggle: () => void; onSelect: (appid: string) => void; + toggleRef?: RefObject; }) { if (games.length === 0) return null; @@ -56,6 +60,7 @@ function GameGroup({
@@ -86,7 +91,15 @@ function GameGroup({ ); } -export function GameConfigurationSelector({ targets, runningGame, onSelect, onEnableAll, onResetAll }: Props) { +export function GameConfigurationSelector({ + targets, + runningGame, + onSelect, + onEnableAll, + onResetAll, + focusConfiguredToggle = false, + onConfiguredToggleFocused, +}: Props) { const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => { if (a.appid === runningGame?.appid) return -1; if (b.appid === runningGame?.appid) return 1; @@ -102,6 +115,17 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`); + const configuredToggleRef = useRef(null); + + useEffect(() => { + if (!focusConfiguredToggle) return; + const frame = requestAnimationFrame(() => { + configuredToggleRef.current?.querySelector('[role="button"], button')?.focus(); + onConfiguredToggleFocused?.(); + }); + return () => cancelAnimationFrame(frame); + }, [configuredGames.length, focusConfiguredToggle, onConfiguredToggleFocused]); + const confirmResetAll = () => { showModal( setExpanded((value) => !value)} > - {expanded ? : } Details + {expanded ? : } Game Details {expanded && ( - + )} diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index e990784..d783620 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -10,7 +10,7 @@ interface WorkaroundsSectionProps { nonSteam: boolean; } -const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2"; type ToggleWorkaroundField = Exclude; const TOGGLE_ROWS: readonly { @@ -20,19 +20,26 @@ const TOGGLE_ROWS: readonly { descriptionKey: string; description: string; }[] = [ + { + field: "disableSteamdeckMode", + labelKey: "CONFIG_DISABLE_STEAMDECK_MODE", + label: "Disable Steam Deck Mode", + descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC", + description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", + }, { field: "disableGamescopeWsi", labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI", label: "Disable Gamescope WSI", descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC", - description: "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", + description: "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.", }, { - field: "disableSteamdeckMode", - labelKey: "CONFIG_DISABLE_STEAMDECK_MODE", - label: "Disable Steam Deck Mode", - descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC", - description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", + field: "disableHdr", + labelKey: "CONFIG_DISABLE_HDR", + label: "Disable HDR", + descriptionKey: "CONFIG_DISABLE_HDR_DESC", + description: "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.", }, { field: "disableVkbasalt", diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 46607cb..6d1fe6a 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -3,7 +3,7 @@ import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions"; +import { applyWorkaroundState, cleanupLegacySteamLaunchOptions, cleanupSteamLaunchOptions, getDefaultWorkaroundState, updateSteamLaunchOptions } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -98,7 +98,7 @@ export function useGameConfiguration() { const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; try { - await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); + await cleanupLegacySteamLaunchOptions(Number(target.appid), target.nonSteam); return true; } catch (error) { showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error)); @@ -106,6 +106,32 @@ export function useGameConfiguration() { } }, [installedGames]); + const removeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); + return true; + } catch (error) { + showErrorToast("Could not clean up Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + + const initializeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await updateSteamLaunchOptions( + Number(target.appid), + target.nonSteam, + (options) => applyWorkaroundState(options, getDefaultWorkaroundState()), + ); + return true; + } catch (error) { + showErrorToast("Could not initialize Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; @@ -117,16 +143,16 @@ export function useGameConfiguration() { const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; - if (!(await cleanupTargetLaunchOptions(target))) return false; + if (!(await initializeTargetLaunchOptions(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); return result.success; - }, [cleanupTargetLaunchOptions, load, targets, template]); + }, [initializeTargetLaunchOptions, load, targets, template]); const enableAll = useCallback(async (): Promise => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; for (const target of available) { - if (!(await cleanupTargetLaunchOptions(target))) return; + if (!(await initializeTargetLaunchOptions(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); @@ -134,12 +160,12 @@ export function useGameConfiguration() { } } await load(); - }, [cleanupTargetLaunchOptions, load, targets, template]); + }, [initializeTargetLaunchOptions, load, targets, template]); const resetSelected = useCallback(async () => { if (selectedAppId) { const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return; + if (selectedTarget && !(await removeTargetLaunchOptions(selectedTarget))) return; const result = await resetGameConfig(selectedAppId); if (result.success) { setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); @@ -147,10 +173,10 @@ export function useGameConfiguration() { await load(); } } - }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); + }, [load, removeTargetLaunchOptions, selectedAppId, targets]); const resetAll = useCallback(async () => { for (const target of targets.filter((item) => item.configured)) { - if (!(await cleanupTargetLaunchOptions(target))) return; + if (!(await removeTargetLaunchOptions(target))) return; } const result = await resetAllGameConfigs(); if (result.success) { @@ -158,7 +184,7 @@ export function useGameConfiguration() { setSelectedAppId(""); await load(); } - }, [cleanupTargetLaunchOptions, load, targets]); + }, [load, removeTargetLaunchOptions, targets]); return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; } diff --git a/src/i18n/languages.json b/src/i18n/languages.json index 3f1992c..7e5676b 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -22,7 +22,9 @@ "CONFIG_ENABLE_WSI": "WSIを有効化", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。", "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化", - "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", + "CONFIG_DISABLE_HDR": "HDRを無効化", + "CONFIG_DISABLE_HDR_DESC": "DXVKがゲームにHDRを公開しないようにします。ゲームの再起動が必要です。", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化", "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。", "CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化", @@ -109,7 +111,9 @@ "CONFIG_ENABLE_WSI": "WSI 활성화", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화", - "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", + "CONFIG_DISABLE_HDR": "HDR 비활성화", + "CONFIG_DISABLE_HDR_DESC": "DXVK가 게임에 HDR을 노출하지 않도록 합니다. 게임 재시작 필요.", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화", "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화", @@ -224,7 +228,9 @@ "CONFIG_ENABLE_WSI": "Enable WSI", "CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.", "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI", - "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.", + "CONFIG_DISABLE_HDR": "Disable HDR", + "CONFIG_DISABLE_HDR_DESC": "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.", "CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode", "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", "CONFIG_DISABLE_VKBASALT": "Disable vkBasalt", diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts index 5b8156c..9449b33 100644 --- a/src/utils/steamLaunchOptionParser.ts +++ b/src/utils/steamLaunchOptionParser.ts @@ -1,6 +1,7 @@ export interface WorkaroundState { dxvkFrameRate: number; disableGamescopeWsi: boolean; + disableHdr: boolean; disableSteamdeckMode: boolean; disableVkbasalt: boolean; enableZink: boolean; @@ -41,8 +42,10 @@ const LEGACY_WRAPPER_TOKENS = new Set([ "/home/deck/.local/bin/lsfg-vk-experimental", "~/.local/bin/mako-run", "/home/deck/.local/bin/mako-run", + "mako-run", "~/.local/bin/mako-launch", "/home/deck/.local/bin/mako-launch", + "mako-launch", ]); const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [ "dxvk.maxFrameRate", @@ -53,7 +56,12 @@ const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]); const WORKAROUND_DEFINITIONS = { disableGamescopeWsi: { spec: ["ENABLE_GAMESCOPE_WSI", "0"], - clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"], + clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI"], + }, + disableHdr: { + spec: ["DXVK_HDR", "0"], + clear: ["DXVK_HDR"], + label: "Disable HDR", }, disableSteamdeckMode: { spec: ["SteamDeck", "0"], @@ -72,24 +80,34 @@ const WORKAROUND_DEFINITIONS = { } as const satisfies Record; const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [ "disableGamescopeWsi", + "disableHdr", "disableSteamdeckMode", "disableVkbasalt", "enableZink", ]; const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI"; const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI"; +const WORKAROUND_ENV_KEYS = new Set( + BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), +); const MANAGED_ENV_KEYS = new Set([ ...DXVK_MANAGED_KEYS, - ...BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), + ...WORKAROUND_ENV_KEYS, ]); -const DEFAULT_WORKAROUND_STATE: WorkaroundState = { +const EMPTY_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: false, + disableHdr: false, disableSteamdeckMode: false, disableVkbasalt: false, enableZink: false, }; +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + ...EMPTY_WORKAROUND_STATE, + disableGamescopeWsi: true, + disableHdr: true, +}; export function getDefaultWorkaroundState(): WorkaroundState { return { ...DEFAULT_WORKAROUND_STATE }; @@ -117,8 +135,6 @@ function decodeToken(raw: string): string { return value; } -// Steam stores one shell-like line. Keep each token's raw spelling beside its -// decoded value so managed edits leave unrelated quoting and arguments alone. function tokenize(options: string): LaunchToken[] { const tokens: LaunchToken[] = []; let start = -1; @@ -358,7 +374,7 @@ function readBoolean( export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions { const tokens = tokenize(options); const entries = effectiveEnvironmentEntries(tokens); - const state = getDefaultWorkaroundState(); + const state = { ...EMPTY_WORKAROUND_STATE }; const issues: string[] = []; for (const [key, entry] of entries) { @@ -418,7 +434,7 @@ export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions state.disableGamescopeWsi = wsiSignals.some(Boolean); } - for (const field of ["disableSteamdeckMode", "disableVkbasalt"] as const) { + for (const field of ["disableHdr", "disableSteamdeckMode", "disableVkbasalt"] as const) { const { spec, label } = WORKAROUND_DEFINITIONS[field]; state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues); } @@ -455,7 +471,7 @@ export function applyWorkaroundState(options: string, state: WorkaroundState): s removeLegacyWrapperFromTokens(tokens); rewriteDxvkFrameRate(tokens, state.dxvkFrameRate); const keysToClear = new Set( - BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), + WORKAROUND_ENV_KEYS, ); if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT"); removeAllAssignments(tokens, keysToClear); @@ -496,6 +512,14 @@ export function cleanupLegacyLaunchOptions(options: string): string { return serialize(tokens); } +export function cleanupPluginLaunchOptions(options: string): string { + const tokens = tokenize(options); + removeLegacyWrapperFromTokens(tokens); + rewriteDxvkFrameRate(tokens, 0); + removeAllAssignments(tokens, WORKAROUND_ENV_KEYS); + return serialize(tokens); +} + export function cleanupLegacyWrapper(options: string): string { return cleanupLegacyLaunchOptions(options); } diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 39125bd..82ff94b 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,5 @@ // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +import { cleanupLegacyLaunchOptions, cleanupPluginLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. export * from "./steamLaunchOptionParser.ts"; @@ -200,6 +200,19 @@ export function updateSteamLaunchOptions( export function cleanupSteamLaunchOptions( appId: number, nonSteam: boolean, +): Promise { + return queueSteamAppOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = cleanupPluginLaunchOptions(current.options); + if (next === current.options) return current; + await setSteamLaunchOptions(appId, nonSteam, next); + return waitForLaunchOptions(appId, nonSteam, next); + }); +} + +export function cleanupLegacySteamLaunchOptions( + appId: number, + nonSteam: boolean, ): Promise { return queueSteamAppOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 0662fbb..c4dede3 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -4,6 +4,7 @@ import { applyWorkaroundChange, applyWorkaroundState, cleanupLegacyLaunchOptions, + cleanupPluginLaunchOptions, cleanupLegacyWrapper, getDefaultWorkaroundState, isLegacyWrapperToken, @@ -17,6 +18,7 @@ test("maps the supported workarounds to current launch variables", () => { const options = applyWorkaroundState('gamemoderun %command% --profile "high quality"', { dxvkFrameRate: 30, disableGamescopeWsi: true, + disableHdr: true, disableSteamdeckMode: true, disableVkbasalt: true, enableZink: true, @@ -24,12 +26,13 @@ test("maps the supported workarounds to current launch variables", () => { assert.equal( options, - 'ENABLE_GAMESCOPE_WSI=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"', + 'ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"', ); assert.deepEqual(parseWorkaroundOptions(options), { state: { dxvkFrameRate: 30, disableGamescopeWsi: true, + disableHdr: true, disableSteamdeckMode: true, disableVkbasalt: true, enableZink: true, @@ -45,10 +48,24 @@ test("uses SteamDeck=0 before %command% without a wrapper", () => { ); }); -test("keeps WSI disable opt-in and does not add HDR assignments", () => { +test("defaults new profiles to disable Gamescope WSI and HDR", () => { const defaults = getDefaultWorkaroundState(); - assert.equal(applyWorkaroundState("%command%", defaults), ""); + assert.equal(defaults.disableGamescopeWsi, true); + assert.equal(defaults.disableHdr, true); + assert.equal( + applyWorkaroundState("%command%", defaults), + "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", + ); assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false); + assert.equal(parseWorkaroundOptions("%command%").state.disableHdr, false); + assert.equal( + parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableGamescopeWsi, + true, + ); + assert.equal( + parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableHdr, + true, + ); assert.equal( applyWorkaroundChange("%command%", "disableGamescopeWsi", true), "ENABLE_GAMESCOPE_WSI=0 %command%", @@ -58,14 +75,6 @@ test("keeps WSI disable opt-in and does not add HDR assignments", () => { "", ); - const legacy = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%"); - assert.equal(legacy.state.disableGamescopeWsi, true); - assert.deepEqual(legacy.issues, []); - assert.equal( - applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", "disableGamescopeWsi", false), - "", - ); - const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%"); assert.equal(invalid.state.disableGamescopeWsi, false); assert.equal(invalid.issues.length, 1); @@ -74,6 +83,27 @@ test("keeps WSI disable opt-in and does not add HDR assignments", () => { assert.match(conflicting.issues.join(" "), /conflicting/); }); +test("manages DXVK HDR independently from Gamescope WSI", () => { + assert.equal( + applyWorkaroundChange("%command%", "disableHdr", true), + "DXVK_HDR=0 %command%", + ); + assert.equal(parseWorkaroundOptions("DXVK_HDR=0 %command%").state.disableHdr, true); + assert.equal(parseWorkaroundOptions("DXVK_HDR=1 %command%").state.disableHdr, false); + assert.equal( + applyWorkaroundChange("DXVK_HDR=0 %command%", "disableHdr", false), + "", + ); + assert.equal( + applyWorkaroundChange("DXVK_HDR=0 %command%", "disableGamescopeWsi", true), + "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", + ); + + const invalid = parseWorkaroundOptions("DXVK_HDR=maybe %command%"); + assert.equal(invalid.state.disableHdr, false); + assert.match(invalid.issues.join(" "), /Disable HDR has an unsupported value/); +}); + test("preserves unrelated prefixes, quoted tokens, suffix arguments, and dropped variables", () => { const options = applyWorkaroundChange( 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"', @@ -203,6 +233,8 @@ test("cleans only the known legacy wrapper and preserves launch options", () => 'FOO=bar %command% --arg "~/lsfg"', ); assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), ""); + assert.equal(cleanupLegacyWrapper("mako-run %command%"), ""); + assert.equal(cleanupLegacyWrapper("mako-launch %command%"), ""); assert.equal( cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"), "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%", @@ -214,6 +246,25 @@ test("cleans only the known legacy wrapper and preserves launch options", () => assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false); }); +test("removes plugin-managed launch options when a profile is removed", () => { + assert.equal( + cleanupPluginLaunchOptions( + 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" ~/lsfg %command% --windowed', + ), + 'DXVK_CONFIG="dxgi.syncInterval = 0" FOO="keep this" %command% --windowed', + ); + assert.equal( + cleanupPluginLaunchOptions( + 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%', + ), + 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%', + ); + assert.equal( + cleanupPluginLaunchOptions('DXVK_CONFIG="dxvk.maxFrameRate = 30" %command%'), + "", + ); +}); + test("canonicalizes a bare command token without removing real arguments", () => { assert.equal(normalizeLaunchOptions("%command%"), ""); assert.equal(normalizeLaunchOptions("%COMMAND%"), ""); diff --git a/tests/test_flatpak_overrides.py b/tests/test_flatpak_overrides.py new file mode 100644 index 0000000..ed3ef6a --- /dev/null +++ b/tests/test_flatpak_overrides.py @@ -0,0 +1,158 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.modules.setdefault("tomllib", types.SimpleNamespace(loads=Mock())) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.flatpak_service import FlatpakService + + +class FlatpakOverrideTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + home = Path(self.tempdir.name) / "home" / "deck" + home.mkdir(parents=True) + self.service = FlatpakService() + self.service.user_home = home + self.service.config_dir = home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.legacy_script_path = home / "lsfg" + self.service.check_flatpak_available = Mock(return_value=True) + self.service._run_flatpak_command = Mock( + return_value=types.SimpleNamespace(returncode=0, stderr="", stdout="") + ) + self.app_id = "com.example.Game" + self.override_path = self.service._override_file_path(self.app_id) + + def tearDown(self): + self.tempdir.cleanup() + sys.modules.pop("lsfg_vk.plugin", None) + sys.modules.pop("lsfg_vk", None) + + def _paths(self): + return self.service._override_paths() + + def _write_override(self, content): + self.override_path.parent.mkdir(parents=True, exist_ok=True) + self.override_path.write_text(content, encoding="utf-8") + + def _show_response(self, content): + return types.SimpleNamespace(returncode=0, stderr="", stdout=content) + + def test_set_cleans_legacy_entries_and_verifies_readback(self): + paths = self._paths() + self._write_override( + "[Context]\n" + f"filesystems=/home/deck/keep;{paths['config_dir']}:rw;!{paths['legacy_home']};" + f"{paths['legacy_script']};{paths['legacy_dll']}:ro;{paths['dll_dir']}:ro;\n" + "unset-environment=KEEP_UNSET;LSFG_CONFIG;\n\n" + "[Environment]\n" + "KEEP_ENV=1\n" + "LSFG_CONFIG=\n" + "LSFGVK_CONFIG=old\n" + "ENABLE_GAMESCOPE_WSI=1\n" + "DXVK_HDR=1\n" + ) + expected = ( + "[Context]\n" + f"filesystems={paths['config_dir']}:rw;{paths['dll_dir']}:ro\n" + "[Environment]\n" + f"LSFGVK_CONFIG={paths['config_file']}\n" + "ENABLE_GAMESCOPE_WSI=0\n" + "DXVK_HDR=0\n" + ) + self.service._run_flatpak_command.side_effect = [ + self._show_response(""), + self._show_response(expected), + ] + + response = self.service.set_app_override(self.app_id) + command_args = self.service._run_flatpak_command.call_args_list[0].args[0] + cleaned = self.override_path.read_text(encoding="utf-8") + + self.assertTrue(response["success"]) + self.assertIn("--env=ENABLE_GAMESCOPE_WSI=0", command_args) + self.assertIn("--env=DXVK_HDR=0", command_args) + self.assertNotIn("--nofilesystem=/home/deck", command_args) + self.assertNotIn("--unset-env=LSFG_CONFIG", command_args) + self.assertIn("/home/deck/keep", cleaned) + self.assertIn("KEEP_ENV=1", cleaned) + self.assertNotIn("LSFG_CONFIG", cleaned) + self.assertNotIn(paths["legacy_home"], cleaned) + + def test_set_reports_failed_readback(self): + paths = self._paths() + self.service._run_flatpak_command.side_effect = [ + self._show_response(""), + self._show_response( + f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n" + f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n" + ), + ] + + response = self.service.set_app_override(self.app_id) + + self.assertFalse(response["success"]) + self.assertIn("verified", response["error"]) + + def test_remove_cleans_known_entries_preserves_unrelated_and_verifies(self): + paths = self._paths() + self._write_override( + "[Context]\n" + f"filesystems=/home/deck/keep;{paths['config_dir']};!{paths['legacy_home']};" + f"{paths['legacy_dll']};{paths['legacy_script']}\n" + "unset-environment=KEEP_UNSET;LSFG_CONFIG;ENABLE_GAMESCOPE_WSI\n\n" + "[Environment]\n" + "KEEP_ENV=1\n" + "LSFGVK_CONFIG=/old/path\n" + "DXVK_HDR=0\n" + ) + self.service._run_flatpak_command.side_effect = [ + self._show_response( + "[Context]\nfilesystems=/home/deck/keep\n" + "[Environment]\nKEEP_ENV=1\n" + ) + ] + + response = self.service.remove_app_override(self.app_id) + cleaned = self.override_path.read_text(encoding="utf-8") + + self.assertTrue(response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 1) + self.assertIn("/home/deck/keep", cleaned) + self.assertIn("KEEP_UNSET", cleaned) + self.assertIn("KEEP_ENV", cleaned) + for name in ("LSFGVK_CONFIG", "LSFG_CONFIG", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"): + self.assertNotIn(name, cleaned) + for path in paths.values(): + if path != paths["config_file"]: + self.assertNotIn(path, cleaned) + + def test_remove_reports_failed_readback(self): + self._write_override("[Context]\nfilesystems=/home/deck/keep\n") + paths = self._paths() + self.service._run_flatpak_command.side_effect = [ + self._show_response( + f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n" + f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n" + "ENABLE_GAMESCOPE_WSI=0\nDXVK_HDR=0\n" + ) + ] + + response = self.service.remove_app_override(self.app_id) + + self.assertFalse(response["success"]) + self.assertIn("verified", response["error"]) + + +if __name__ == "__main__": + unittest.main() -- 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 ++++++++++++++++++++++ src/api/lsfgApi.ts | 29 ++ src/components/ConfigurationTab.tsx | 3 + src/components/Content.tsx | 2 + src/components/GameConfigurationControls.tsx | 8 +- src/components/WorkaroundsSection.tsx | 41 ++- src/hooks/useGameConfiguration.ts | 158 ++++++-- src/hooks/usePerAppWorkarounds.ts | 234 +++++++++--- src/types.d.ts | 1 + src/utils/steamLaunchOptionParser.ts | 525 --------------------------- src/utils/steamLaunchOptions.ts | 472 +++++++++++++++++++++--- tests/steamLaunchOptions.test.ts | 434 ++++++++-------------- tests/test_wrapper_service.py | 170 +++++++++ 16 files changed, 1566 insertions(+), 966 deletions(-) create mode 100644 py_modules/lsfg_vk/wrapper_service.py delete mode 100644 src/utils/steamLaunchOptionParser.ts create mode 100644 tests/test_wrapper_service.py 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, + } diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 8eaad98..087228c 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -60,6 +60,27 @@ export interface GameConfigResult extends ConfigUpdateResult { config?: LsfgConfig; } +export interface WorkaroundState { + dxvkFrameRate: number; + disableGamescopeWsi: boolean; + disableHdr: boolean; + disableSteamdeckMode: boolean; + disableVkbasalt: boolean; + enableZink: boolean; +} + +export interface WorkaroundStateResult { + success: boolean; + message?: string; + error?: string; + appid?: string; + state?: WorkaroundState | null; + wrapper_path?: string; + wrapper_owned?: boolean; + shortcut_exe?: string | null; + command_token_added?: boolean; +} + export interface FileContentResult { success: boolean; content?: string; @@ -120,3 +141,11 @@ export const getInstalledGames = callable<[], InstalledGamesResult>("get_install export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); +export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state"); +export const setWorkaroundState = callable<[ + string, + WorkaroundState, + string | null | undefined, + boolean, +], WorkaroundStateResult>("set_workaround_state"); +export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index c41c941..62a971b 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -15,6 +15,7 @@ interface ConfigurationTabProps { onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; onEnable: (appid: string) => Promise; onEnableAll: () => Promise; + onRepair: (appid: string) => Promise; onReset: () => Promise; onResetAll: () => Promise; } @@ -27,6 +28,7 @@ export function ConfigurationTab({ onConfigChange, onEnable, onEnableAll, + onRepair, onReset, onResetAll, }: ConfigurationTabProps) { @@ -157,6 +159,7 @@ export function ConfigurationTab({ onFpsMultiplierFocused={clearFpsFocusRequest} showWorkarounds workaroundTarget={selectedTarget || undefined} + onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined} /> )} {selectedTarget?.configured && ( diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 1d7d2d1..59dc514 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -39,6 +39,7 @@ export function Content() { save, enable, enableAll, + repair, resetSelected, resetAll, reload, @@ -132,6 +133,7 @@ export function Content() { onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} onEnable={enable} onEnableAll={enableAll} + onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 58ccd02..7025f78 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -11,6 +11,7 @@ interface Props { onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; workaroundTarget?: Pick; + onRepairWorkaround?: () => Promise; } export function GameConfigurationControls({ @@ -20,6 +21,7 @@ export function GameConfigurationControls({ onFpsMultiplierFocused, showWorkarounds = false, workaroundTarget, + onRepairWorkaround, }: Props) { return ( <> @@ -31,7 +33,11 @@ export function GameConfigurationControls({ /> {showWorkarounds && workaroundTarget && ( - + )} ); diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index d783620..dbdd6a3 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -3,11 +3,12 @@ import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; -import type { WorkaroundField } from "../utils/steamLaunchOptions"; +import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; + onRepair?: () => Promise; } const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2"; @@ -78,18 +79,28 @@ function usePersistentCollapsed() { return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); - const state = snapshot?.parsed.state; - const issues = snapshot?.parsed.issues || []; - const controlsDisabled = status !== "ready" || state === undefined; + const [repairing, setRepairing] = useState(false); + const state = snapshot?.state; + const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; const [fpsValue, setFpsValue] = useState(null); const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0; const fpsLabel = effectiveFpsValue > 0 ? `${effectiveFpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + const handleRepair = async () => { + if (!onRepair || repairing) return; + setRepairing(true); + try { + if (await onRepair()) await refresh(); + } finally { + setRepairing(false); + } + }; + useEffect(() => { setFpsValue(state?.dxvkFrameRate ?? null); }, [state?.dxvkFrameRate, status]); @@ -155,13 +166,19 @@ export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) )} - {status === "ready" && issues.length > 0 && ( - - - + {status === "ready" && snapshot && (!snapshot.wrapperOwned || !snapshot.integrationInstalled) && ( + <> + + + + {onRepair && ( + + void handleRepair()}> + {repairing ? "Reinstalling..." : "Reinstall wrapper"} + + + )} + )} diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 6d1fe6a..c66596a 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { applyWorkaroundState, cleanupLegacySteamLaunchOptions, cleanupSteamLaunchOptions, getDefaultWorkaroundState, updateSteamLaunchOptions } from "../utils/steamLaunchOptions"; +import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -32,6 +32,19 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta return Array.from(games.values()); } +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + export function useGameConfiguration() { const [games, setGames] = useState([]); const [globalConfig, setGlobalConfig] = useState({ dll: "", no_fp16: false }); @@ -95,39 +108,107 @@ export function useGameConfiguration() { const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; - const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { + const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; + const appId = Number(target.appid); try { - await cleanupLegacySteamLaunchOptions(Number(target.appid), target.nonSteam); - return true; - } catch (error) { - showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error)); - return false; - } - }, [installedGames]); + const existing = await getWorkaroundState(target.appid); + if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); + const current = await readSteamLaunchOptions(appId, target.nonSteam); + const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const oldState = existing.state; + const oldShortcutExe = existing.shortcut_exe || undefined; + const oldCommandTokenAdded = existing.command_token_added === true; + if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) { + throw new Error("Managed shortcut Target has no saved original executable"); + } + if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { + throw new Error("Shortcut Target changed externally; refusing to replace it"); + } + if (target.nonSteam && !oldState && current.target === wrapperPath) { + throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); + } + const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; + const originalExecutable = target.nonSteam ? (oldShortcutExe || current.target) : undefined; + const initialIntegration = target.nonSteam + ? current.target === wrapperPath + : hasWrapperLaunchIntegration(current.options, wrapperPath); + const initialStateResult = await setWorkaroundState( + target.appid, + state, + originalExecutable || null, + oldCommandTokenAdded, + ); + if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state"); - const removeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { - if (!installedGames.some((game) => game.appid === target.appid)) return true; - try { - await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); - return true; + let integration: Awaited> | null = null; + try { + integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded); + const finalStateResult = await setWorkaroundState( + target.appid, + state, + target.nonSteam ? (integration.originalExecutable || originalExecutable || null) : null, + integration.commandTokenAdded, + ); + if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state"); + return true; + } catch (error) { + let rollbackSucceeded = true; + if (!initialIntegration && integration) { + try { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + target.nonSteam ? (integration?.originalExecutable || originalExecutable) : undefined, + integration?.commandTokenAdded ?? oldCommandTokenAdded, + ); + } catch (rollbackError) { + showErrorToast("Workaround rollback failed", asError(rollbackError).message); + rollbackSucceeded = false; + } + } + if (rollbackSucceeded) { + const restored = oldState + ? await setWorkaroundState(target.appid, oldState, oldShortcutExe || null, oldCommandTokenAdded) + : await removeWorkaroundState(target.appid); + if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state"); + } + throw error; + } } catch (error) { - showErrorToast("Could not clean up Steam launch options", error instanceof Error ? error.message : String(error)); + showErrorToast("Could not initialize workarounds", asError(error).message); return false; } }, [installedGames]); - const initializeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise => { + const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; + const appId = Number(target.appid); try { - await updateSteamLaunchOptions( - Number(target.appid), - target.nonSteam, - (options) => applyWorkaroundState(options, getDefaultWorkaroundState()), - ); + const existing = await getWorkaroundState(target.appid); + if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); + const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + if (existing.state) { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + existing.shortcut_exe || undefined, + existing.command_token_added === true, + ); + } else { + const current = await readSteamLaunchOptions(appId, target.nonSteam); + if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { + throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); + } + await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); + } + const removed = await removeWorkaroundState(target.appid); + if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); return true; } catch (error) { - showErrorToast("Could not initialize Steam launch options", error instanceof Error ? error.message : String(error)); + showErrorToast("Could not clean up game workarounds", asError(error).message); return false; } }, [installedGames]); @@ -135,37 +216,46 @@ export function useGameConfiguration() { const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; - if (cleanupLaunchOptions && !(await cleanupTargetLaunchOptions(selectedTarget))) return; + // The profile owns its wrapper integration. Keep this check on every + // configuration save so an external edit is detected before the profile + // is changed; toggles update the sidecar only. + if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return; const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); - }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); + }, [ensureTargetWorkarounds, load, selectedAppId, targets]); const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; - if (!(await initializeTargetLaunchOptions(target))) return false; + if (!(await ensureTargetWorkarounds(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); + else await removeTargetWorkarounds(target); return result.success; - }, [initializeTargetLaunchOptions, load, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); const enableAll = useCallback(async (): Promise => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; for (const target of available) { - if (!(await initializeTargetLaunchOptions(target))) return; + if (!(await ensureTargetWorkarounds(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); + await removeTargetWorkarounds(target); return; } } await load(); - }, [initializeTargetLaunchOptions, load, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const repair = useCallback(async (appid: string): Promise => { + const target = targets.find((item) => item.appid === appid); + return target ? ensureTargetWorkarounds(target) : false; + }, [ensureTargetWorkarounds, targets]); const resetSelected = useCallback(async () => { if (selectedAppId) { const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (selectedTarget && !(await removeTargetLaunchOptions(selectedTarget))) return; + if (selectedTarget && !(await removeTargetWorkarounds(selectedTarget))) return; const result = await resetGameConfig(selectedAppId); if (result.success) { setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); @@ -173,10 +263,10 @@ export function useGameConfiguration() { await load(); } } - }, [load, removeTargetLaunchOptions, selectedAppId, targets]); + }, [load, removeTargetWorkarounds, selectedAppId, targets]); const resetAll = useCallback(async () => { for (const target of targets.filter((item) => item.configured)) { - if (!(await removeTargetLaunchOptions(target))) return; + if (!(await removeTargetWorkarounds(target))) return; } const result = await resetAllGameConfigs(); if (result.success) { @@ -184,7 +274,7 @@ export function useGameConfiguration() { setSelectedAppId(""); await load(); } - }, [load, removeTargetLaunchOptions, targets]); + }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index a937780..9e283db 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -1,29 +1,50 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - applyWorkaroundChange, - parseWorkaroundOptions, + getWorkaroundState, + removeWorkaroundState, + setWorkaroundState, + type WorkaroundState, +} from "../api/lsfgApi"; +import { + getDefaultWrapperPath, + hasWrapperLaunchIntegration, + installWrapperIntegration, + isLegacyWrapperToken, readSteamLaunchOptions, + removeWrapperIntegration, subscribeSteamLaunchOptions, - updateSteamLaunchOptions, - type ParsedWorkaroundOptions, type SteamLaunchOptionsSnapshot, - type WorkaroundField, } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; +export type WorkaroundField = keyof WorkaroundState; export type WorkaroundLoadStatus = "loading" | "ready" | "error"; const SLIDER_DEBOUNCE_MS = 250; +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + interface PendingSliderUpdate { timer: number; value: number; waiters: Array<(success: boolean) => void>; } -interface WorkaroundSnapshot { +export interface WorkaroundSnapshot { steam: SteamLaunchOptionsSnapshot; - parsed: ParsedWorkaroundOptions; + state: WorkaroundState; + wrapperPath: string; + wrapperOwned: boolean; + integrationInstalled: boolean; + commandTokenAdded: boolean; + shortcutExe?: string | null; } interface PerAppWorkarounds { @@ -38,8 +59,84 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function makeSnapshot(steam: SteamLaunchOptionsSnapshot): WorkaroundSnapshot { - return { steam, parsed: parseWorkaroundOptions(steam.options) }; +function integrationIsInstalled( + steam: SteamLaunchOptionsSnapshot, + nonSteam: boolean, + wrapperPath: string, +): boolean { + return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath); +} + +function makeSnapshot( + steam: SteamLaunchOptionsSnapshot, + result: Awaited>, + nonSteam: boolean, +): WorkaroundSnapshot { + if (!result.state) throw new Error("Workaround state is not initialized for this profile"); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) { + throw new Error("Managed shortcut Target has no saved original executable"); + } + return { + steam, + state: result.state, + wrapperPath, + wrapperOwned: result.wrapper_owned === true, + integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath), + commandTokenAdded: result.command_token_added === true, + shortcutExe: result.shortcut_exe, + }; +} + +async function adoptWorkaroundState( + appId: string, + nonSteam: boolean, + steam: SteamLaunchOptionsSnapshot, + wrapperPath: string, +): Promise { + if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { + throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); + } + const originalExecutable = nonSteam ? steam.target : null; + const initial = await setWorkaroundState(appId, DEFAULT_WORKAROUND_STATE, originalExecutable, false); + if (!initial.success) throw new Error(initial.error || "Could not create workaround state"); + let integration: Awaited> | null = null; + try { + integration = await installWrapperIntegration( + Number(appId), + nonSteam, + wrapperPath, + ); + const finalized = await setWorkaroundState( + appId, + DEFAULT_WORKAROUND_STATE, + nonSteam ? (integration.originalExecutable || originalExecutable) : null, + integration.commandTokenAdded, + ); + if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); + return makeSnapshot(integration.snapshot, finalized, nonSteam); + } catch (error) { + let rollbackSucceeded = true; + if (integration) { + try { + await removeWrapperIntegration( + Number(appId), + nonSteam, + wrapperPath, + nonSteam ? (integration?.originalExecutable || originalExecutable || undefined) : undefined, + integration?.commandTokenAdded ?? false, + ); + } catch { + // Leave the owned integration in place rather than guessing at cleanup. + rollbackSucceeded = false; + } + } + if (rollbackSucceeded) { + const removed = await removeWorkaroundState(appId); + if (!removed.success) throw new Error(removed.error || "Could not roll back workaround state"); + } + throw error; + } } export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { @@ -49,8 +146,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo const pendingSliderUpdate = useRef(null); const numericAppId = Number(appId); - const applySnapshot = useCallback((steam: SteamLaunchOptionsSnapshot) => { - setSnapshot(makeSnapshot(steam)); + const loadSnapshot = useCallback(async () => { + const [result, steam] = await Promise.all([ + getWorkaroundState(appId), + readSteamLaunchOptions(numericAppId, nonSteam), + ]); + if (!result.success) throw new Error(result.error || "Could not read workaround state"); + if (!result.state) { + return adoptWorkaroundState( + appId, + nonSteam, + steam, + result.wrapper_path || getDefaultWrapperPath(), + ); + } + return makeSnapshot(steam, result, nonSteam); + }, [appId, nonSteam, numericAppId]); + + const applySnapshot = useCallback((next: WorkaroundSnapshot) => { + setSnapshot(next); setStatus("ready"); setError(null); }, []); @@ -59,65 +173,79 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo setStatus("loading"); setError(null); try { - applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam)); + applySnapshot(await loadSnapshot()); } catch (refreshError) { const nextError = asError(refreshError); setStatus("error"); setError(nextError.message); } - }, [applySnapshot, nonSteam, numericAppId]); + }, [applySnapshot, loadSnapshot]); useEffect(() => { let active = true; setStatus("loading"); setSnapshot(null); setError(null); - - const handleSnapshot = (nextSnapshot: SteamLaunchOptionsSnapshot) => { - if (!active) return; - applySnapshot(nextSnapshot); - }; - const handleSubscriptionError = (subscriptionError: Error) => { - if (!active) return; - setStatus("error"); - setError(subscriptionError.message); - }; - let unsubscribe = () => {}; try { unsubscribe = subscribeSteamLaunchOptions( numericAppId, nonSteam, - handleSnapshot, - handleSubscriptionError, + (steam) => { + if (!active) return; + setSnapshot((current) => current ? { + ...current, + steam, + integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath), + } : current); + }, + (subscriptionError) => { + if (!active) return; + setStatus("error"); + setError(subscriptionError.message); + }, ); } catch (subscriptionError) { - handleSubscriptionError(asError(subscriptionError)); + if (active) { + setStatus("error"); + setError(asError(subscriptionError).message); + } } - - void readSteamLaunchOptions(numericAppId, nonSteam) - .then((nextSnapshot) => { - if (active) applySnapshot(nextSnapshot); - }) + void loadSnapshot() + .then((next) => { if (active) applySnapshot(next); }) .catch((readError) => { - if (active) handleSubscriptionError(asError(readError)); + if (active) { + setStatus("error"); + setError(asError(readError).message); + } }); - return () => { active = false; unsubscribe(); }; - }, [applySnapshot, nonSteam, numericAppId]); + }, [applySnapshot, loadSnapshot, nonSteam, numericAppId]); const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise => { + const current = snapshot; + if (!current) return false; setError(null); + const nextState = { ...current.state, [field]: value } as WorkaroundState; try { - const nextSnapshot = await updateSteamLaunchOptions( - numericAppId, - nonSteam, - (options) => applyWorkaroundChange(options, field, value), + const result = await setWorkaroundState( + appId, + nextState, + current.shortcutExe ?? null, + current.commandTokenAdded, ); - applySnapshot(nextSnapshot); + if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); + applySnapshot({ + ...current, + state: result.state, + wrapperPath: result.wrapper_path || current.wrapperPath, + wrapperOwned: result.wrapper_owned === true, + shortcutExe: result.shortcut_exe, + commandTokenAdded: result.command_token_added === true, + }); return true; } catch (updateError) { const nextError = asError(updateError); @@ -126,14 +254,13 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo showErrorToast("Workaround update failed", nextError.message); return false; } - }, [applySnapshot, nonSteam, numericAppId]); + }, [appId, applySnapshot, snapshot]); const flushSliderUpdate = useCallback(async (): Promise => { const pending = pendingSliderUpdate.current; if (!pending) return true; - pendingSliderUpdate.current = null; - window.clearTimeout(pending.timer); + clearTimeout(pending.timer); const success = await persistUpdate("dxvkFrameRate", pending.value); pending.waiters.forEach((resolve) => resolve(success)); return success; @@ -143,30 +270,25 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo if (field === "dxvkFrameRate") { setError(null); return new Promise((resolve) => { - const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] }; + const pending = pendingSliderUpdate.current || { timer: 0, value: 0, waiters: [] }; window.clearTimeout(pending.timer); pending.value = Number(value); pending.waiters.push(resolve); - pending.timer = window.setTimeout(() => { - void flushSliderUpdate(); - }, SLIDER_DEBOUNCE_MS); + pending.timer = window.setTimeout(() => { void flushSliderUpdate(); }, SLIDER_DEBOUNCE_MS); pendingSliderUpdate.current = pending; }); } - const sliderSuccess = await flushSliderUpdate(); if (!sliderSuccess) return false; return persistUpdate(field, value); }, [flushSliderUpdate, persistUpdate]); - useEffect(() => { - return () => { - const pending = pendingSliderUpdate.current; - if (!pending) return; - window.clearTimeout(pending.timer); - pendingSliderUpdate.current = null; - pending.waiters.forEach((resolve) => resolve(false)); - }; + useEffect(() => () => { + const pending = pendingSliderUpdate.current; + if (!pending) return; + window.clearTimeout(pending.timer); + pendingSliderUpdate.current = null; + pending.waiters.forEach((resolve) => resolve(false)); }, [numericAppId, nonSteam]); return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]); diff --git a/src/types.d.ts b/src/types.d.ts index 4b88d3d..7b5d055 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -30,6 +30,7 @@ interface SteamApps { ): SteamAppDetailsRegistration; SetAppLaunchOptions(appId: number, options: string): void | Promise; SetShortcutLaunchOptions(appId: number, options: string): void | Promise; + SetShortcutExe(appId: number, executable: string): void | Promise; GetAllShortcuts?(): Promise; } diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts deleted file mode 100644 index 9449b33..0000000 --- a/src/utils/steamLaunchOptionParser.ts +++ /dev/null @@ -1,525 +0,0 @@ -export interface WorkaroundState { - dxvkFrameRate: number; - disableGamescopeWsi: boolean; - disableHdr: boolean; - disableSteamdeckMode: boolean; - disableVkbasalt: boolean; - enableZink: boolean; -} - -export type WorkaroundField = keyof WorkaroundState; - -export interface ParsedWorkaroundOptions { - state: WorkaroundState; - issues: string[]; -} - -interface LaunchToken { - raw: string; - value: string; -} - -interface EnvironmentEntry { - value: string; - count: number; -} - -type BooleanWorkaroundField = Exclude; -type EnvironmentSpec = readonly [key: string, value: string]; -type DxvkFrameRateKey = "dxvk.maxFrameRate" | "dxgi.maxFrameRate" | "d3d9.maxFrameRate"; - -interface WorkaroundDefinition { - spec: EnvironmentSpec; - clear: readonly string[]; - label?: string; -} - -const COMMAND_TOKEN = "%command%"; -const LEGACY_WRAPPER_TOKENS = new Set([ - "~/lsfg", - "/home/deck/lsfg", - "~/.local/bin/lsfg-vk-experimental", - "/home/deck/.local/bin/lsfg-vk-experimental", - "~/.local/bin/mako-run", - "/home/deck/.local/bin/mako-run", - "mako-run", - "~/.local/bin/mako-launch", - "/home/deck/.local/bin/mako-launch", - "mako-launch", -]); -const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [ - "dxvk.maxFrameRate", - "dxgi.maxFrameRate", - "d3d9.maxFrameRate", -]; -const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]); -const WORKAROUND_DEFINITIONS = { - disableGamescopeWsi: { - spec: ["ENABLE_GAMESCOPE_WSI", "0"], - clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI"], - }, - disableHdr: { - spec: ["DXVK_HDR", "0"], - clear: ["DXVK_HDR"], - label: "Disable HDR", - }, - disableSteamdeckMode: { - spec: ["SteamDeck", "0"], - clear: ["SteamDeck"], - label: "Steam Deck mode", - }, - disableVkbasalt: { - spec: ["DISABLE_VKBASALT", "1"], - clear: ["DISABLE_VKBASALT"], - label: "Disable vkBasalt", - }, - enableZink: { - spec: ["MESA_LOADER_DRIVER_OVERRIDE", "zink"], - clear: ["__GLX_VENDOR_LIBRARY_NAME", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER"], - }, -} as const satisfies Record; -const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [ - "disableGamescopeWsi", - "disableHdr", - "disableSteamdeckMode", - "disableVkbasalt", - "enableZink", -]; -const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI"; -const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI"; -const WORKAROUND_ENV_KEYS = new Set( - BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), -); -const MANAGED_ENV_KEYS = new Set([ - ...DXVK_MANAGED_KEYS, - ...WORKAROUND_ENV_KEYS, -]); - -const EMPTY_WORKAROUND_STATE: WorkaroundState = { - dxvkFrameRate: 0, - disableGamescopeWsi: false, - disableHdr: false, - disableSteamdeckMode: false, - disableVkbasalt: false, - enableZink: false, -}; -const DEFAULT_WORKAROUND_STATE: WorkaroundState = { - ...EMPTY_WORKAROUND_STATE, - disableGamescopeWsi: true, - disableHdr: true, -}; - -export function getDefaultWorkaroundState(): WorkaroundState { - return { ...DEFAULT_WORKAROUND_STATE }; -} - -function decodeToken(raw: string): string { - let value = ""; - let quote: "'" | '"' | null = null; - - for (let index = 0; index < raw.length; index += 1) { - const character = raw[index]; - if (character === "\\" && quote !== "'" && index + 1 < raw.length) { - value += raw[index + 1]; - index += 1; - } else if (quote !== null) { - if (character === quote) quote = null; - else value += character; - } else if (character === "'" || character === '"') { - quote = character; - } else { - value += character; - } - } - - return value; -} - -function tokenize(options: string): LaunchToken[] { - const tokens: LaunchToken[] = []; - let start = -1; - let quote: "'" | '"' | null = null; - let escaped = false; - - for (let index = 0; index < options.length; index += 1) { - const character = options[index]; - if (start < 0) { - if (/\s/.test(character)) continue; - start = index; - } - - if (escaped) { - escaped = false; - } else if (character === "\\" && quote !== "'") { - escaped = true; - } else if (quote !== null) { - if (character === quote) quote = null; - } else if (character === "'" || character === '"') { - quote = character; - } else if (/\s/.test(character)) { - const raw = options.slice(start, index); - tokens.push({ raw, value: decodeToken(raw) }); - start = -1; - } - } - - if (start >= 0) { - const raw = options.slice(start); - tokens.push({ raw, value: decodeToken(raw) }); - } - return tokens; -} - -function serialize(tokens: readonly LaunchToken[]): string { - if (tokens.length === 1 && tokens[0].raw.toLowerCase() === COMMAND_TOKEN) return ""; - return tokens.map((token) => token.raw).join(" "); -} - -export function normalizeLaunchOptions(options: string): string { - return serialize(tokenize(options)); -} - -function parseEnvironmentToken(token: LaunchToken): [string, string] | null { - const separator = token.value.indexOf("="); - if (separator < 1) return null; - const key = token.value.slice(0, separator); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null; - return [key, token.value.slice(separator + 1)]; -} - -function findCommandIndex(tokens: readonly LaunchToken[]): number { - return tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); -} - -function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number { - let count = 0; - while (count < tokens.length && parseEnvironmentToken(tokens[count]) !== null) count += 1; - return count; -} - -function effectivePrefixLimit(tokens: readonly LaunchToken[]): number { - return leadingEnvironmentCount(tokens); -} - -function effectiveEnvironmentEntries(tokens: readonly LaunchToken[]): Map { - const entries = new Map(); - for (let index = 0; index < effectivePrefixLimit(tokens); index += 1) { - const parsed = parseEnvironmentToken(tokens[index]); - if (!parsed) continue; - const [key, value] = parsed; - const previous = entries.get(key); - entries.set(key, { value, count: (previous?.count || 0) + 1 }); - } - return entries; -} - -function removePrefixAssignments(tokens: LaunchToken[], predicate: (token: LaunchToken) => boolean): boolean { - const limit = effectivePrefixLimit(tokens); - const retained = tokens.filter((token, index) => index >= limit || !predicate(token)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -function removeAllAssignments(tokens: LaunchToken[], keys: ReadonlySet): boolean { - return removePrefixAssignments(tokens, (token) => { - const parsed = parseEnvironmentToken(token); - return parsed !== null && keys.has(parsed[0]); - }); -} - -function encodeEnvironmentValue(value: string): string { - if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function insertEnvironmentSpecs(tokens: LaunchToken[], specs: readonly EnvironmentSpec[]): void { - tokens.unshift(...specs.map(([key, value]) => ({ - raw: `${key}=${encodeEnvironmentValue(value)}`, - value: `${key}=${value}`, - }))); -} - -function ensureCommandToken(tokens: LaunchToken[]): void { - if (findCommandIndex(tokens) >= 0) return; - tokens.splice(leadingEnvironmentCount(tokens), 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); -} - -export function isLegacyWrapperToken(value: string): boolean { - const path = decodeToken(value); - return LEGACY_WRAPPER_TOKENS.has(path); -} - -function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean { - const commandIndex = findCommandIndex(tokens); - const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length; - const retained = tokens.filter((token, index) => index >= prefixEnd || !isLegacyWrapperToken(token.raw)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -interface DxvkConfigAssignment { - values: string[]; - malformed: number; -} - -interface ParsedDxvkConfig { - segments: string[]; - assignments: Map; -} - -function splitDxvkConfig(value: string): string[] { - const segments: string[] = []; - let start = 0; - let quote: "'" | '"' | null = null; - let escaped = false; - - for (let index = 0; index < value.length; index += 1) { - const character = value[index]; - if (escaped) escaped = false; - else if (character === "\\" && quote !== "'") escaped = true; - else if (quote !== null) { - if (character === quote) quote = null; - } else if (character === "'" || character === '"') quote = character; - else if (character === ";") { - segments.push(value.slice(start, index)); - start = index + 1; - } - } - - segments.push(value.slice(start)); - return segments; -} - -function knownDxvkKey(value: string): DxvkFrameRateKey | null { - const key = value.match(/^([A-Za-z][A-Za-z0-9.]*)/)?.[1]; - return key && DXVK_FRAME_RATE_KEYS.includes(key as DxvkFrameRateKey) - ? key as DxvkFrameRateKey - : null; -} - -function parseDxvkConfig(value: string): ParsedDxvkConfig { - const segments = splitDxvkConfig(value); - const assignments = new Map(); - for (const segment of segments) { - const trimmed = segment.trim(); - const key = knownDxvkKey(trimmed); - if (!key) continue; - const match = trimmed.match(/^[A-Za-z][A-Za-z0-9.]*\s*=\s*(.*?)\s*$/); - const entry = assignments.get(key) || { values: [], malformed: 0 }; - if (match) entry.values.push(match[1]); - else entry.malformed += 1; - assignments.set(key, entry); - } - return { segments, assignments }; -} - -function isDxvkFrameRateSegment(segment: string): boolean { - return knownDxvkKey(segment.trim()) !== null; -} - -function parseSupportedFrameRate(value: string): number | null { - if (!/^\d+$/.test(value)) return null; - const numericValue = Number(value); - return Number.isSafeInteger(numericValue) && numericValue <= 60 ? numericValue : null; -} - -function rewriteDxvkFrameRate(tokens: LaunchToken[], frameRate: number): void { - const config = effectiveEnvironmentEntries(tokens).get("DXVK_CONFIG"); - const parsed = parseDxvkConfig(config?.value || ""); - const retained = parsed.segments - .filter((segment) => !isDxvkFrameRateSegment(segment)) - .filter((segment) => segment.trim().length > 0) - .join(";"); - const nextConfig = frameRate > 0 - ? [`dxvk.maxFrameRate = ${frameRate}`, ...(retained ? [retained] : [])].join(";") - : retained; - - removeAllAssignments(tokens, DXVK_MANAGED_KEYS); - if (nextConfig) { - ensureCommandToken(tokens); - insertEnvironmentSpecs(tokens, [["DXVK_CONFIG", nextConfig]]); - } -} - -function environmentSpecsForState(state: WorkaroundState): EnvironmentSpec[] { - return BOOLEAN_WORKAROUND_FIELDS - .filter((field) => state[field]) - .map((field) => WORKAROUND_DEFINITIONS[field].spec); -} - -function validateFrameRate(frameRate: number): void { - if (!Number.isInteger(frameRate) || frameRate < 0 || frameRate > 60) { - throw new Error("Base FPS Cap must be an integer from 0 to 60"); - } -} - -function readBoolean( - entries: Map, - key: string, - label: string, - trueValue: string, - issues: string[], -): boolean { - const entry = entries.get(key); - if (!entry) return false; - const falseValue = trueValue === "1" ? "0" : "1"; - if (entry.value === trueValue) return true; - if (entry.value === falseValue) return false; - issues.push(`${label} has an unsupported value.`); - return false; -} - -export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions { - const tokens = tokenize(options); - const entries = effectiveEnvironmentEntries(tokens); - const state = { ...EMPTY_WORKAROUND_STATE }; - const issues: string[] = []; - - for (const [key, entry] of entries) { - if (MANAGED_ENV_KEYS.has(key) && entry.count > 1) { - issues.push(`${key} appears more than once; Steam uses the last value.`); - } - } - - const dxvkConfig = parseDxvkConfig(entries.get("DXVK_CONFIG")?.value || ""); - const effectiveDxvkValues = new Map(); - for (const key of DXVK_FRAME_RATE_KEYS) { - const assignment = dxvkConfig.assignments.get(key); - if (!assignment) continue; - if (assignment.malformed > 0) issues.push(`${key} in DXVK_CONFIG is malformed.`); - if (assignment.values.length > 1) { - issues.push(`${key} appears more than once in DXVK_CONFIG; DXVK uses the last value.`); - } - if (assignment.values.length === 0) continue; - const value = parseSupportedFrameRate(assignment.values[assignment.values.length - 1]); - effectiveDxvkValues.set(key, value); - if (value === null) issues.push(`${key} in DXVK_CONFIG is outside the supported 0-60 range.`); - } - - const unifiedFrameRate = effectiveDxvkValues.get("dxvk.maxFrameRate"); - const dxgiFrameRate = effectiveDxvkValues.get("dxgi.maxFrameRate"); - const d3d9FrameRate = effectiveDxvkValues.get("d3d9.maxFrameRate"); - if (unifiedFrameRate !== undefined) { - if (unifiedFrameRate !== null) state.dxvkFrameRate = unifiedFrameRate; - } else if (dxgiFrameRate !== undefined && d3d9FrameRate !== undefined) { - if (dxgiFrameRate !== null && dxgiFrameRate === d3d9FrameRate) state.dxvkFrameRate = dxgiFrameRate; - else issues.push("DXVK_CONFIG has conflicting or invalid DirectX frame caps."); - } else if (dxgiFrameRate !== undefined || d3d9FrameRate !== undefined) { - const partial = dxgiFrameRate ?? d3d9FrameRate; - if (partial !== null && partial !== undefined) state.dxvkFrameRate = partial; - issues.push("DXVK_CONFIG only caps one DirectX API; adjust the cap to normalize it."); - } - - if (entries.has("DXVK_FRAME_RATE")) { - issues.push("DXVK_FRAME_RATE is obsolete on current DXVK; adjust the cap to migrate it."); - } - - const wsiSignals: boolean[] = []; - const wsiDisable = entries.get(WSI_DISABLE_KEY); - if (wsiDisable) { - if (wsiDisable.value !== "0" && wsiDisable.value !== "1") issues.push("Disable Gamescope WSI has an unsupported value."); - else wsiSignals.push(wsiDisable.value === "1"); - } - const wsiEnable = entries.get(WSI_ENABLE_KEY); - if (wsiEnable) { - if (wsiEnable.value !== "0" && wsiEnable.value !== "1") issues.push("Enable Gamescope WSI has an unsupported value."); - else wsiSignals.push(wsiEnable.value === "0"); - } - if (wsiSignals.length > 0) { - if (wsiSignals.length === 2 && wsiSignals[0] !== wsiSignals[1]) { - issues.push("Gamescope WSI has conflicting enable and disable assignments."); - } - state.disableGamescopeWsi = wsiSignals.some(Boolean); - } - - for (const field of ["disableHdr", "disableSteamdeckMode", "disableVkbasalt"] as const) { - const { spec, label } = WORKAROUND_DEFINITIONS[field]; - state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues); - } - - const vkBasaltEnable = entries.get("ENABLE_VKBASALT"); - const vkBasaltDisable = entries.get("DISABLE_VKBASALT"); - if (vkBasaltEnable?.value === "1" && vkBasaltDisable?.value === "1") { - issues.push("vkBasalt has conflicting enable and disable assignments."); - } - - const zink = entries.get(WORKAROUND_DEFINITIONS.enableZink.spec[0]); - const glxVendor = entries.get("__GLX_VENDOR_LIBRARY_NAME"); - const galliumDriver = entries.get("GALLIUM_DRIVER"); - const hasLegacyZink = glxVendor !== undefined || galliumDriver !== undefined; - if (zink || hasLegacyZink) { - state.enableZink = zink?.value === WORKAROUND_DEFINITIONS.enableZink.spec[1]; - if (hasLegacyZink && ( - glxVendor?.value !== "mesa" || - zink?.value !== WORKAROUND_DEFINITIONS.enableZink.spec[1] || - galliumDriver?.value !== "zink" - )) { - issues.push("Zink workaround is only partially configured."); - } else if (!state.enableZink) { - issues.push("Zink workaround has an unsupported driver value."); - } - } - - return { state, issues }; -} - -export function applyWorkaroundState(options: string, state: WorkaroundState): string { - validateFrameRate(state.dxvkFrameRate); - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - rewriteDxvkFrameRate(tokens, state.dxvkFrameRate); - const keysToClear = new Set( - WORKAROUND_ENV_KEYS, - ); - if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT"); - removeAllAssignments(tokens, keysToClear); - const specs = environmentSpecsForState(state); - if (specs.length > 0) { - ensureCommandToken(tokens); - insertEnvironmentSpecs(tokens, specs); - } - return serialize(tokens); -} - -export function applyWorkaroundChange(options: string, field: WorkaroundField, value: boolean | number): string { - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - - if (field === "dxvkFrameRate") { - if (typeof value !== "number") throw new Error("Base FPS Cap must be an integer from 0 to 60"); - validateFrameRate(value); - rewriteDxvkFrameRate(tokens, value); - return serialize(tokens); - } - - if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`); - const definition = WORKAROUND_DEFINITIONS[field]; - const keysToClear = new Set(definition.clear); - if (value && field === "disableVkbasalt") keysToClear.add("ENABLE_VKBASALT"); - removeAllAssignments(tokens, keysToClear); - if (value) { - ensureCommandToken(tokens); - insertEnvironmentSpecs(tokens, [definition.spec]); - } - return serialize(tokens); -} - -export function cleanupLegacyLaunchOptions(options: string): string { - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - return serialize(tokens); -} - -export function cleanupPluginLaunchOptions(options: string): string { - const tokens = tokenize(options); - removeLegacyWrapperFromTokens(tokens); - rewriteDxvkFrameRate(tokens, 0); - removeAllAssignments(tokens, WORKAROUND_ENV_KEYS); - return serialize(tokens); -} - -export function cleanupLegacyWrapper(options: string): string { - return cleanupLegacyLaunchOptions(options); -} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 82ff94b..e00b32d 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,16 +1,52 @@ -// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyLaunchOptions, cleanupPluginLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +const DEFAULT_WRAPPER_PATH = "~/.lsfg"; +const COMMAND_TOKEN = "%command%"; -// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -export * from "./steamLaunchOptionParser.ts"; +export const LEGACY_WRAPPER_TOKENS = new Set([ + "~/lsfg", + "~/.local/bin/lsfg", + "~/.local/bin/lsfg-vk-experimental", + "~/.local/bin/mako-run", + "mako-run", + "~/.local/bin/mako-launch", + "mako-launch", +]); + +const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/; + +const MANAGED_ENV_KEYS = new Set([ + "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", +]); + +const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i; + +interface LaunchToken { + raw: string; + value: string; +} export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; + target: string; details: SteamAppDetails; } +export interface WrapperIntegrationResult { + snapshot: SteamLaunchOptionsSnapshot; + originalExecutable?: string; + commandTokenAdded: boolean; +} + function validateAppId(appId: number): void { if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } @@ -21,14 +57,30 @@ function getSteamApps(): Partial | undefined { }).SteamClient?.Apps; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { - if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) { - throw new Error("The shortcut Target still points to a legacy frame-generation wrapper; restore its original executable first"); +interface TimerHost { + setTimeout(handler: () => void, timeout: number): number; + clearTimeout(timeout: number): void; +} + +function timerHost(): TimerHost { + if (typeof window !== "undefined") { + return { + setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), + clearTimeout: (timeout) => window.clearTimeout(timeout), + }; } + return { + setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, + clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + }; +} + +function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { return { appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", + target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -44,7 +96,7 @@ function registerSteamAppDetails( validateAppId(appId); const apps = getSteamApps(); const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable"); + if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); let active = true; let unregisterPending = false; @@ -58,7 +110,7 @@ function registerSteamAppDetails( try { registration.unregister(); } catch { - // Steam may invalidate registrations during a details refresh. + // Steam can invalidate a registration while details are refreshing. } }; @@ -71,7 +123,7 @@ function registerSteamAppDetails( try { registration.unregister(); } catch { - // The registration can be invalidated before a synchronous callback returns. + // A synchronous callback can invalidate the registration before return. } } } catch (error) { @@ -83,25 +135,21 @@ function registerSteamAppDetails( export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise { return new Promise((resolve, reject) => { let settled = false; - let timeout = 0; + let timeout: number | undefined; let unsubscribe = () => {}; const finish = (error?: unknown, details?: SteamAppDetails) => { if (settled) return; settled = true; - window.clearTimeout(timeout); + if (timeout !== undefined) timerHost().clearTimeout(timeout); unsubscribe(); if (error) { reject(asError(error)); return; } - try { - resolve(snapshotFromDetails(appId, nonSteam, details || {})); - } catch (snapshotError) { - reject(asError(snapshotError)); - } + resolve(snapshotFromDetails(appId, nonSteam, details || {})); }; - timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000); + timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { unsubscribe = registerSteamAppDetails(appId, (details) => { finish(undefined, details); @@ -128,6 +176,224 @@ export function subscribeSteamLaunchOptions( }); } +function decodeToken(raw: string): string { + let value = ""; + let quote: "'" | '"' | null = null; + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === "\\" && quote !== "'" && index + 1 < raw.length) { + value += raw[index + 1]; + index += 1; + } else if (quote !== null) { + if (character === quote) quote = null; + else value += character; + } else if (character === "'" || character === '"') { + quote = character; + } else { + value += character; + } + } + return value; +} + +function tokenize(options: string): LaunchToken[] { + const tokens: LaunchToken[] = []; + let start = -1; + let quote: "'" | '"' | null = null; + let escaped = false; + for (let index = 0; index < options.length; index += 1) { + const character = options[index]; + if (start < 0) { + if (/\s/.test(character)) continue; + start = index; + } + if (escaped) escaped = false; + else if (character === "\\" && quote !== "'") escaped = true; + else if (quote !== null) { + if (character === quote) quote = null; + } else if (character === "'" || character === '"') quote = character; + else if (/\s/.test(character)) { + const raw = options.slice(start, index); + tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + } + } + if (start >= 0) { + const raw = options.slice(start); + tokens.push({ raw, value: decodeToken(raw) }); + } + return tokens; +} + +function serialize(tokens: readonly LaunchToken[]): string { + return tokens.map((token) => token.raw).join(" "); +} + +export function normalizeLaunchOptions(options: string): string { + return serialize(tokenize(options)); +} + +function isCommandToken(token: LaunchToken): boolean { + return token.raw.toLowerCase() === COMMAND_TOKEN; +} + +function commandIndex(tokens: readonly LaunchToken[]): number { + return tokens.findIndex(isCommandToken); +} + +function isAssignment(token: LaunchToken): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); +} + +function isLegacyToken(value: string): boolean { + return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); +} + +export function isLegacyWrapperToken(value: string): boolean { + return isLegacyToken(decodeToken(value)); +} + +function isWrapperToken(value: string, wrapperPath: string): boolean { + return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); +} + +function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean { + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function removeLegacyTokens(tokens: LaunchToken[]): boolean { + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value)); + if (retained.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...retained); + return true; +} + +function leadingAssignments(tokens: readonly LaunchToken[]): number { + let count = 0; + while (count < tokens.length && isAssignment(tokens[count])) count += 1; + return count; +} + +function wrapperToken(wrapperPath: string): LaunchToken { + return { raw: wrapperPath, value: wrapperPath }; +} + +export interface LaunchOptionRewrite { + options: string; + commandTokenAdded: boolean; +} + +/** Add one exact wrapper token immediately before Steam's command macro. */ +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite { + const tokens = tokenize(options); + removeLegacyTokens(tokens); + let index = commandIndex(tokens); + if (index >= 0) { + const currentWrapper = tokens[index - 1]; + if (currentWrapper && currentWrapper.value === wrapperPath) { + return { options: serialize(tokens), commandTokenAdded: false }; + } + const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath); + tokens.splice(0, tokens.length, ...retained); + index = commandIndex(tokens); + tokens.splice(index, 0, wrapperToken(wrapperPath)); + return { options: serialize(tokens), commandTokenAdded: false }; + } + + const insertion = leadingAssignments(tokens); + const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); + if (tokens.length !== insertion && !argumentsOnly) { + throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); + } + tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + return { options: serialize(tokens), commandTokenAdded: true }; +} + +/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */ +export function removeWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + commandTokenAdded = false, +): string { + const tokens = tokenize(options); + const removed = removeWrapperTokens(tokens, wrapperPath); + if (removed && commandTokenAdded) { + const index = commandIndex(tokens); + if (index >= 0) tokens.splice(index, 1); + } + return serialize(tokens); +} + +function encodeAssignmentValue(value: string): string { + if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function cleanDxvkConfigValue(value: string): string | null { + const retained = value + .split(";") + .map((segment) => segment.trim()) + .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment)); + return retained.length > 0 ? retained.join("; ") : null; +} + +/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */ +export function cleanupPluginAssignments(options: string): string { + const tokens = tokenize(options); + const index = commandIndex(tokens); + const prefixEnd = index >= 0 ? index : tokens.length; + const retained: LaunchToken[] = []; + for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { + const token = tokens[tokenIndex]; + if (tokenIndex >= prefixEnd || !isAssignment(token)) { + retained.push(token); + continue; + } + const separator = token.value.indexOf("="); + const key = token.value.slice(0, separator); + if (key === "DXVK_CONFIG") { + const cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); + if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); + continue; + } + if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); + } + return serialize(retained); +} + +export function cleanupLegacyLaunchOptions(options: string): string { + const tokens = tokenize(options); + removeLegacyTokens(tokens); + return serialize(tokens); +} + +export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { + const tokens = tokenize(options); + removeWrapperTokens(tokens, wrapperPath); + return cleanupPluginAssignments(serialize(tokens)); +} + +export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { + return cleanupPluginLaunchOptions(options, wrapperPath); +} + +export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { + const tokens = tokenize(options); + const index = commandIndex(tokens); + return index > 0 && tokens[index - 1].value === wrapperPath; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); +} + async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise { const apps = getSteamApps(); const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; @@ -135,49 +401,96 @@ async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: await Promise.resolve(setter.call(apps, appId, options)); } -function delay(milliseconds: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +async function setShortcutExecutable(appId: number, executable: string): Promise { + const apps = getSteamApps(); + if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); + await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); } -async function waitForLaunchOptions( +async function waitForSnapshot( appId: number, nonSteam: boolean, - expected: string, + matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean, + message: string, ): Promise { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { const snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot; + if (matches(snapshot)) return snapshot; } catch (error) { lastError = asError(error); } if (Date.now() >= deadline) break; await delay(100); } - if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`); - throw new Error("Steam did not accept the launch options before the readback timeout"); + if (lastError) throw new Error(`${message}: ${lastError.message}`); + throw new Error(`${message} before the readback timeout`); } -const operationQueues = new Map>(); +async function writeLaunchOptionsAndVerify( + appId: number, + nonSteam: boolean, + previous: string, + next: string, + message: string, +): Promise { + try { + await setSteamLaunchOptions(appId, nonSteam, next); + return await waitForSnapshot( + appId, + nonSteam, + (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next), + message, + ); + } catch (error) { + const failure = asError(error); + try { + await setSteamLaunchOptions(appId, nonSteam, previous); + await waitForSnapshot( + appId, + nonSteam, + (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous), + "Steam did not restore the previous launch options", + ); + } catch (rollbackError) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + } + throw failure; + } +} -function queueKey(appId: number, nonSteam: boolean): string { - return `${nonSteam ? "shortcut" : "app"}:${appId}`; +async function writeShortcutExecutableAndVerify( + appId: number, + previous: string, + next: string, + message: string, +): Promise { + try { + await setShortcutExecutable(appId, next); + return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + } catch (error) { + const failure = asError(error); + try { + await setShortcutExecutable(appId, previous); + await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target"); + } catch (rollbackError) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + } + throw failure; + } } -function queueSteamAppOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { - const key = queueKey(appId, nonSteam); +const operationQueues = new Map>(); + +function queueSteamOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; const previous = operationQueues.get(key) || Promise.resolve(); const queued = previous.catch(() => undefined).then(operation); - let cleanup: Promise; - cleanup = queued.then( - () => { - if (operationQueues.get(key) === cleanup) operationQueues.delete(key); - }, - () => { - if (operationQueues.get(key) === cleanup) operationQueues.delete(key); - }, + const cleanup = queued.then( + () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, + () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, ); operationQueues.set(key, cleanup); return queued; @@ -188,37 +501,96 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); }); } -export function cleanupSteamLaunchOptions( +export function installWrapperIntegration( appId: number, nonSteam: boolean, + wrapperPath: string, + commandTokenAdded = false, +): Promise { + return queueSteamOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + if (nonSteam) { + if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); + if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { + throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); + } + const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleanedOptions !== current.options) { + await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options"); + } + if (current.target === wrapperPath) { + return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false }; + } + const originalExecutable = current.target; + const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target"); + return { snapshot, originalExecutable, commandTokenAdded: false }; + } + + const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); + const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); + const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); + if (rewrite.options === current.options) { + return { snapshot: current, commandTokenAdded }; + } + const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options"); + return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + }); +} + +export function removeWrapperIntegration( + appId: number, + nonSteam: boolean, + wrapperPath: string, + originalExecutable?: string, + commandTokenAdded = false, ): Promise { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options); + if (nonSteam) { + if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } + if (current.target !== wrapperPath && current.target !== originalExecutable) { + throw new Error("Shortcut Target changed externally; refusing to restore it"); + } + const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleaned !== current.options) { + await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); + } + if (current.target === originalExecutable) { + return readSteamLaunchOptions(appId, true); + } + return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target"); + } + + const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); + const next = cleanupPluginAssignments(withoutWrapper); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); }); } export function cleanupLegacySteamLaunchOptions( appId: number, nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, ): Promise { - return queueSteamAppOperation(appId, nonSteam, async () => { + return queueSteamOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupLegacyLaunchOptions(current.options); + const next = cleanupPluginLaunchOptions(current.options, wrapperPath); if (next === current.options) return current; - await setSteamLaunchOptions(appId, nonSteam, next); - return waitForLaunchOptions(appId, nonSteam, next); + return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); }); } + +export function getDefaultWrapperPath(): string { + return DEFAULT_WRAPPER_PATH; +} diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index c4dede3..3170fe8 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -1,342 +1,138 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - applyWorkaroundChange, - applyWorkaroundState, - cleanupLegacyLaunchOptions, + cleanupPluginAssignments, cleanupPluginLaunchOptions, cleanupLegacyWrapper, - getDefaultWorkaroundState, + hasWrapperLaunchIntegration, + installWrapperIntegration, + installWrapperLaunchOption, isLegacyWrapperToken, normalizeLaunchOptions, - parseWorkaroundOptions, readSteamLaunchOptions, - updateSteamLaunchOptions, + removeWrapperIntegration, + removeWrapperLaunchOption, } from "../src/utils/steamLaunchOptions.ts"; -test("maps the supported workarounds to current launch variables", () => { - const options = applyWorkaroundState('gamemoderun %command% --profile "high quality"', { - dxvkFrameRate: 30, - disableGamescopeWsi: true, - disableHdr: true, - disableSteamdeckMode: true, - disableVkbasalt: true, - enableZink: true, - }); +const wrapper = "~/.lsfg"; - assert.equal( - options, - 'ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"', - ); - assert.deepEqual(parseWorkaroundOptions(options), { - state: { - dxvkFrameRate: 30, - disableGamescopeWsi: true, - disableHdr: true, - disableSteamdeckMode: true, - disableVkbasalt: true, - enableZink: true, - }, - issues: [], +test("inserts one wrapper immediately before an existing command macro", () => { + assert.deepEqual(installWrapperLaunchOption('gamemoderun %command% --profile "high quality"', wrapper), { + options: 'gamemoderun ~/.lsfg %command% --profile "high quality"', + commandTokenAdded: false, + }); + assert.equal(hasWrapperLaunchIntegration(`gamemoderun ${wrapper} %command%`, wrapper), true); + assert.deepEqual(installWrapperLaunchOption(`gamemoderun ${wrapper} %command%`, wrapper), { + options: `gamemoderun ${wrapper} %command%`, + commandTokenAdded: false, }); }); -test("uses SteamDeck=0 before %command% without a wrapper", () => { - assert.equal( - applyWorkaroundChange("", "disableSteamdeckMode", true), - "SteamDeck=0 %command%", - ); -}); - -test("defaults new profiles to disable Gamescope WSI and HDR", () => { - const defaults = getDefaultWorkaroundState(); - assert.equal(defaults.disableGamescopeWsi, true); - assert.equal(defaults.disableHdr, true); - assert.equal( - applyWorkaroundState("%command%", defaults), - "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", - ); - assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false); - assert.equal(parseWorkaroundOptions("%command%").state.disableHdr, false); - assert.equal( - parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableGamescopeWsi, - true, - ); - assert.equal( - parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableHdr, - true, - ); - assert.equal( - applyWorkaroundChange("%command%", "disableGamescopeWsi", true), - "ENABLE_GAMESCOPE_WSI=0 %command%", - ); - assert.equal( - applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 %command%", "disableGamescopeWsi", false), - "", - ); - - const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%"); - assert.equal(invalid.state.disableGamescopeWsi, false); - assert.equal(invalid.issues.length, 1); - const conflicting = parseWorkaroundOptions("DISABLE_GAMESCOPE_WSI=1 ENABLE_GAMESCOPE_WSI=1 %command%"); - assert.equal(conflicting.state.disableGamescopeWsi, true); - assert.match(conflicting.issues.join(" "), /conflicting/); -}); - -test("manages DXVK HDR independently from Gamescope WSI", () => { - assert.equal( - applyWorkaroundChange("%command%", "disableHdr", true), - "DXVK_HDR=0 %command%", - ); - assert.equal(parseWorkaroundOptions("DXVK_HDR=0 %command%").state.disableHdr, true); - assert.equal(parseWorkaroundOptions("DXVK_HDR=1 %command%").state.disableHdr, false); - assert.equal( - applyWorkaroundChange("DXVK_HDR=0 %command%", "disableHdr", false), - "", - ); - assert.equal( - applyWorkaroundChange("DXVK_HDR=0 %command%", "disableGamescopeWsi", true), - "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", - ); - - const invalid = parseWorkaroundOptions("DXVK_HDR=maybe %command%"); - assert.equal(invalid.state.disableHdr, false); - assert.match(invalid.issues.join(" "), /Disable HDR has an unsupported value/); -}); - -test("preserves unrelated prefixes, quoted tokens, suffix arguments, and dropped variables", () => { - const options = applyWorkaroundChange( - 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"', - "disableSteamdeckMode", - true, - ); - assert.equal( - options, - 'SteamDeck=0 PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"', - ); - assert.deepEqual(parseWorkaroundOptions(options).issues, []); - - assert.equal( - applyWorkaroundChange("FOO=bar --flag", "disableSteamdeckMode", true), - "SteamDeck=0 FOO=bar %command% --flag", - ); - assert.equal( - applyWorkaroundChange("FOO=1 %command% MANGOHUD=1", "disableSteamdeckMode", false), - "FOO=1 %command% MANGOHUD=1", - ); - assert.equal( - applyWorkaroundChange('FOO=bar --literal "%command%"', "disableSteamdeckMode", true), - 'SteamDeck=0 FOO=bar %command% --literal "%command%"', - ); - assert.equal( - applyWorkaroundChange("gamemoderun SteamDeck=1 %command%", "disableSteamdeckMode", true), - "SteamDeck=0 gamemoderun SteamDeck=1 %command%", - ); - assert.equal(parseWorkaroundOptions("gamemoderun SteamDeck=0 %command%").state.disableSteamdeckMode, false); -}); - -test("uses DXVK_CONFIG for the base cap and preserves other DXVK settings", () => { - assert.equal( - applyWorkaroundChange("%command%", "dxvkFrameRate", 60), - 'DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%', - ); - assert.equal(parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%').state.dxvkFrameRate, 60); - assert.equal( - applyWorkaroundChange( - 'DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%', - "dxvkFrameRate", - 0, - ), - 'DXVK_CONFIG="dxgi.syncInterval = 0" %command%', - ); - assert.equal( - applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 45), - 'DXVK_CONFIG="dxvk.maxFrameRate = 45" %command%', - ); - assert.equal( - applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 0), - "", - ); - - const apiSpecific = parseWorkaroundOptions( - 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 30" %command%', - ); - assert.equal(apiSpecific.state.dxvkFrameRate, 30); - assert.deepEqual(apiSpecific.issues, []); - const partial = parseWorkaroundOptions('DXVK_CONFIG="dxgi.maxFrameRate = 30" %command%'); - assert.equal(partial.state.dxvkFrameRate, 30); - assert.match(partial.issues.join(" "), /only caps one DirectX API/); - const conflicting = parseWorkaroundOptions( - 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 60" %command%', - ); - assert.equal(conflicting.state.dxvkFrameRate, 0); - assert.match(conflicting.issues.join(" "), /conflicting/); -}); - -test("reports invalid and malformed FPS values instead of treating them as off", () => { - const invalid = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 61" %command%'); - assert.equal(invalid.state.dxvkFrameRate, 0); - assert.match(invalid.issues.join(" "), /outside the supported 0-60 range/); - const malformed = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate" %command%'); - assert.equal(malformed.state.dxvkFrameRate, 0); - assert.match(malformed.issues.join(" "), /malformed/); - const obsolete = parseWorkaroundOptions("DXVK_FRAME_RATE=wat %command%"); - assert.equal(obsolete.state.dxvkFrameRate, 0); - assert.match(obsolete.issues.join(" "), /obsolete/); - assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 61), /0 to 60/); - assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 1.5), /0 to 60/); -}); - -test("keeps vkBasalt disable mutually exclusive while preserving the dropped enable flag otherwise", () => { - assert.equal( - applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableSteamdeckMode", true), - "SteamDeck=0 ENABLE_VKBASALT=1 %command%", - ); - const disabled = applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableVkbasalt", true); - assert.equal(disabled, "DISABLE_VKBASALT=1 %command%"); - assert.equal( - applyWorkaroundChange(disabled, "disableVkbasalt", false), - "", - ); - const conflict = parseWorkaroundOptions("ENABLE_VKBASALT=1 DISABLE_VKBASALT=1 %command%"); - assert.equal(conflict.state.disableVkbasalt, true); - assert.match(conflict.issues.join(" "), /conflicting/); +test("normalizes blank and argument-only fields while refusing ambiguous launchers", () => { + assert.deepEqual(installWrapperLaunchOption("", wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption("FOO=bar --windowed", wrapper), { + options: `FOO=bar ${wrapper} %command% --windowed`, + commandTokenAdded: true, + }); + assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/); + assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); }); -test("handles current and legacy Zink forms and reports partial state", () => { - const enabled = applyWorkaroundChange("%command%", "enableZink", true); - assert.equal(enabled, "MESA_LOADER_DRIVER_OVERRIDE=zink %command%"); - assert.equal(parseWorkaroundOptions(enabled).state.enableZink, true); - - const legacy = parseWorkaroundOptions( - "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%", - ); - assert.equal(legacy.state.enableZink, true); - assert.deepEqual(legacy.issues, []); - - const partial = parseWorkaroundOptions("__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink %command%"); - assert.equal(partial.state.enableZink, true); - assert.match(partial.issues.join(" "), /partially configured/); +test("preserves assignments, quoting, suffixes, and unrelated values", () => { + const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"'; assert.equal( - applyWorkaroundChange( - "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%", - "enableZink", - false, - ), - "", + installWrapperLaunchOption(options, wrapper).options, + 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun ~/.lsfg %command% --flag "two words"', ); + assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`); + assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag"); }); -test("cleans only the known legacy wrapper and preserves launch options", () => { - assert.equal( - cleanupLegacyWrapper('FOO=bar ~/lsfg %command% --arg "~/lsfg"'), - 'FOO=bar %command% --arg "~/lsfg"', - ); - assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), ""); - assert.equal(cleanupLegacyWrapper("mako-run %command%"), ""); - assert.equal(cleanupLegacyWrapper("mako-launch %command%"), ""); - assert.equal( - cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"), - "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%", - ); - assert.equal( - cleanupLegacyWrapper("LSFG_PROCESS=decky-lsfg-vk %command%"), - "LSFG_PROCESS=decky-lsfg-vk %command%", - ); - assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false); +test("cleans current, legacy, and bare Mako wrappers without touching suffix arguments", () => { + for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) { + assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`); + } + assert.equal(cleanupLegacyWrapper(`FOO=bar ${wrapper} %command%`), "FOO=bar %command%"); + assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true); + assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false); + assert.equal(removeWrapperLaunchOption(`FOO=bar ${wrapper} %command% --arg`, wrapper), "FOO=bar %command% --arg"); }); -test("removes plugin-managed launch options when a profile is removed", () => { +test("removes only old plugin assignments and preserves DXVK settings", () => { assert.equal( - cleanupPluginLaunchOptions( - 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" ~/lsfg %command% --windowed', + cleanupPluginAssignments( + 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%', ), - 'DXVK_CONFIG="dxgi.syncInterval = 0" FOO="keep this" %command% --windowed', + 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%', ); assert.equal( - cleanupPluginLaunchOptions( - 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%', - ), - 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%', + cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), + "%command%", ); assert.equal( - cleanupPluginLaunchOptions('DXVK_CONFIG="dxvk.maxFrameRate = 30" %command%'), - "", + cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"), + "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%", ); }); -test("canonicalizes a bare command token without removing real arguments", () => { - assert.equal(normalizeLaunchOptions("%command%"), ""); - assert.equal(normalizeLaunchOptions("%COMMAND%"), ""); - assert.equal(normalizeLaunchOptions("FOO=bar %command%"), "FOO=bar %command%"); - assert.equal(normalizeLaunchOptions("%command% --windowed"), "%command% --windowed"); -}); - -test("is idempotent", () => { - const first = applyWorkaroundChange("gamemoderun %command%", "enableZink", true); - assert.equal(applyWorkaroundState(first, parseWorkaroundOptions(first).state), first); - assert.equal(applyWorkaroundChange(first, "enableZink", true), first); - const capped = applyWorkaroundChange(first, "dxvkFrameRate", 30); - assert.equal(applyWorkaroundChange(capped, "dxvkFrameRate", 30), capped); -}); - -test("reads and writes the matching Steam app-details launch-option field", async () => { +test("reads the matching app-details field and installs/removes Steam integration", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; - let normalOptions = "FOO=bar %command%"; + let appOptions = "FOO=bar %command%"; let shortcutOptions = "--windowed"; - const normalWrites: string[] = []; + let shortcutTarget = "/usr/bin/example-game"; + const appWrites: string[] = []; const shortcutWrites: string[] = []; + const targetWrites: string[] = []; const unregisters: number[] = []; - - const windowShim = { setTimeout, clearTimeout }; const apps = { RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { - if (appId === 42) { - callback({ strLaunchOptions: normalOptions, strShortcutLaunchOptions: "must-not-be-read" }); - } else { - callback({ - strShortcutExe: "/usr/bin/example-game", - strShortcutLaunchOptions: shortcutOptions, - strLaunchOptions: "must-not-be-read", - }); - } + callback(appId === 42 + ? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" } + : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); return { unregister: () => unregisters.push(appId) }; }, SetAppLaunchOptions(appId: number, options: string) { assert.equal(appId, 42); - normalWrites.push(options); - normalOptions = options.replaceAll(" ", " "); + appWrites.push(options); + appOptions = options.replaceAll(" ", " "); }, SetShortcutLaunchOptions(appId: number, options: string) { assert.equal(appId, 43); shortcutWrites.push(options); shortcutOptions = options; }, + SetShortcutExe(appId: number, executable: string) { + assert.equal(appId, 43); + targetWrites.push(executable); + shortcutTarget = executable; + }, }; - - (globalThis as Record).window = windowShim; + (globalThis as Record).window = { setTimeout, clearTimeout }; (globalThis as Record).SteamClient = { Apps: apps }; try { - const normalBefore = await readSteamLaunchOptions(42, false); - assert.equal(normalBefore.options, "FOO=bar %command%"); - const normalAfter = await updateSteamLaunchOptions( - 42, - false, - (options) => applyWorkaroundChange(options, "disableSteamdeckMode", true), - ); - assert.equal(normalWrites.length, 1); + const normal = await readSteamLaunchOptions(42, false); + assert.equal(normal.options, "FOO=bar %command%"); + const installed = await installWrapperIntegration(42, false, wrapper); + assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " ")); + assert.equal(installed.commandTokenAdded, false); + assert.equal(appWrites.length, 1); assert.equal(shortcutWrites.length, 0); - assert.equal(normalAfter.options, "SteamDeck=0 FOO=bar %command%"); - const shortcutAfter = await updateSteamLaunchOptions( - 43, - true, - (options) => applyWorkaroundChange(options, "disableGamescopeWsi", true), - ); - assert.equal(shortcutWrites.length, 1); - assert.equal(shortcutWrites[0], "ENABLE_GAMESCOPE_WSI=0 %command% --windowed"); - assert.equal(shortcutAfter.options, shortcutWrites[0]); + const shortcut = await installWrapperIntegration(43, true, wrapper); + assert.equal(shortcut.originalExecutable, "/usr/bin/example-game"); + assert.equal(shortcut.snapshot.target, wrapper); + assert.deepEqual(targetWrites, [wrapper]); + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable); + assert.equal(restored.target, "/usr/bin/example-game"); + assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]); + assert.equal(shortcutWrites.length, 0); + + const cleaned = await removeWrapperIntegration(42, false, wrapper, undefined, installed.commandTokenAdded); + assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " ")); assert.ok(unregisters.includes(42)); assert.ok(unregisters.includes(43)); } finally { @@ -346,3 +142,69 @@ test("reads and writes the matching Steam app-details launch-option field", asyn else (globalThis as Record).SteamClient = previousSteamClient; } }); + +test("fails closed when shortcut Target ownership or setters are unavailable", async () => { + const previousWindow = (globalThis as Record).window; + const previousSteamClient = (globalThis as Record).SteamClient; + (globalThis as Record).window = { setTimeout, clearTimeout }; + (globalThis as Record).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutExe: "/usr/bin/other", strShortcutLaunchOptions: "" }); + return { unregister() {} }; + }, + }, + }; + try { + await assert.rejects(installWrapperIntegration(99, true, wrapper), /Target API is unavailable/); + await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original"), /Target changed externally/); + } finally { + if (previousWindow === undefined) delete (globalThis as Record).window; + else (globalThis as Record).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record).SteamClient; + else (globalThis as Record).SteamClient = previousSteamClient; + } +}); + +test("restores launch options and shortcut Target when a setter fails after changing them", async () => { + const previousWindow = (globalThis as Record).window; + const previousSteamClient = (globalThis as Record).SteamClient; + let appOptions = "FOO=bar %command%"; + let shortcutTarget = "/usr/bin/original"; + const appWrites: string[] = []; + const targetWrites: string[] = []; + const apps = { + RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { + callback(appId === 42 + ? { strLaunchOptions: appOptions } + : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" }); + return { unregister() {} }; + }, + SetAppLaunchOptions(_appId: number, options: string) { + appWrites.push(options); + appOptions = options; + if (options.includes(wrapper)) throw new Error("simulated launch-option write failure"); + }, + SetShortcutExe(_appId: number, executable: string) { + targetWrites.push(executable); + shortcutTarget = executable; + if (executable === wrapper) throw new Error("simulated Target write failure"); + }, + }; + (globalThis as Record).window = { setTimeout, clearTimeout }; + (globalThis as Record).SteamClient = { Apps: apps }; + try { + await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch-option write failure/); + assert.equal(appOptions, "FOO=bar %command%"); + assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); + + await assert.rejects(installWrapperIntegration(43, true, wrapper), /simulated Target write failure/); + assert.equal(shortcutTarget, "/usr/bin/original"); + assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]); + } finally { + if (previousWindow === undefined) delete (globalThis as Record).window; + else (globalThis as Record).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record).SteamClient; + else (globalThis as Record).SteamClient = previousSteamClient; + } +}); diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py new file mode 100644 index 0000000..f17a944 --- /dev/null +++ b/tests/test_wrapper_service.py @@ -0,0 +1,170 @@ +import os +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.wrapper_service import WrapperService + + +class WrapperServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.service = WrapperService() + self.service.user_home = self.home + self.service.local_bin_dir = self.home / ".local/bin" + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.sidecar_path = self.service.config_dir / "workarounds.json" + self.service.wrapper_path = self.service.local_bin_dir / "lsfg" + + def tearDown(self): + self.tempdir.cleanup() + + def _state(self, **changes): + state = self.service.default_state() + state.update(changes) + return state + + def _run(self, appid, *args, env=None): + process_env = {"PATH": "/usr/bin:/bin", "SteamAppId": str(appid)} + if env: + process_env.update(env) + return subprocess.run( + [str(self.service.wrapper_path), *args], + env=process_env, + capture_output=True, + text=True, + check=True, + ) + + def test_writes_owned_dispatcher_and_validates_shell(self): + response = self.service.set("123", self._state(dxvkFrameRate=60, enableZink=True)) + self.assertTrue(response["success"]) + self.assertEqual(response["wrapper_path"], "~/.lsfg") + self.assertTrue(response["wrapper_owned"]) + self.assertEqual(response["state"]["dxvkFrameRate"], 60) + self.assertEqual(subprocess.run(["/bin/sh", "-n", str(self.service.wrapper_path)]).returncode, 0) + self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8")) + self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True)) + + def test_dispatch_clears_managed_values_preserves_other_environment_and_appends_config(self): + self.service.set( + "123", + self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True), + ) + result = self._run( + 123, + "/usr/bin/env", + env={ + "DXVK_CONFIG": "dxgi.syncInterval = 0", + "DXVK_FRAME_RATE": "5", + "ENABLE_GAMESCOPE_WSI": "1", + "DISABLE_VKBASALT": "0", + "MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe", + "MANGOHUD": "1", + }, + ) + values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0") + self.assertEqual(values["DXVK_HDR"], "0") + self.assertEqual(values["SteamDeck"], "0") + self.assertEqual(values["DISABLE_VKBASALT"], "1") + self.assertEqual(values["__GLX_VENDOR_LIBRARY_NAME"], "mesa") + self.assertEqual(values["MESA_LOADER_DRIVER_OVERRIDE"], "zink") + self.assertEqual(values["GALLIUM_DRIVER"], "zink") + self.assertEqual(values["DXVK_CONFIG"], "dxgi.syncInterval = 0; dxvk.maxFrameRate = 30") + self.assertEqual(values["MANGOHUD"], "1") + self.assertNotIn("DXVK_FRAME_RATE", values) + self.assertNotIn("ENABLE_VKBASALT", values) + + def test_appid_fallback_and_unmatched_passthrough(self): + self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False)) + self.service.set("456", self._state(disableSteamdeckMode=True)) + fallback = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/env"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "bad", "SteamGameId": "456"}, + capture_output=True, + text=True, + check=True, + ) + fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line) + self.assertEqual(fallback_values["SteamDeck"], "0") + self.assertEqual(fallback_values["SteamGameId"], "456") + + passthrough = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/env"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "999", "KEEP": "yes", "DXVK_HDR": "1"}, + capture_output=True, + text=True, + check=True, + ) + passthrough_values = dict(line.split("=", 1) for line in passthrough.stdout.splitlines() if "=" in line) + self.assertEqual(passthrough_values["KEEP"], "yes") + self.assertEqual(passthrough_values["DXVK_HDR"], "1") + + def test_flatpak_shortcut_receives_env_arguments_and_original_target(self): + fake_flatpak = self.home / ".local/bin/flatpak" + fake_flatpak.parent.mkdir(parents=True, exist_ok=True) + fake_flatpak.write_text( + "#!/bin/sh\n" + "printf 'ARG:%s\\n' \"$@\"\n", + encoding="utf-8", + ) + fake_flatpak.chmod(0o755) + self.service.set("123", self._state(dxvkFrameRate=20, enableZink=True), str(fake_flatpak)) + result = self._run(123, "run", "com.example.Game", "--windowed", env={"DXVK_CONFIG": "foo=1"}) + args = result.stdout.splitlines() + self.assertEqual(args[0], "ARG:run") + self.assertIn("ARG:--env=SteamAppId=123", args) + self.assertIn("ARG:--env=ENABLE_GAMESCOPE_WSI=0", args) + self.assertIn("ARG:--env=DXVK_HDR=0", args) + self.assertIn("ARG:--env=__GLX_VENDOR_LIBRARY_NAME=mesa", args) + self.assertIn("ARG:--env=MESA_LOADER_DRIVER_OVERRIDE=zink", args) + self.assertIn("ARG:--env=GALLIUM_DRIVER=zink", args) + self.assertIn("ARG:--env=DXVK_CONFIG=foo=1; dxvk.maxFrameRate = 20", args) + self.assertIn("ARG:com.example.Game", args) + self.assertIn("ARG:--windowed", args) + + def test_invalid_state_and_foreign_wrapper_fail_closed(self): + invalid = self.service.set("0", self.service.default_state()) + self.assertFalse(invalid["success"]) + invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61}) + self.assertFalse(invalid["success"]) + + self.service.local_bin_dir.mkdir(parents=True, exist_ok=True) + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.set("123", self.service.default_state()) + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + + def test_remove_keeps_a_safe_owned_passthrough_wrapper(self): + self.service.set("123", self.service.default_state()) + response = self.service.remove("123") + self.assertTrue(response["success"]) + self.assertIsNone(self.service.get("123")["state"]) + self.assertTrue(self.service.wrapper_path.exists()) + result = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/printf", "ok"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, + capture_output=True, + text=True, + check=True, + ) + self.assertEqual(result.stdout, "ok") + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3 From 102a4a0f0ce4af303a12e7f6f1a4454f4e572a17 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Wed, 9 Sep 2026 09:51:21 -0400 Subject: move all games --- src/components/GameConfigurationSelector.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 50b2cf8..df0c3e9 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -174,13 +174,6 @@ export function GameConfigurationSelector({ )} - {availableGames.length > 0 && ( - - - Enable all available games - - - )} + {availableGames.length > 0 && ( + + + Enable all available games + + + )} 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 ++++- src/api/lsfgApi.ts | 81 +-- src/components/ConfigurationTab.tsx | 22 +- src/components/Content.tsx | 41 +- src/components/FlatpaksTab.tsx | 196 -------- src/components/GameConfigurationControls.tsx | 3 +- src/components/GameConfigurationSelector.tsx | 101 +--- src/components/NowPlayingTab.tsx | 93 +++- src/components/SetupTab.tsx | 142 +++++- src/components/WorkaroundsSection.tsx | 6 +- src/components/index.ts | 2 - src/hooks/useGameConfiguration.ts | 78 ++- src/hooks/usePerAppWorkarounds.ts | 24 +- src/types.d.ts | 1 + tests/test_flatpak_overrides.py | 158 ------ tests/test_flatpak_service.py | 231 +++++++++ tests/test_steam_service.py | 77 +++ tests/test_wrapper_service.py | 79 ++- 21 files changed, 1468 insertions(+), 925 deletions(-) delete mode 100644 src/components/FlatpaksTab.tsx delete mode 100644 tests/test_flatpak_overrides.py create mode 100644 tests/test_flatpak_service.py create mode 100644 tests/test_steam_service.py 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}) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 087228c..f487dff 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -43,7 +43,34 @@ export interface GameConfigEntry { profile: string; config: LsfgConfig; } -export interface InstalledGame { appid: string; name: string; nonSteam: boolean; } +export type TargetTransport = + | { kind: "host" } + | { kind: "flatpak"; flatpakAppId: string }; + +export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; + +export interface FlatpakTargetSupport { + success: boolean; + message?: string; + error?: string | null; + flatpak_app_id?: string; + runtime?: string | null; + runtime_branch?: string | null; + support_status: FlatpakTargetSupportStatus; + extension_installed: boolean; + installed_branches: string[]; +} + +export interface InstalledGame { + appid: string; + name: string; + nonSteam: boolean; + transport: TargetTransport; + executable?: string; + arguments?: string; + startDir?: string; + flatpakSupport?: FlatpakTargetSupport; +} export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } export interface GlobalConfig { dll: string; no_fp16: boolean; } @@ -79,6 +106,7 @@ export interface WorkaroundStateResult { wrapper_owned?: boolean; shortcut_exe?: string | null; command_token_added?: boolean; + transport?: TargetTransport | null; } export interface FileContentResult { @@ -88,37 +116,25 @@ export interface FileContentResult { error?: string; } -// Flatpak management interfaces export interface FlatpakExtensionStatus { success: boolean; message: string; - error?: string; - installed_23_08: boolean; - installed_24_08: boolean; - installed_25_08: boolean; + error?: string | null; + available: boolean; + extension_id: string; + supported_branches: string[]; + installed_branches: string[]; + owned_branches: string[]; + ownership_uncertain: boolean; } -export interface FlatpakApp { - app_id: string; - app_name: string; - has_filesystem_override: boolean; - has_env_override: boolean; -} - -export interface FlatpakAppInfo { - success: boolean; - message: string; - error?: string; - apps: FlatpakApp[]; - total_apps: number; -} - -export interface FlatpakOperationResult { +export interface FlatpakCleanupResult { success: boolean; message: string; - error?: string; - app_id?: string; - operation?: string; + error?: string | null; + removed_branches: string[]; + preserved_branches: string[]; + ownership_uncertain: boolean; } // API functions @@ -128,13 +144,13 @@ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); -// Flatpak management API functions -export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status"); -export const installFlatpakExtension = callable<[string], FlatpakOperationResult>("install_flatpak_extension"); -export const uninstallFlatpakExtension = callable<[string], FlatpakOperationResult>("uninstall_flatpak_extension"); -export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps"); -export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override"); -export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override"); +export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); +export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); +export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); +export const removePluginOwnedFlatpakExtensions = callable< + [], + FlatpakCleanupResult +>("remove_plugin_owned_flatpak_extensions"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); @@ -147,5 +163,6 @@ export const setWorkaroundState = callable<[ WorkaroundState, string | null | undefined, boolean, + TargetTransport | null | undefined, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 62a971b..90c1a7b 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -14,7 +14,6 @@ interface ConfigurationTabProps { onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; onEnable: (appid: string) => Promise; - onEnableAll: () => Promise; onRepair: (appid: string) => Promise; onReset: () => Promise; onResetAll: () => Promise; @@ -27,7 +26,6 @@ export function ConfigurationTab({ onSelect, onConfigChange, onEnable, - onEnableAll, onRepair, onReset, onResetAll, @@ -86,7 +84,6 @@ export function ConfigurationTab({ onSelect(appid); setDetailAppId(appid); }} - onEnableAll={onEnableAll} onResetAll={onResetAll} focusConfiguredToggle={focusConfiguredToggle} onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} @@ -96,8 +93,13 @@ export function ConfigurationTab({ } const profileLabel = selectedTarget?.name || "Game profile"; + const profileTransport = selectedTarget + ? selectedTarget.transport.kind === "flatpak" + ? "Non-Steam · Flatpak" + : selectedTarget.nonSteam ? "Non-Steam" : "Steam" + : "Game"; const profileDescription = selectedTarget - ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` + ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` : "Game is no longer available"; const handleProfileAction = async () => { if (selectedTarget?.configured) { @@ -151,6 +153,18 @@ export function ConfigurationTab({ )} + {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && ( + + + void onRepair(selectedTarget.appid)} + > + Repair Flatpak support + + + + )} {selectedTarget?.configured && ( , - configuration: , - flatpak: , - configFile: , + games: , setup: , }; @@ -38,7 +34,6 @@ export function Content() { setSelectedAppId, save, enable, - enableAll, repair, resetSelected, resetAll, @@ -52,25 +47,25 @@ export function Content() { steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; - const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null); + const previousRunningAppId = useRef(null); useEffect(() => { if (!setupComplete) { setTab("Setup"); return; } - setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current); - }, [runningGame?.configured, setupComplete]); + setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current); + }, [runningGame?.appid, setupComplete]); useEffect(() => { if (!setupComplete) return; - const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null; - const previous = previousRunningState.current; - previousRunningState.current = current; - if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) { - setTab(current.configured ? "NowPlaying" : "Configuration"); - } else if (!current && previous) { - setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab); + const appid = runningGame?.appid || null; + const previous = previousRunningAppId.current; + previousRunningAppId.current = appid; + if (appid && appid !== previous) { + setTab("NowPlaying"); + } else if (!appid && previous) { + setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab); } }, [runningGame?.appid, runningGame?.configured, setupComplete]); @@ -105,12 +100,13 @@ export function Content() { isUninstalling={isUninstalling} onInstall={onInstall} onUninstall={onUninstall} + flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")} /> ); const tabs = setupComplete ? [ - ...(runningGame?.configured ? [{ + ...(runningGame ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: ( @@ -118,12 +114,14 @@ export function Content() { game={runningGame} config={config} onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)} + onEnable={enable} + onRepair={repair} /> ), }] : []), { - id: "Configuration", - title: tabIcons.configuration, + id: "Games", + title: tabIcons.games, content: ( handleConfigChange(fieldName, value, true)} onEnable={enable} - onEnableAll={enableAll} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> ), }, - { id: "Flatpak", title: tabIcons.flatpak, content: }, - { id: "ConfigFile", title: tabIcons.configFile, content: }, // comment out for prod { id: "Setup", title: tabIcons.setup, content: setupContent }, ] : [ diff --git a/src/components/FlatpaksTab.tsx b/src/components/FlatpaksTab.tsx deleted file mode 100644 index 8aab940..0000000 --- a/src/components/FlatpaksTab.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useEffect, useState } from "react"; -import { - ConfirmModal, - Field, - PanelSection, - PanelSectionRow, - ToggleField, - showModal, -} from "@decky/ui"; -import { - checkFlatpakExtensionStatus, - FlatpakApp, - FlatpakAppInfo, - FlatpakExtensionStatus, - getFlatpakApps, - installFlatpakExtension, - removeFlatpakAppOverride, - setFlatpakAppOverride, - uninstallFlatpakExtension, -} from "../api/lsfgApi"; -import { showErrorToast } from "../utils/toastUtils"; -import t from "../i18n/i18n"; - -const runtimeVersions = [ - { version: "23.08", key: "installed_23_08" }, - { version: "24.08", key: "installed_24_08" }, - { version: "25.08", key: "installed_25_08" }, -] as const; - -interface RuntimeRowProps { - version: string; - installed: boolean; - busy: boolean; - onAction: () => void; -} - -function RuntimeRow({ version, installed, busy, onAction }: RuntimeRowProps) { - return ( - - onAction()} - disabled={busy} - /> - - ); -} - -interface AppRowProps { - app: FlatpakApp; - runtimeReady: boolean; - busy: boolean; - onToggle: () => void; -} - -function AppRow({ app, runtimeReady, busy, onToggle }: AppRowProps) { - const configured = app.has_filesystem_override && app.has_env_override; - const partial = app.has_filesystem_override || app.has_env_override; - const status = configured - ? runtimeReady - ? t("FLATPAK_STATUS_READY", "Ready") - : t("FLATPAK_STATUS_RUNTIME_MISSING", "Runtime missing") - : partial - ? t("FLATPAK_STATUS_PARTIAL", "Partial") - : t("FLATPAK_STATUS_NOT_ENABLED", "Not enabled"); - - return ( - - - - ); -} - -export function FlatpaksTab() { - const [extensionStatus, setExtensionStatus] = useState(null); - const [apps, setApps] = useState(null); - const [loading, setLoading] = useState(true); - const [operation, setOperation] = useState(null); - const [error, setError] = useState(null); - const runtimeReady = extensionStatus?.success === true - && runtimeVersions.some(({ key }) => extensionStatus[key]); - - const load = async () => { - setLoading(true); - try { - const [nextStatus, nextApps] = await Promise.all([ - checkFlatpakExtensionStatus(), - getFlatpakApps(), - ]); - setExtensionStatus(nextStatus); - setApps(nextApps); - } catch (loadError) { - setError(String(loadError)); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void load(); - }, []); - - const runExtensionOperation = async (version: string, installed: boolean) => { - const action = installed ? "uninstall" : "install"; - setOperation(`${action}-${version}`); - setError(null); - try { - const result = installed - ? await uninstallFlatpakExtension(version) - : await installFlatpakExtension(version); - if (!result.success) throw new Error(result.error || result.message); - setExtensionStatus(await checkFlatpakExtensionStatus()); - } catch (operationError) { - const message = String(operationError); - setError(message); - showErrorToast("Flatpak operation failed", message); - } finally { - setOperation(null); - } - }; - - const confirmExtensionOperation = (version: string, installed: boolean) => { - if (!installed) { - void runExtensionOperation(version, false); - return; - } - showModal( - void runExtensionOperation(version, true)} - onCancel={() => {}} - />, - ); - }; - - const toggleApp = async (app: FlatpakApp) => { - const configured = app.has_filesystem_override && app.has_env_override; - setOperation(`app-${app.app_id}`); - setError(null); - try { - const result = configured - ? await removeFlatpakAppOverride(app.app_id) - : await setFlatpakAppOverride(app.app_id); - if (!result.success) throw new Error(result.error || result.message); - setApps(await getFlatpakApps()); - } catch (operationError) { - const message = String(operationError); - setError(message); - showErrorToast("Flatpak override failed", message); - } finally { - setOperation(null); - } - }; - - if (loading) { - return ; - } - - return ( - <> - - {error && } - {extensionStatus?.success ? runtimeVersions.map(({ version, key }) => ( - confirmExtensionOperation(version, extensionStatus[key])} - /> - )) : } - - - - {apps?.success ? apps.apps.length ? apps.apps.map((app) => ( - void toggleApp(app)} - /> - )) : : } - - - ); -} diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 7025f78..6bea2e0 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -10,7 +10,7 @@ interface Props { autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; - workaroundTarget?: Pick; + workaroundTarget?: Pick; onRepairWorkaround?: () => Promise; } @@ -36,6 +36,7 @@ export function GameConfigurationControls({ )} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index df0c3e9..2a92c67 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -7,14 +7,13 @@ interface Props { targets: GameTarget[]; runningGame: GameTarget | null; onSelect: (appid: string) => void; - onEnableAll: () => Promise; onResetAll: () => Promise; focusConfiguredToggle?: boolean; onConfiguredToggleFocused?: () => void; } -const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3"; -const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v2"; +const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; function usePersistentCollapsed(key: string) { const [collapsed, setCollapsed] = useState(() => { @@ -36,6 +35,11 @@ function usePersistentCollapsed(key: string) { return [collapsed, () => setCollapsed((value) => !value)] as const; } +function targetDescription(game: GameTarget): string { + if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; + return game.nonSteam ? "Non-Steam" : "Steam"; +} + function GameGroup({ title, games, @@ -56,7 +60,7 @@ function GameGroup({ return ( <> - +
- {collapsed ? ( - - ) : ( - - )} + {collapsed ? : }
@@ -81,7 +81,7 @@ function GameGroup({ onSelect(game.appid)} highlightOnFocus /> @@ -95,7 +95,6 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, - onEnableAll, onResetAll, focusConfiguredToggle = false, onConfiguredToggleFocused, @@ -105,26 +104,20 @@ export function GameConfigurationSelector({ if (b.appid === runningGame?.appid) return 1; return a.name.localeCompare(b.name); }); - const configuredGames = sortGames(targets.filter((game) => game.configured)); + const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); - const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam); - const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam); - const availableSteamGames = availableGames.filter((game) => !game.nonSteam); - const availableNonSteamGames = availableGames.filter((game) => game.nonSteam); - const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY); - const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); - const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`); - const configuredToggleRef = useRef(null); + const enabledToggleRef = useRef(null); useEffect(() => { if (!focusConfiguredToggle) return; const frame = requestAnimationFrame(() => { - configuredToggleRef.current?.querySelector('[role="button"], button')?.focus(); + enabledToggleRef.current?.querySelector('[role="button"], button')?.focus(); onConfiguredToggleFocused?.(); }); return () => cancelAnimationFrame(frame); - }, [configuredGames.length, focusConfiguredToggle, onConfiguredToggleFocused]); + }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]); const confirmResetAll = () => { showModal( @@ -137,79 +130,29 @@ export function GameConfigurationSelector({ />, ); }; - const confirmEnableAll = () => { - showModal( - void onEnableAll()} - onCancel={() => {}} - />, - ); - }; return ( <> - {targets.length === 0 && ( )} - - - {availableGames.length > 0 && ( - - - Enable all available games - - - )} Promise; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + ) => Promise; + onEnable: (appid: string) => Promise; + onRepair: (appid: string) => Promise; } -export function NowPlayingTab({ game, config, onConfigChange }: Props) { +function targetDescription(game: GameTarget): string { + if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; + return game.nonSteam ? "Non-Steam" : "Steam"; +} + +export function NowPlayingTab({ + game, + config, + onConfigChange, + onEnable, + onRepair, +}: Props) { + const [busy, setBusy] = useState(false); + const supportNeedsRepair = + game.configured && + game.transport.kind === "flatpak" && + game.flatpakSupport?.support_status !== "ready"; + + const handleEnable = async () => { + if (busy) return; + setBusy(true); + try { + await onEnable(game.appid); + } finally { + setBusy(false); + } + }; + + const handleRepair = async () => { + if (busy) return; + setBusy(true); + try { + await onRepair(game.appid); + } finally { + setBusy(false); + } + }; + return ( - + - + - + {!game.configured && ( + + + + + + void handleEnable()}> + {busy ? "Enabling..." : "Enable LSFG-VK"} + + + + )} + {game.configured && supportNeedsRepair && ( + + + + + + void handleRepair()}> + {busy ? "Repairing..." : "Repair Flatpak support"} + + + + )} + {game.configured && ( + + )} ); } diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 98d6e78..9c854e1 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,5 +1,11 @@ -import { PanelSection } from "@decky/ui"; -import type { SteamBranchStatus } from "../api/lsfgApi"; +import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, showModal } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { + getFlatpakSupportStatus, + removePluginOwnedFlatpakExtensions, + type FlatpakExtensionStatus, + type SteamBranchStatus, +} from "../api/lsfgApi"; import { InstallationButton } from "./InstallationButton"; import { StatusDisplay } from "./StatusDisplay"; @@ -13,6 +19,104 @@ interface SetupTabProps { isUninstalling: boolean; onInstall: () => void; onUninstall: () => void; + flatpakRelevant: boolean; +} + +function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { + const [status, setStatus] = useState(null); + const [advanced, setAdvanced] = useState(false); + const [busy, setBusy] = useState(false); + + const refresh = async () => { + try { + setStatus(await getFlatpakSupportStatus()); + } catch (error) { + setStatus({ + success: false, + message: "", + error: String(error), + available: false, + extension_id: "", + supported_branches: [], + installed_branches: [], + owned_branches: [], + ownership_uncertain: false, + }); + } + }; + + useEffect(() => { + if (relevant) void refresh(); + }, [relevant]); + + if (!relevant || !status?.available) return null; + + const confirmCleanup = () => { + showModal( + { + setBusy(true); + try { + await removePluginOwnedFlatpakExtensions(); + await refresh(); + } finally { + setBusy(false); + } + }} + onCancel={() => {}} + />, + ); + }; + + return ( + + + + + + setAdvanced((value) => !value)}> + {advanced ? "Hide runtime details" : "Show runtime details"} + + + {advanced && ( + <> + {status.supported_branches.map((branch) => ( + + + + ))} + {status.ownership_uncertain && ( + + + + )} + + + {busy ? "Removing..." : "Remove plugin-installed extensions"} + + + + )} + + ); } export function SetupTab({ @@ -25,22 +129,26 @@ export function SetupTab({ isUninstalling, onInstall, onUninstall, + flatpakRelevant, }: SetupTabProps) { return ( - - - - + <> + + + + + + ); } diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index dbdd6a3..5587392 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -1,6 +1,7 @@ import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import type { TargetTransport } from "../api/lsfgApi"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; @@ -8,6 +9,7 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; + transport: TargetTransport; onRepair?: () => Promise; } @@ -79,9 +81,9 @@ function usePersistentCollapsed() { return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); - const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport); const [repairing, setRepairing] = useState(false); const state = snapshot?.state; const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; diff --git a/src/components/index.ts b/src/components/index.ts index 37a8edb..7c3ee0a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,8 +5,6 @@ export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; export { SetupTab } from "./SetupTab"; -export { ConfigFileTab } from "./ConfigFileTab"; -export { FlatpaksTab } from "./FlatpaksTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; export { NowPlayingTab } from "./NowPlayingTab"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index c66596a..b59d592 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; +import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; @@ -19,7 +19,12 @@ async function getSteamShortcuts(): Promise { const appid = Number(shortcut?.appid); const name = shortcut?.data?.strAppName; if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return []; - return [{ appid: String(appid >>> 0), name, nonSteam: true }]; + return [{ + appid: String(appid >>> 0), + name, + nonSteam: true, + transport: { kind: "host" }, + }]; }); } catch { return []; @@ -28,7 +33,10 @@ async function getSteamShortcuts(): Promise { function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) { const games = new Map(backendGames.map((game) => [game.appid, game])); - for (const game of shortcutGames) games.set(game.appid, game); + for (const game of shortcutGames) { + const existing = games.get(game.appid); + games.set(game.appid, existing ? { ...existing, name: game.name, nonSteam: true } : game); + } return Array.from(games.values()); } @@ -82,7 +90,7 @@ export function useGameConfiguration() { const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); setRunningGame((current) => current?.appid === appid ? current : { - ...(installed || { appid, name, nonSteam: false }), + ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }), name, configured: games.some((game) => game.appid === appid), }); @@ -101,13 +109,26 @@ export function useGameConfiguration() { const targets = useMemo(() => { const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true }); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true }); if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); return configured; }, [games, installedGames, runningGame]); const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; + const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise => { + if (target.transport.kind !== "flatpak") return true; + const result = await ensureFlatpakSupport(target.transport.flatpakAppId); + if (!result.success || result.support_status !== "ready") { + showErrorToast( + "Flatpak support unavailable", + result.error || result.message || "The required Flatpak runtime extension is not ready", + ); + return false; + } + return true; + }, []); + const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; const appId = Number(target.appid); @@ -119,6 +140,7 @@ export function useGameConfiguration() { const oldState = existing.state; const oldShortcutExe = existing.shortcut_exe || undefined; const oldCommandTokenAdded = existing.command_token_added === true; + const oldTransport = existing.transport || target.transport; if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) { throw new Error("Managed shortcut Target has no saved original executable"); } @@ -138,6 +160,7 @@ export function useGameConfiguration() { state, originalExecutable || null, oldCommandTokenAdded, + target.transport, ); if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state"); @@ -149,6 +172,7 @@ export function useGameConfiguration() { state, target.nonSteam ? (integration.originalExecutable || originalExecutable || null) : null, integration.commandTokenAdded, + target.transport, ); if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state"); return true; @@ -170,7 +194,13 @@ export function useGameConfiguration() { } if (rollbackSucceeded) { const restored = oldState - ? await setWorkaroundState(target.appid, oldState, oldShortcutExe || null, oldCommandTokenAdded) + ? await setWorkaroundState( + target.appid, + oldState, + oldShortcutExe || null, + oldCommandTokenAdded, + oldTransport, + ) : await removeWorkaroundState(target.appid); if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state"); } @@ -227,30 +257,30 @@ export function useGameConfiguration() { const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; + if (!(await ensureTargetFlatpakSupport(target))) return false; if (!(await ensureTargetWorkarounds(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); else await removeTargetWorkarounds(target); return result.success; - }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); - const enableAll = useCallback(async (): Promise => { - const available = targets.filter((target) => !target.configured && target.name); - if (available.length === 0) return; - for (const target of available) { - if (!(await ensureTargetWorkarounds(target))) return; - const result = await updateGameConfig(target.appid, target.name, template); - if (!result.success) { - showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); - await removeTargetWorkarounds(target); - return; - } - } - await load(); - }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); const repair = useCallback(async (appid: string): Promise => { const target = targets.find((item) => item.appid === appid); - return target ? ensureTargetWorkarounds(target) : false; - }, [ensureTargetWorkarounds, targets]); + if (!target) return false; + if (target.transport.kind === "flatpak") { + const support = await repairFlatpakSupport(target.transport.flatpakAppId); + if (!support.success || support.support_status !== "ready") { + showErrorToast( + "Flatpak support unavailable", + support.error || support.message || "The required Flatpak runtime extension is not ready", + ); + return false; + } + } + const success = await ensureTargetWorkarounds(target); + if (success) await load(); + return success; + }, [ensureTargetWorkarounds, load, targets]); const resetSelected = useCallback(async () => { if (selectedAppId) { @@ -276,5 +306,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, repair, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index 9e283db..c7413b0 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -3,6 +3,7 @@ import { getWorkaroundState, removeWorkaroundState, setWorkaroundState, + type TargetTransport, type WorkaroundState, } from "../api/lsfgApi"; import { @@ -45,6 +46,7 @@ export interface WorkaroundSnapshot { integrationInstalled: boolean; commandTokenAdded: boolean; shortcutExe?: string | null; + transport: TargetTransport; } interface PerAppWorkarounds { @@ -85,12 +87,14 @@ function makeSnapshot( integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath), commandTokenAdded: result.command_token_added === true, shortcutExe: result.shortcut_exe, + transport: result.transport || { kind: "host" }, }; } async function adoptWorkaroundState( appId: string, nonSteam: boolean, + transport: TargetTransport, steam: SteamLaunchOptionsSnapshot, wrapperPath: string, ): Promise { @@ -98,7 +102,13 @@ async function adoptWorkaroundState( throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); } const originalExecutable = nonSteam ? steam.target : null; - const initial = await setWorkaroundState(appId, DEFAULT_WORKAROUND_STATE, originalExecutable, false); + const initial = await setWorkaroundState( + appId, + DEFAULT_WORKAROUND_STATE, + originalExecutable, + false, + transport, + ); if (!initial.success) throw new Error(initial.error || "Could not create workaround state"); let integration: Awaited> | null = null; try { @@ -112,6 +122,7 @@ async function adoptWorkaroundState( DEFAULT_WORKAROUND_STATE, nonSteam ? (integration.originalExecutable || originalExecutable) : null, integration.commandTokenAdded, + transport, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); return makeSnapshot(integration.snapshot, finalized, nonSteam); @@ -139,7 +150,11 @@ async function adoptWorkaroundState( } } -export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { +export function usePerAppWorkarounds( + appId: string, + nonSteam: boolean, + transport: TargetTransport = { kind: "host" }, +): PerAppWorkarounds { const [status, setStatus] = useState("loading"); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); @@ -156,12 +171,13 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo return adoptWorkaroundState( appId, nonSteam, + transport, steam, result.wrapper_path || getDefaultWrapperPath(), ); } return makeSnapshot(steam, result, nonSteam); - }, [appId, nonSteam, numericAppId]); + }, [appId, nonSteam, numericAppId, transport]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { setSnapshot(next); @@ -236,6 +252,7 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo nextState, current.shortcutExe ?? null, current.commandTokenAdded, + current.transport, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); applySnapshot({ @@ -245,6 +262,7 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo wrapperOwned: result.wrapper_owned === true, shortcutExe: result.shortcut_exe, commandTokenAdded: result.command_token_added === true, + transport: result.transport || current.transport, }); return true; } catch (updateError) { diff --git a/src/types.d.ts b/src/types.d.ts index 7b5d055..df433e0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -17,6 +17,7 @@ interface SteamAppDetails { strLaunchOptions?: string; strShortcutLaunchOptions?: string; strShortcutExe?: string; + strShortcutStartDir?: string; } interface SteamAppDetailsRegistration { diff --git a/tests/test_flatpak_overrides.py b/tests/test_flatpak_overrides.py deleted file mode 100644 index ed3ef6a..0000000 --- a/tests/test_flatpak_overrides.py +++ /dev/null @@ -1,158 +0,0 @@ -import sys -import tempfile -import types -import unittest -from pathlib import Path -from unittest.mock import Mock - - -sys.modules.setdefault( - "decky", - types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), -) -sys.modules.setdefault("tomllib", types.SimpleNamespace(loads=Mock())) -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) - -from lsfg_vk.flatpak_service import FlatpakService - - -class FlatpakOverrideTests(unittest.TestCase): - def setUp(self): - self.tempdir = tempfile.TemporaryDirectory() - home = Path(self.tempdir.name) / "home" / "deck" - home.mkdir(parents=True) - self.service = FlatpakService() - self.service.user_home = home - self.service.config_dir = home / ".config/lsfg-vk" - self.service.config_file_path = self.service.config_dir / "conf.toml" - self.service.legacy_script_path = home / "lsfg" - self.service.check_flatpak_available = Mock(return_value=True) - self.service._run_flatpak_command = Mock( - return_value=types.SimpleNamespace(returncode=0, stderr="", stdout="") - ) - self.app_id = "com.example.Game" - self.override_path = self.service._override_file_path(self.app_id) - - def tearDown(self): - self.tempdir.cleanup() - sys.modules.pop("lsfg_vk.plugin", None) - sys.modules.pop("lsfg_vk", None) - - def _paths(self): - return self.service._override_paths() - - def _write_override(self, content): - self.override_path.parent.mkdir(parents=True, exist_ok=True) - self.override_path.write_text(content, encoding="utf-8") - - def _show_response(self, content): - return types.SimpleNamespace(returncode=0, stderr="", stdout=content) - - def test_set_cleans_legacy_entries_and_verifies_readback(self): - paths = self._paths() - self._write_override( - "[Context]\n" - f"filesystems=/home/deck/keep;{paths['config_dir']}:rw;!{paths['legacy_home']};" - f"{paths['legacy_script']};{paths['legacy_dll']}:ro;{paths['dll_dir']}:ro;\n" - "unset-environment=KEEP_UNSET;LSFG_CONFIG;\n\n" - "[Environment]\n" - "KEEP_ENV=1\n" - "LSFG_CONFIG=\n" - "LSFGVK_CONFIG=old\n" - "ENABLE_GAMESCOPE_WSI=1\n" - "DXVK_HDR=1\n" - ) - expected = ( - "[Context]\n" - f"filesystems={paths['config_dir']}:rw;{paths['dll_dir']}:ro\n" - "[Environment]\n" - f"LSFGVK_CONFIG={paths['config_file']}\n" - "ENABLE_GAMESCOPE_WSI=0\n" - "DXVK_HDR=0\n" - ) - self.service._run_flatpak_command.side_effect = [ - self._show_response(""), - self._show_response(expected), - ] - - response = self.service.set_app_override(self.app_id) - command_args = self.service._run_flatpak_command.call_args_list[0].args[0] - cleaned = self.override_path.read_text(encoding="utf-8") - - self.assertTrue(response["success"]) - self.assertIn("--env=ENABLE_GAMESCOPE_WSI=0", command_args) - self.assertIn("--env=DXVK_HDR=0", command_args) - self.assertNotIn("--nofilesystem=/home/deck", command_args) - self.assertNotIn("--unset-env=LSFG_CONFIG", command_args) - self.assertIn("/home/deck/keep", cleaned) - self.assertIn("KEEP_ENV=1", cleaned) - self.assertNotIn("LSFG_CONFIG", cleaned) - self.assertNotIn(paths["legacy_home"], cleaned) - - def test_set_reports_failed_readback(self): - paths = self._paths() - self.service._run_flatpak_command.side_effect = [ - self._show_response(""), - self._show_response( - f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n" - f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n" - ), - ] - - response = self.service.set_app_override(self.app_id) - - self.assertFalse(response["success"]) - self.assertIn("verified", response["error"]) - - def test_remove_cleans_known_entries_preserves_unrelated_and_verifies(self): - paths = self._paths() - self._write_override( - "[Context]\n" - f"filesystems=/home/deck/keep;{paths['config_dir']};!{paths['legacy_home']};" - f"{paths['legacy_dll']};{paths['legacy_script']}\n" - "unset-environment=KEEP_UNSET;LSFG_CONFIG;ENABLE_GAMESCOPE_WSI\n\n" - "[Environment]\n" - "KEEP_ENV=1\n" - "LSFGVK_CONFIG=/old/path\n" - "DXVK_HDR=0\n" - ) - self.service._run_flatpak_command.side_effect = [ - self._show_response( - "[Context]\nfilesystems=/home/deck/keep\n" - "[Environment]\nKEEP_ENV=1\n" - ) - ] - - response = self.service.remove_app_override(self.app_id) - cleaned = self.override_path.read_text(encoding="utf-8") - - self.assertTrue(response["success"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 1) - self.assertIn("/home/deck/keep", cleaned) - self.assertIn("KEEP_UNSET", cleaned) - self.assertIn("KEEP_ENV", cleaned) - for name in ("LSFGVK_CONFIG", "LSFG_CONFIG", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"): - self.assertNotIn(name, cleaned) - for path in paths.values(): - if path != paths["config_file"]: - self.assertNotIn(path, cleaned) - - def test_remove_reports_failed_readback(self): - self._write_override("[Context]\nfilesystems=/home/deck/keep\n") - paths = self._paths() - self.service._run_flatpak_command.side_effect = [ - self._show_response( - f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n" - f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n" - "ENABLE_GAMESCOPE_WSI=0\nDXVK_HDR=0\n" - ) - ] - - response = self.service.remove_app_override(self.app_id) - - self.assertFalse(response["success"]) - self.assertIn("verified", response["error"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py new file mode 100644 index 0000000..38dfcb5 --- /dev/null +++ b/tests/test_flatpak_service.py @@ -0,0 +1,231 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.flatpak_service import FlatpakService + + +class FlatpakServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.service = FlatpakService() + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.check_flatpak_available = Mock(return_value=True) + self.service._run_flatpak_command = Mock() + self.bundle = self.home / "lsfg-vk-24.08.flatpak" + self.bundle.write_bytes(b"bundle") + self.service._bundled_extension_path = Mock(return_value=self.bundle) + + def tearDown(self): + self.tempdir.cleanup() + + @staticmethod + def _result(stdout="", returncode=0, stderr=""): + return types.SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr) + + @staticmethod + def _extension_line(branch): + return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + + def test_runtime_branch_mapping_is_strict_and_branch_specific(self): + self.assertEqual( + FlatpakService.runtime_branch_from_ref( + "org.freedesktop.Platform/x86_64/24.08" + ), + "24.08", + ) + self.assertEqual( + FlatpakService.runtime_branch_from_ref( + "org.freedesktop.Platform//25.08" + ), + "25.08", + ) + with self.assertRaises(ValueError): + FlatpakService.runtime_branch_from_ref("org.gnome.Sdk/x86_64/46") + with self.assertRaises(ValueError): + FlatpakService.runtime_branch_from_ref( + "org.freedesktop.Platform/x86_64/26.08" + ) + + def test_resolve_reads_required_runtime_instead_of_any_installed_branch(self): + self.service._run_flatpak_command.side_effect = [ + self._result("org.freedesktop.Platform/x86_64/24.08\n"), + self._result(self._extension_line("23.08")), + ] + + response = self.service.resolve_app_support("com.example.Game") + + self.assertTrue(response["success"]) + self.assertEqual(response["runtime_branch"], "24.08") + self.assertEqual(response["support_status"], "needs-runtime") + self.assertFalse(response["extension_installed"]) + self.assertEqual( + self.service._run_flatpak_command.call_args_list[0].args[0], + ["info", "--show-runtime", "com.example.Game"], + ) + self.assertEqual( + self.service._run_flatpak_command.call_args_list[1].args[0], + ["list", "--runtime", "--columns=application,arch,branch"], + ) + + def test_install_records_only_a_new_user_owned_branch(self): + self.service._run_flatpak_command.side_effect = [ + self._result(""), + self._result(""), + self._result(self._extension_line("24.08")), + ] + + response = self.service.install_extension("24.08") + + self.assertTrue(response["success"]) + self.assertTrue(response["owned_by_plugin"]) + install_args = self.service._run_flatpak_command.call_args_list[1].args[0] + self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"]) + self.assertEqual( + json.loads(self.service.ownership_path.read_text(encoding="utf-8")), + {"version": 1, "plugin_owned_branches": ["24.08"]}, + ) + + def test_preexisting_branch_is_not_claimed_or_removed(self): + self.service._run_flatpak_command.return_value = self._result( + self._extension_line("24.08") + ) + + install_response = self.service.install_extension("24.08") + cleanup_response = self.service.remove_plugin_owned_extensions() + + self.assertTrue(install_response["success"]) + self.assertFalse(install_response["owned_by_plugin"]) + self.assertFalse(self.service.ownership_path.exists()) + self.assertTrue(cleanup_response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 1) + + def test_corrupt_ownership_metadata_fails_closed(self): + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) + self.service.ownership_path.write_text("{not-json", encoding="utf-8") + + response = self.service.remove_plugin_owned_extensions() + + self.assertFalse(response["success"]) + self.assertTrue(response["ownership_uncertain"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 0) + + def test_dangling_ownership_symlink_fails_closed(self): + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) + self.service.ownership_path.symlink_to(self.home / "missing-metadata") + + response = self.service.remove_plugin_owned_extensions() + + self.assertFalse(response["success"]) + self.assertTrue(response["ownership_uncertain"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 0) + + def test_ensure_app_support_installs_only_the_app_runtime_branch(self): + self.service._run_flatpak_command.side_effect = [ + self._result("org.freedesktop.Platform/x86_64/24.08\n"), + self._result(""), + self._result(""), + self._result(""), + self._result(""), + self._result(self._extension_line("24.08")), + self._result("org.freedesktop.Platform/x86_64/24.08\n"), + self._result(self._extension_line("24.08")), + ] + + response = self.service.ensure_app_support("com.example.Game") + + self.assertTrue(response["success"]) + self.assertEqual(response["support_status"], "ready") + self.assertEqual(response["runtime_branch"], "24.08") + install_args = self.service._run_flatpak_command.call_args_list[4].args[0] + self.assertEqual(install_args[0], "install") + self.assertIn("--user", install_args) + self.assertNotIn("23.08", install_args) + self.assertEqual( + json.loads(self.service.ownership_path.read_text(encoding="utf-8")), + {"version": 1, "plugin_owned_branches": ["24.08"]}, + ) + + def test_two_shortcuts_using_one_flatpak_share_one_extension_branch(self): + self.service._run_flatpak_command.side_effect = [ + self._result("org.freedesktop.Platform/x86_64/24.08\n"), + self._result(""), + self._result(""), + self._result(""), + self._result(""), + self._result(self._extension_line("24.08")), + self._result("org.freedesktop.Platform/x86_64/24.08\n"), + self._result(self._extension_line("24.08")), + self._result("org.freedesktop.Platform/x86_64/24.08\n"), + self._result(self._extension_line("24.08")), + ] + + first = self.service.ensure_app_support("net.pcsx2.PCSX2") + second = self.service.ensure_app_support("net.pcsx2.PCSX2.Dev") + + self.assertEqual(first["support_status"], "ready") + self.assertEqual(second["support_status"], "ready") + install_commands = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ] + self.assertEqual(len(install_commands), 1) + self.assertEqual( + json.loads(self.service.ownership_path.read_text(encoding="utf-8")), + {"version": 1, "plugin_owned_branches": ["24.08"]}, + ) + + def test_cleanup_removes_all_owned_branches_without_reusing_stale_metadata(self): + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) + self.service.ownership_path.write_text( + json.dumps({"version": 1, "plugin_owned_branches": ["23.08", "24.08"]}), + encoding="utf-8", + ) + self.service._run_flatpak_command.side_effect = [ + self._result( + "\n".join( + [ + "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "23.08"]), + "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]), + ] + ) + + "\n" + ), + self._result(""), + self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"), + self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"), + self._result(""), + self._result(""), + ] + + response = self.service.remove_plugin_owned_extensions() + + self.assertTrue(response["success"]) + self.assertEqual(response["removed_branches"], ["23.08", "24.08"]) + self.assertFalse(self.service.ownership_path.exists()) + uninstall_commands = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "uninstall" + ] + self.assertEqual(len(uninstall_commands), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py new file mode 100644 index 0000000..849bb01 --- /dev/null +++ b/tests/test_steam_service.py @@ -0,0 +1,77 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.steam_service import SteamService, classify_shortcut_transport + + +class SteamTransportTests(unittest.TestCase): + def test_only_direct_canonical_flatpak_forms_are_classified(self): + self.assertEqual( + classify_shortcut_transport( + "/usr/bin/flatpak", + "run com.example.PCSX2 --fullscreen", + ), + {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, + ) + self.assertEqual( + classify_shortcut_transport( + "/usr/bin/flatpak run com.example.PCSX2", + "--fullscreen", + ), + {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, + ) + self.assertEqual( + classify_shortcut_transport( + "/usr/bin/bash", + "~/launch-game.sh --fullscreen", + ), + {"kind": "host"}, + ) + self.assertEqual( + classify_shortcut_transport( + "/usr/bin/flatpak", + "--user run com.example.PCSX2", + ), + {"kind": "host"}, + ) + self.assertEqual( + classify_shortcut_transport( + "/usr/bin/flatpak", + "run bash ~/launch-game.sh", + ), + {"kind": "host"}, + ) + + def test_shortcut_data_preserves_transport_inputs(self): + game = SteamService._shortcut_game( + { + "appid": 123456, + "AppName": "PCSX2 shortcut", + "Exe": "/usr/bin/flatpak", + "LaunchOptions": "run net.pcsx2.PCSX2 --fullscreen", + "StartDir": "/home/deck/Games", + } + ) + + self.assertEqual(game["appid"], "123456") + self.assertEqual(game["transport"], { + "kind": "flatpak", + "flatpakAppId": "net.pcsx2.PCSX2", + }) + self.assertEqual(game["executable"], "/usr/bin/flatpak") + self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen") + self.assertEqual(game["startDir"], "/home/deck/Games") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py index f17a944..5010d23 100644 --- a/tests/test_wrapper_service.py +++ b/tests/test_wrapper_service.py @@ -123,10 +123,20 @@ class WrapperServiceTests(unittest.TestCase): encoding="utf-8", ) fake_flatpak.chmod(0o755) - self.service.set("123", self._state(dxvkFrameRate=20, enableZink=True), str(fake_flatpak)) + self.service.set( + "123", + self._state(dxvkFrameRate=20, enableZink=True), + str(fake_flatpak), + False, + {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, + ) result = self._run(123, "run", "com.example.Game", "--windowed", env={"DXVK_CONFIG": "foo=1"}) args = result.stdout.splitlines() self.assertEqual(args[0], "ARG:run") + self.assertIn("ARG:--filesystem=" + str(self.service.config_dir) + ":rw", args) + self.assertIn("ARG:--filesystem=" + str(self.home / ".local/share/Steam/steamapps/common/Lossless Scaling") + ":ro", args) + self.assertIn("ARG:--env=LSFGVK_CONFIG=" + str(self.service.config_file_path), args) + self.assertIn("ARG:--env=LSFGVK_FLATPAK=1", args) self.assertIn("ARG:--env=SteamAppId=123", args) self.assertIn("ARG:--env=ENABLE_GAMESCOPE_WSI=0", args) self.assertIn("ARG:--env=DXVK_HDR=0", args) @@ -137,6 +147,73 @@ class WrapperServiceTests(unittest.TestCase): self.assertIn("ARG:com.example.Game", args) self.assertIn("ARG:--windowed", args) + def test_flatpak_full_executable_form_is_preserved(self): + fake_flatpak = self.home / ".local/bin/flatpak" + fake_flatpak.parent.mkdir(parents=True, exist_ok=True) + fake_flatpak.write_text( + "#!/bin/sh\n" + "printf 'ARG:%s\\n' \"$@\"\n", + encoding="utf-8", + ) + fake_flatpak.chmod(0o755) + response = self.service.set( + "123", + self._state(), + f"{fake_flatpak} run com.example.Game", + False, + {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, + ) + self.assertTrue(response["success"]) + result = self._run(123, "--windowed") + args = result.stdout.splitlines() + self.assertEqual(args[0], "ARG:run") + self.assertIn("ARG:com.example.Game", args) + self.assertIn("ARG:--windowed", args) + + def test_flatpak_transport_rejects_non_run_invocation(self): + fake_flatpak = self.home / ".local/bin/flatpak" + fake_flatpak.parent.mkdir(parents=True, exist_ok=True) + fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_flatpak.chmod(0o755) + response = self.service.set( + "123", + self._state(), + str(fake_flatpak), + False, + {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, + ) + self.assertTrue(response["success"]) + result = subprocess.run( + [str(self.service.wrapper_path), "bash", "launch-game.sh"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 64) + self.assertIn("direct flatpak run", result.stderr) + + def test_flatpak_transport_rejects_external_app_id_change(self): + fake_flatpak = self.home / ".local/bin/flatpak" + fake_flatpak.parent.mkdir(parents=True, exist_ok=True) + fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_flatpak.chmod(0o755) + response = self.service.set( + "123", + self._state(), + str(fake_flatpak), + False, + {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, + ) + self.assertTrue(response["success"]) + result = subprocess.run( + [str(self.service.wrapper_path), "run", "com.other.Game"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 64) + self.assertIn("application ID changed externally", result.stderr) + def test_invalid_state_and_foreign_wrapper_fail_closed(self): invalid = self.service.set("0", self.service.default_state()) self.assertFalse(invalid["success"]) -- 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 ++ src/api/lsfgApi.ts | 15 ++++++ src/components/ConfigurationTab.tsx | 3 ++ src/components/Content.tsx | 11 +++- src/components/GameConfigurationSelector.tsx | 40 ++++++++++++++ src/components/SetupTab.tsx | 81 ++++++++++++++++++++++++---- src/components/index.ts | 1 + src/hooks/useGameConfiguration.ts | 65 ++++++++++++++++++++-- src/hooks/usePerAppWorkarounds.ts | 24 +++++++-- tests/test_flatpak_service.py | 48 +++++++++++++++++ 11 files changed, 320 insertions(+), 27 deletions(-) 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() diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index f487dff..16b6eab 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -137,6 +137,17 @@ export interface FlatpakCleanupResult { ownership_uncertain: boolean; } +export interface FlatpakExtensionToggleResult { + success: boolean; + message: string; + error?: string | null; + runtime_branch: string; + enabled: boolean; + installed: boolean; + owned_by_plugin: boolean; + preserved: boolean; +} + // API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); @@ -147,6 +158,10 @@ export const getConfigFileContent = callable<[], FileContentResult>("get_config_ export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); +export const setFlatpakExtensionEnabled = callable< + [string, boolean], + FlatpakExtensionToggleResult +>("set_flatpak_extension_enabled"); export const removePluginOwnedFlatpakExtensions = callable< [], FlatpakCleanupResult diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 90c1a7b..8f8690c 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -14,6 +14,7 @@ interface ConfigurationTabProps { onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; onEnable: (appid: string) => Promise; + onEnableAll: () => Promise; onRepair: (appid: string) => Promise; onReset: () => Promise; onResetAll: () => Promise; @@ -26,6 +27,7 @@ export function ConfigurationTab({ onSelect, onConfigChange, onEnable, + onEnableAll, onRepair, onReset, onResetAll, @@ -84,6 +86,7 @@ export function ConfigurationTab({ onSelect(appid); setDetailAppId(appid); }} + onEnableAll={onEnableAll} onResetAll={onResetAll} focusConfiguredToggle={focusConfiguredToggle} onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index ff2376b..58512d5 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,18 +1,20 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; -import { FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { useInstallationStatus } from "../hooks/useLsfgHooks"; import { ConfigurationTab } from "./ConfigurationTab"; +import { ConfigFileTab } from "./ConfigFileTab"; import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; const tabIcons = { nowPlaying: , games: , + configFile: , setup: , }; @@ -34,6 +36,7 @@ export function Content() { setSelectedAppId, save, enable, + enableAll, repair, resetSelected, resetAll, @@ -130,12 +133,18 @@ export function Content() { onSelect={setSelectedAppId} onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} onEnable={enable} + onEnableAll={enableAll} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> ), }, + { + id: "ConfigFile", + title: tabIcons.configFile, + content: , + }, { id: "Setup", title: tabIcons.setup, content: setupContent }, ] : [ diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 2a92c67..92eacba 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -7,6 +7,7 @@ interface Props { targets: GameTarget[]; runningGame: GameTarget | null; onSelect: (appid: string) => void; + onEnableAll: () => Promise; onResetAll: () => Promise; focusConfiguredToggle?: boolean; onConfiguredToggleFocused?: () => void; @@ -95,6 +96,7 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, + onEnableAll, onResetAll, focusConfiguredToggle = false, onConfiguredToggleFocused, @@ -131,8 +133,39 @@ export function GameConfigurationSelector({ ); }; + const confirmEnableAll = () => { + showModal( + void onEnableAll()} + onCancel={() => {}} + />, + ); + }; + return ( <> + {targets.length === 0 && ( @@ -153,6 +186,13 @@ export function GameConfigurationSelector({ onToggle={toggleAvailable} onSelect={onSelect} /> + {availableGames.length > 0 && ( + + + Enable all available games + + + )} (null); const [advanced, setAdvanced] = useState(false); - const [busy, setBusy] = useState(false); + const [operation, setOperation] = useState(null); const refresh = async () => { try { @@ -51,6 +53,51 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { if (!relevant || !status?.available) return null; + const runExtensionOperation = async (version: string, enabled: boolean) => { + const operationKey = `${enabled ? "enable" : "disable"}-${version}`; + setOperation(operationKey); + try { + const result = await setFlatpakExtensionEnabled(version, enabled); + if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); + await refresh(); + } catch (error) { + showErrorToast("Flatpak runtime update failed", String(error)); + } finally { + setOperation(null); + } + }; + + const confirmDisable = (version: string) => { + showModal( + void runExtensionOperation(version, false)} + onCancel={() => {}} + />, + ); + }; + + const handleExtensionToggle = (version: string, enabled: boolean) => { + const installed = status.installed_branches.includes(version); + const owned = status.owned_branches.includes(version); + if (!enabled && installed && !owned) { + showErrorToast( + "Flatpak runtime preserved", + `${version} was not installed by this plugin, so it will remain installed.`, + ); + void refresh(); + return; + } + if (!enabled && installed && owned) { + confirmDisable(version); + return; + } + void runExtensionOperation(version, enabled); + }; + const confirmCleanup = () => { showModal( { - setBusy(true); + setOperation("cleanup"); try { - await removePluginOwnedFlatpakExtensions(); + const result = await removePluginOwnedFlatpakExtensions(); + if (!result.success) throw new Error(result.error || result.message || "Flatpak cleanup failed"); await refresh(); + } catch (error) { + showErrorToast("Flatpak cleanup failed", String(error)); } finally { - setBusy(false); + setOperation(null); } }} onCancel={() => {}} @@ -89,13 +139,22 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { <> {status.supported_branches.map((branch) => ( - handleExtensionToggle(branch, enabled)} + disabled={operation !== null || status.ownership_uncertain} /> ))} @@ -107,10 +166,10 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { - {busy ? "Removing..." : "Remove plugin-installed extensions"} + {operation === "cleanup" ? "Removing..." : "Remove plugin-installed extensions"} diff --git a/src/components/index.ts b/src/components/index.ts index 7c3ee0a..6856e76 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; +export { ConfigFileTab } from "./ConfigFileTab"; export { SetupTab } from "./SetupTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index b59d592..c70d589 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -40,6 +40,23 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta return Array.from(games.values()); } +function selectShortcutExecutable( + target: GameTarget, + ...candidates: Array +): string | undefined { + const absolute = candidates + .map((candidate) => candidate?.trim()) + .find((candidate) => candidate && candidate.startsWith("/")); + if (absolute) return absolute; + + // Steam's app-details API can report a Flatpak Target as just "flatpak" + // even when the shortcut's canonical VDF executable is /usr/bin/flatpak. + // Keep the stored original executable absolute so SetShortcutExe and the + // generated dispatcher agree on the same direct transport. + if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; + return candidates.map((candidate) => candidate?.trim()).find(Boolean); +} + const DEFAULT_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: true, @@ -151,7 +168,14 @@ export function useGameConfiguration() { throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); } const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = target.nonSteam ? (oldShortcutExe || current.target) : undefined; + const originalExecutable = target.nonSteam + ? selectShortcutExecutable( + target, + oldShortcutExe, + target.transport.kind === "flatpak" ? target.executable : undefined, + current.target, + ) + : undefined; const initialIntegration = target.nonSteam ? current.target === wrapperPath : hasWrapperLaunchIntegration(current.options, wrapperPath); @@ -170,7 +194,14 @@ export function useGameConfiguration() { const finalStateResult = await setWorkaroundState( target.appid, state, - target.nonSteam ? (integration.originalExecutable || originalExecutable || null) : null, + target.nonSteam + ? (selectShortcutExecutable( + target, + integration.originalExecutable, + originalExecutable, + target.transport.kind === "flatpak" ? target.executable : undefined, + ) || null) + : null, integration.commandTokenAdded, target.transport, ); @@ -184,7 +215,14 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - target.nonSteam ? (integration?.originalExecutable || originalExecutable) : undefined, + target.nonSteam + ? (selectShortcutExecutable( + target, + integration?.originalExecutable, + originalExecutable, + target.transport.kind === "flatpak" ? target.executable : undefined, + ) || undefined) + : undefined, integration?.commandTokenAdded ?? oldCommandTokenAdded, ); } catch (rollbackError) { @@ -264,6 +302,25 @@ export function useGameConfiguration() { else await removeTargetWorkarounds(target); return result.success; }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const enableAll = useCallback(async (): Promise => { + const available = targets.filter((target) => !target.configured && target.name); + if (available.length === 0) return; + + for (const target of available) { + if (!(await ensureTargetFlatpakSupport(target))) return; + if (!(await ensureTargetWorkarounds(target))) return; + const result = await updateGameConfig(target.appid, target.name, template); + if (!result.success) { + await removeTargetWorkarounds(target); + showErrorToast( + "Could not enable all games", + result.error || `Could not create a profile for ${target.name}`, + ); + return; + } + } + await load(); + }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); const repair = useCallback(async (appid: string): Promise => { const target = targets.find((item) => item.appid === appid); if (!target) return false; @@ -306,5 +363,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, repair, resetSelected, resetAll, reload: load }; + return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index c7413b0..e9e44e1 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -61,6 +61,18 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +function selectShortcutExecutable( + transport: TargetTransport, + ...candidates: Array +): string | undefined { + const absolute = candidates + .map((candidate) => candidate?.trim()) + .find((candidate) => candidate && candidate.startsWith("/")); + if (absolute) return absolute; + if (transport.kind === "flatpak") return "/usr/bin/flatpak"; + return candidates.map((candidate) => candidate?.trim()).find(Boolean); +} + function integrationIsInstalled( steam: SteamLaunchOptionsSnapshot, nonSteam: boolean, @@ -101,7 +113,9 @@ async function adoptWorkaroundState( if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); } - const originalExecutable = nonSteam ? steam.target : null; + const originalExecutable = nonSteam + ? selectShortcutExecutable(transport, steam.target) + : null; const initial = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, @@ -120,7 +134,9 @@ async function adoptWorkaroundState( const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - nonSteam ? (integration.originalExecutable || originalExecutable) : null, + nonSteam + ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) + : null, integration.commandTokenAdded, transport, ); @@ -134,7 +150,9 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, - nonSteam ? (integration?.originalExecutable || originalExecutable || undefined) : undefined, + nonSteam + ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) + : undefined, integration?.commandTokenAdded ?? false, ); } catch { diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 38dfcb5..274c0c5 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -115,6 +115,54 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(cleanup_response["success"]) self.assertEqual(self.service._run_flatpak_command.call_count, 1) + def test_extension_toggle_is_idempotent_and_preserves_preexisting_branch(self): + self.service._run_flatpak_command.return_value = self._result( + self._extension_line("24.08") + ) + + enable_response = self.service.set_extension_enabled("24.08", True) + disable_response = self.service.set_extension_enabled("24.08", False) + + self.assertTrue(enable_response["success"]) + self.assertTrue(enable_response["enabled"]) + self.assertTrue(disable_response["success"]) + self.assertTrue(disable_response["enabled"]) + self.assertTrue(disable_response["preserved"]) + self.assertFalse(disable_response["owned_by_plugin"]) + self.assertEqual( + [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], + ["list", "list"], + ) + + def test_extension_toggle_removes_owned_branch_and_can_repeat_disable(self): + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) + self.service.ownership_path.write_text( + json.dumps({"version": 1, "plugin_owned_branches": ["24.08"]}), + encoding="utf-8", + ) + self.service._run_flatpak_command.side_effect = [ + self._result(self._extension_line("24.08")), + self._result(""), + self._result(""), + self._result(""), + ] + + disable_response = self.service.set_extension_enabled("24.08", False) + repeat_response = self.service.set_extension_enabled("24.08", False) + + self.assertTrue(disable_response["success"]) + self.assertFalse(disable_response["enabled"]) + self.assertTrue(disable_response["removed"]) + self.assertTrue(repeat_response["success"]) + self.assertFalse(repeat_response["enabled"]) + self.assertFalse(repeat_response["installed"]) + uninstall_commands = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "uninstall" + ] + self.assertEqual(len(uninstall_commands), 1) + def test_corrupt_ownership_metadata_fails_closed(self): self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) self.service.ownership_path.write_text("{not-json", encoding="utf-8") -- 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 -- src/api/lsfgApi.ts | 17 -------- src/components/SetupTab.tsx | 76 ++--------------------------------- tests/test_flatpak_service.py | 26 +++++++----- 5 files changed, 33 insertions(+), 165 deletions(-) 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): """ diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 16b6eab..8179ef9 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -124,17 +124,6 @@ export interface FlatpakExtensionStatus { extension_id: string; supported_branches: string[]; installed_branches: string[]; - owned_branches: string[]; - ownership_uncertain: boolean; -} - -export interface FlatpakCleanupResult { - success: boolean; - message: string; - error?: string | null; - removed_branches: string[]; - preserved_branches: string[]; - ownership_uncertain: boolean; } export interface FlatpakExtensionToggleResult { @@ -144,8 +133,6 @@ export interface FlatpakExtensionToggleResult { runtime_branch: string; enabled: boolean; installed: boolean; - owned_by_plugin: boolean; - preserved: boolean; } // API functions @@ -162,10 +149,6 @@ export const setFlatpakExtensionEnabled = callable< [string, boolean], FlatpakExtensionToggleResult >("set_flatpak_extension_enabled"); -export const removePluginOwnedFlatpakExtensions = callable< - [], - FlatpakCleanupResult ->("remove_plugin_owned_flatpak_extensions"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index df43c5e..aec0e93 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,8 +1,7 @@ -import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, ToggleField, showModal } from "@decky/ui"; +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { getFlatpakSupportStatus, - removePluginOwnedFlatpakExtensions, setFlatpakExtensionEnabled, type FlatpakExtensionStatus, type SteamBranchStatus, @@ -41,8 +40,6 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { extension_id: "", supported_branches: [], installed_branches: [], - owned_branches: [], - ownership_uncertain: false, }); } }; @@ -67,61 +64,10 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { } }; - const confirmDisable = (version: string) => { - showModal( - void runExtensionOperation(version, false)} - onCancel={() => {}} - />, - ); - }; - const handleExtensionToggle = (version: string, enabled: boolean) => { - const installed = status.installed_branches.includes(version); - const owned = status.owned_branches.includes(version); - if (!enabled && installed && !owned) { - showErrorToast( - "Flatpak runtime preserved", - `${version} was not installed by this plugin, so it will remain installed.`, - ); - void refresh(); - return; - } - if (!enabled && installed && owned) { - confirmDisable(version); - return; - } void runExtensionOperation(version, enabled); }; - const confirmCleanup = () => { - showModal( - { - setOperation("cleanup"); - try { - const result = await removePluginOwnedFlatpakExtensions(); - if (!result.success) throw new Error(result.error || result.message || "Flatpak cleanup failed"); - await refresh(); - } catch (error) { - showErrorToast("Flatpak cleanup failed", String(error)); - } finally { - setOperation(null); - } - }} - onCancel={() => {}} - />, - ); - }; - return ( @@ -147,31 +93,15 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { : operation === `disable-${branch}` ? "Uninstalling..." : status.installed_branches.includes(branch) - ? status.owned_branches.includes(branch) - ? "Installed · plugin-owned" - : "Installed · pre-existing (preserved)" + ? "Installed" : "Not installed" } checked={status.installed_branches.includes(branch)} onChange={(enabled) => handleExtensionToggle(branch, enabled)} - disabled={operation !== null || status.ownership_uncertain} + disabled={operation !== null} /> ))} - {status.ownership_uncertain && ( - - - - )} - - - {operation === "cleanup" ? "Removing..." : "Remove plugin-installed extensions"} - - )} diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 274c0c5..70ba228 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -93,7 +93,8 @@ class FlatpakServiceTests(unittest.TestCase): response = self.service.install_extension("24.08") self.assertTrue(response["success"]) - self.assertTrue(response["owned_by_plugin"]) + self.assertTrue(response["enabled"]) + self.assertTrue(response["installed"]) install_args = self.service._run_flatpak_command.call_args_list[1].args[0] self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"]) self.assertEqual( @@ -110,28 +111,33 @@ class FlatpakServiceTests(unittest.TestCase): cleanup_response = self.service.remove_plugin_owned_extensions() self.assertTrue(install_response["success"]) - self.assertFalse(install_response["owned_by_plugin"]) + self.assertTrue(install_response["enabled"]) + self.assertTrue(install_response["installed"]) self.assertFalse(self.service.ownership_path.exists()) self.assertTrue(cleanup_response["success"]) self.assertEqual(self.service._run_flatpak_command.call_count, 1) - def test_extension_toggle_is_idempotent_and_preserves_preexisting_branch(self): - self.service._run_flatpak_command.return_value = self._result( - self._extension_line("24.08") - ) + def test_extension_toggle_installs_and_uninstalls_preexisting_branch(self): + self.service._run_flatpak_command.side_effect = [ + self._result(self._extension_line("24.08")), + self._result(self._extension_line("24.08")), + self._result(""), + self._result(""), + ] enable_response = self.service.set_extension_enabled("24.08", True) disable_response = self.service.set_extension_enabled("24.08", False) self.assertTrue(enable_response["success"]) self.assertTrue(enable_response["enabled"]) + self.assertTrue(enable_response["installed"]) self.assertTrue(disable_response["success"]) - self.assertTrue(disable_response["enabled"]) - self.assertTrue(disable_response["preserved"]) - self.assertFalse(disable_response["owned_by_plugin"]) + self.assertFalse(disable_response["enabled"]) + self.assertFalse(disable_response["installed"]) + self.assertTrue(disable_response["removed"]) self.assertEqual( [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["list", "list"], + ["list", "list", "uninstall", "list"], ) def test_extension_toggle_removes_owned_branch_and_can_repeat_disable(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 ++++++++++-- src/components/Content.tsx | 1 - src/components/SetupTab.tsx | 68 +++++++++++++++---------------------- tests/test_steam_service.py | 33 ++++++++++++++++++ 4 files changed, 79 insertions(+), 44 deletions(-) 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] diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 58512d5..d4f54e9 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -103,7 +103,6 @@ export function Content() { isUninstalling={isUninstalling} onInstall={onInstall} onUninstall={onUninstall} - flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")} /> ); diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index aec0e93..6bf1fad 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { getFlatpakSupportStatus, @@ -20,12 +20,10 @@ interface SetupTabProps { isUninstalling: boolean; onInstall: () => void; onUninstall: () => void; - flatpakRelevant: boolean; } -function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { +function FlatpakSupportDiagnostics() { const [status, setStatus] = useState(null); - const [advanced, setAdvanced] = useState(false); const [operation, setOperation] = useState(null); const refresh = async () => { @@ -45,10 +43,10 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { }; useEffect(() => { - if (relevant) void refresh(); - }, [relevant]); + void refresh(); + }, []); - if (!relevant || !status?.available) return null; + if (!status?.available) return null; const runExtensionOperation = async (version: string, enabled: boolean) => { const operationKey = `${enabled ? "enable" : "disable"}-${version}`; @@ -69,41 +67,32 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { }; return ( - + - - setAdvanced((value) => !value)}> - {advanced ? "Hide runtime details" : "Show runtime details"} - - - {advanced && ( - <> - {status.supported_branches.map((branch) => ( - - handleExtensionToggle(branch, enabled)} - disabled={operation !== null} - /> - - ))} - - )} + {status.supported_branches.map((branch) => ( + + handleExtensionToggle(branch, enabled)} + disabled={operation !== null} + /> + + ))} ); } @@ -118,7 +107,6 @@ export function SetupTab({ isUninstalling, onInstall, onUninstall, - flatpakRelevant, }: SetupTabProps) { return ( <> @@ -137,7 +125,7 @@ export function SetupTab({ onUninstall={onUninstall} /> - + ); } diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 849bb01..9924186 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -51,6 +51,24 @@ class SteamTransportTests(unittest.TestCase): ), {"kind": "host"}, ) + self.assertEqual( + classify_shortcut_transport( + "~/.lsfg", + "run --branch=stable --arch=x86_64 com.example.PCSX2", + ), + {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, + ) + self.assertEqual( + classify_shortcut_transport( + "/home/deck/.lsfg", + "run com.example.PCSX2", + ), + {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, + ) + self.assertEqual( + classify_shortcut_transport("~/.lsfg", "--profile high"), + {"kind": "host"}, + ) def test_shortcut_data_preserves_transport_inputs(self): game = SteamService._shortcut_game( @@ -72,6 +90,21 @@ class SteamTransportTests(unittest.TestCase): self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen") self.assertEqual(game["startDir"], "/home/deck/Games") + def test_wrapped_flatpak_shortcut_remains_a_flatpak_target(self): + game = SteamService._shortcut_game( + { + "appid": 987654, + "AppName": "Wrapped Flatpak", + "Exe": "~/.lsfg", + "LaunchOptions": "run --branch=stable --arch=x86_64 com.example.Game", + } + ) + + self.assertEqual(game["transport"], { + "kind": "flatpak", + "flatpakAppId": "com.example.Game", + }) + if __name__ == "__main__": unittest.main() -- 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 +- src/api/lsfgApi.ts | 101 +++--- src/components/Content.tsx | 73 ++--- src/components/InstallationButton.tsx | 38 --- src/components/SetupTab.tsx | 145 +++++---- src/components/StatusDisplay.tsx | 41 --- src/components/index.ts | 2 - src/hooks/useInstallationActions.ts | 84 ----- src/hooks/useLsfgHooks.ts | 87 +++++- src/utils/steamLaunchOptions.ts | 566 ++++++++++++---------------------- src/utils/toastUtils.ts | 77 +---- 16 files changed, 789 insertions(+), 1515 deletions(-) delete mode 100644 src/components/InstallationButton.tsx delete mode 100644 src/components/StatusDisplay.tsx delete mode 100644 src/hooks/useInstallationActions.ts 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 diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 8179ef9..b96acec 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -1,11 +1,13 @@ import { callable } from "@decky/api"; import { ConfigurationData } from "../config/configSchema"; -// Type definitions for API responses -export interface InstallationResult { +interface ApiResult { success: boolean; - error?: string; message?: string; + error?: string | null; +} + +export interface InstallationResult extends ApiResult { removed_files?: string[]; } @@ -16,10 +18,8 @@ export interface InstallationStatus { error?: string; } -export interface SteamBranchStatus { - success: boolean; +export interface SteamBranchStatus extends ApiResult { message: string; - error?: string; installed: boolean; manifest_path?: string; selected_branch?: string; @@ -29,36 +29,16 @@ export interface SteamBranchStatus { restart_required: boolean; } -// Use centralized configuration data type export type LsfgConfig = ConfigurationData; - -export interface ConfigUpdateResult { - success: boolean; - message?: string; - error?: string; -} - -export interface GameConfigEntry { - appid: string; - profile: string; - config: LsfgConfig; -} export type TargetTransport = | { kind: "host" } | { kind: "flatpak"; flatpakAppId: string }; - export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; -export interface FlatpakTargetSupport { - success: boolean; - message?: string; - error?: string | null; - flatpak_app_id?: string; - runtime?: string | null; - runtime_branch?: string | null; - support_status: FlatpakTargetSupportStatus; - extension_installed: boolean; - installed_branches: string[]; +export interface GameConfigEntry { + appid: string; + profile: string; + config: LsfgConfig; } export interface InstalledGame { @@ -71,20 +51,19 @@ export interface InstalledGame { startDir?: string; flatpakSupport?: FlatpakTargetSupport; } -export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } -export interface GlobalConfig { dll: string; no_fp16: boolean; } -export interface GameConfigsResult { - success: boolean; - global_config?: GlobalConfig; - games?: GameConfigEntry[]; - error?: string; +export interface GlobalConfig { + dll: string; + no_fp16: boolean; } -export interface GameConfigResult extends ConfigUpdateResult { - appid?: string; - exists?: boolean; - config?: LsfgConfig; +export interface FlatpakTargetSupport extends ApiResult { + flatpak_app_id?: string; + runtime?: string | null; + runtime_branch?: string | null; + support_status: FlatpakTargetSupportStatus; + extension_installed: boolean; + installed_branches: string[]; } export interface WorkaroundState { @@ -96,10 +75,7 @@ export interface WorkaroundState { enableZink: boolean; } -export interface WorkaroundStateResult { - success: boolean; - message?: string; - error?: string; +export interface WorkaroundStateResult extends ApiResult { appid?: string; state?: WorkaroundState | null; wrapper_path?: string; @@ -109,47 +85,50 @@ export interface WorkaroundStateResult { transport?: TargetTransport | null; } -export interface FileContentResult { - success: boolean; +export interface GameConfigsResult extends ApiResult { + global_config?: GlobalConfig; + games?: GameConfigEntry[]; +} + +export interface GameConfigResult extends ApiResult { + appid?: string; + exists?: boolean; + config?: LsfgConfig; +} + +export interface InstalledGamesResult extends ApiResult { + games?: InstalledGame[]; +} + +export interface FileContentResult extends ApiResult { content?: string; path?: string; - error?: string; } -export interface FlatpakExtensionStatus { - success: boolean; +export interface FlatpakExtensionStatus extends ApiResult { message: string; - error?: string | null; available: boolean; extension_id: string; supported_branches: string[]; installed_branches: string[]; } -export interface FlatpakExtensionToggleResult { - success: boolean; +export interface FlatpakExtensionToggleResult extends ApiResult { message: string; - error?: string | null; runtime_branch: string; enabled: boolean; installed: boolean; } -// API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); - export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); -export const setFlatpakExtensionEnabled = callable< - [string, boolean], - FlatpakExtensionToggleResult ->("set_flatpak_extension_enabled"); - +export const setFlatpakExtensionEnabled = callable<[string, boolean], FlatpakExtensionToggleResult>("set_flatpak_extension_enabled"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); diff --git a/src/components/Content.tsx b/src/components/Content.tsx index d4f54e9..d92ef86 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -2,12 +2,11 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import { tabStyles } from "../styles"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; -import { useInstallationActions } from "../hooks/useInstallationActions"; -import { useInstallationStatus } from "../hooks/useLsfgHooks"; -import { ConfigurationTab } from "./ConfigurationTab"; +import { useInstallation } from "../hooks/useLsfgHooks"; +import { tabStyles } from "../styles"; import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigurationTab } from "./ConfigurationTab"; import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; @@ -19,16 +18,6 @@ const tabIcons = { }; export function Content() { - const { - isInstalled, - installationStatus, - setIsInstalled, - setInstallationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - checkInstallation, - } = useInstallationStatus(); const { config, targets, @@ -42,15 +31,25 @@ export function Content() { resetAll, reload, } = useGameConfiguration(); - const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + install, + uninstall, + } = useInstallation(reload); const [tab, setTab] = useState("Setup"); + const previousRunningAppId = useRef(null); const setupComplete = isInstalled && losslessScalingInstalled && steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; - const previousRunningAppId = useRef(null); useEffect(() => { if (!setupComplete) { @@ -65,10 +64,9 @@ export function Content() { const appid = runningGame?.appid || null; const previous = previousRunningAppId.current; previousRunningAppId.current = appid; - if (appid && appid !== previous) { - setTab("NowPlaying"); - } else if (!appid && previous) { - setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab); + if (appid && appid !== previous) setTab("NowPlaying"); + else if (!appid && previous) { + setTab((current) => current === "NowPlaying" ? "Games" : current); } }, [runningGame?.appid, runningGame?.configured, setupComplete]); @@ -80,19 +78,9 @@ export function Content() { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], cleanupLaunchOptions = false, - ) => { - await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); - }; - - const onInstall = () => { - void handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); - }; - - const onUninstall = () => { - void handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); - }; + ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions); - const setupContent = ( + const setup = ( void install()} + onUninstall={() => void uninstall()} + flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")} /> ); @@ -115,7 +104,7 @@ export function Content() { handleConfigChange(fieldName, value)} + onConfigChange={(field, value) => handleConfigChange(field, value)} onEnable={enable} onRepair={repair} /> @@ -130,7 +119,7 @@ export function Content() { targets={targets} runningGame={runningGame} onSelect={setSelectedAppId} - onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} + onConfigChange={(field, value) => handleConfigChange(field, value, true)} onEnable={enable} onEnableAll={enableAll} onRepair={repair} @@ -139,16 +128,10 @@ export function Content() { /> ), }, - { - id: "ConfigFile", - title: tabIcons.configFile, - content: , - }, - { id: "Setup", title: tabIcons.setup, content: setupContent }, + { id: "ConfigFile", title: tabIcons.configFile, content: }, + { id: "Setup", title: tabIcons.setup, content: setup }, ] - : [ - { id: "Setup", title: tabIcons.setup, content: setupContent }, - ]; + : [{ id: "Setup", title: tabIcons.setup, content: setup }]; return (
void; - onUninstall: () => void; -} - -export function InstallationButton({ - isInstalled, - isInstalling, - isUninstalling, - onInstall, - onUninstall -}: InstallationButtonProps) { - const label = isInstalling - ? t('INSTALL_INSTALLING', 'Installing...') - : isUninstalling - ? t('INSTALL_UNINSTALLING', 'Uninstalling...') - : isInstalled - ? t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK') - : t('INSTALL_INSTALL_BTN', 'Install LSFG-VK'); - - return ( - - - {label} - - - ); -} diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 6bf1fad..e936049 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,4 +1,4 @@ -import { Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { getFlatpakSupportStatus, @@ -6,8 +6,7 @@ import { type FlatpakExtensionStatus, type SteamBranchStatus, } from "../api/lsfgApi"; -import { InstallationButton } from "./InstallationButton"; -import { StatusDisplay } from "./StatusDisplay"; +import t from "../i18n/i18n"; import { showErrorToast } from "../utils/toastUtils"; interface SetupTabProps { @@ -20,10 +19,12 @@ interface SetupTabProps { isUninstalling: boolean; onInstall: () => void; onUninstall: () => void; + flatpakRelevant: boolean; } -function FlatpakSupportDiagnostics() { +function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { const [status, setStatus] = useState(null); + const [advanced, setAdvanced] = useState(false); const [operation, setOperation] = useState(null); const refresh = async () => { @@ -43,16 +44,15 @@ function FlatpakSupportDiagnostics() { }; useEffect(() => { - void refresh(); - }, []); + if (relevant) void refresh(); + }, [relevant]); - if (!status?.available) return null; + if (!relevant || !status?.available) return null; - const runExtensionOperation = async (version: string, enabled: boolean) => { - const operationKey = `${enabled ? "enable" : "disable"}-${version}`; - setOperation(operationKey); + const setEnabled = async (branch: string, enabled: boolean) => { + setOperation(`${enabled ? "enable" : "disable"}-${branch}`); try { - const result = await setFlatpakExtensionEnabled(version, enabled); + const result = await setFlatpakExtensionEnabled(branch, enabled); if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); await refresh(); } catch (error) { @@ -62,70 +62,91 @@ function FlatpakSupportDiagnostics() { } }; - const handleExtensionToggle = (version: string, enabled: boolean) => { - void runExtensionOperation(version, enabled); - }; - return ( - + - {status.supported_branches.map((branch) => ( - - handleExtensionToggle(branch, enabled)} - disabled={operation !== null} - /> - - ))} + + setAdvanced((value) => !value)}> + {advanced ? "Hide runtime details" : "Show runtime details"} + + + {advanced && status.supported_branches.map((branch) => { + const installed = status.installed_branches.includes(branch); + const pending = operation?.endsWith(`-${branch}`); + return ( + + void setEnabled(branch, enabled)} + disabled={operation !== null} + /> + + ); + })} ); } -export function SetupTab({ - isInstalled, - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - isInstalling, - isUninstalling, - onInstall, - onUninstall, -}: SetupTabProps) { +export function SetupTab(props: SetupTabProps) { + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + onInstall, + onUninstall, + flatpakRelevant, + } = props; + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + const buttonLabel = isInstalling + ? t("INSTALL_INSTALLING", "Installing...") + : isUninstalling + ? t("INSTALL_UNINSTALLING", "Uninstalling...") + : isInstalled + ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") + : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); + return ( <> - - + + + + + + + {steamBranchStatus?.installed && ( + + + + )} + + + {buttonLabel} + + - + ); } diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx deleted file mode 100644 index b1a98e5..0000000 --- a/src/components/StatusDisplay.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Field, PanelSectionRow } from "@decky/ui"; -import type { SteamBranchStatus } from "../api/lsfgApi"; - -interface StatusDisplayProps { - installationStatus: string; - losslessScalingInstalled: boolean; - losslessScalingStatus: string; - steamBranchStatus: SteamBranchStatus | null; -} - -export function StatusDisplay({ - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus -}: StatusDisplayProps) { - const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; - - return ( - <> - - - - - - - - {steamBranchStatus?.installed && ( - - - - )} - - ); -} diff --git a/src/components/index.ts b/src/components/index.ts index 6856e76..bca6f6f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,4 @@ export { Content } from "./Content"; -export { StatusDisplay } from "./StatusDisplay"; -export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts deleted file mode 100644 index 41189bd..0000000 --- a/src/hooks/useInstallationActions.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { useState } from "react"; -import { installLsfgVk, uninstallLsfgVk } from "../api/lsfgApi"; -import { - showInstallSuccessToast, - showInstallErrorToast, - showUninstallSuccessToast, - showUninstallErrorToast -} from "../utils/toastUtils"; - -export function useInstallationActions() { - const [isInstalling, setIsInstalling] = useState(false); - const [isUninstalling, setIsUninstalling] = useState(false); - - const handleInstall = async ( - setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void, - reloadConfig?: () => Promise, - reloadStatus?: () => Promise - ) => { - setIsInstalling(true); - setInstallationStatus("Installing lsfg-vk..."); - - try { - const result = await installLsfgVk(); - if (result.success) { - setIsInstalled(true); - setInstallationStatus("lsfg-vk installed"); - showInstallSuccessToast(); - - // Reload lsfg config after installation - if (reloadConfig) { - await reloadConfig(); - } - if (reloadStatus) { - await reloadStatus(); - } - } else { - setInstallationStatus(`Installation failed: ${result.error}`); - showInstallErrorToast(result.error); - } - } catch (error) { - setInstallationStatus(`Installation failed: ${error}`); - showInstallErrorToast(String(error)); - } finally { - setIsInstalling(false); - } - }; - - const handleUninstall = async ( - setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void, - reloadStatus?: () => Promise - ) => { - setIsUninstalling(true); - setInstallationStatus("Uninstalling lsfg-vk..."); - - try { - const result = await uninstallLsfgVk(); - if (result.success) { - setIsInstalled(false); - setInstallationStatus("lsfg-vk uninstalled successfully!"); - if (reloadStatus) { - await reloadStatus(); - } - showUninstallSuccessToast(); - } else { - setInstallationStatus(`Uninstallation failed: ${result.error}`); - showUninstallErrorToast(result.error); - } - } catch (error) { - setInstallationStatus(`Uninstallation failed: ${error}`); - showUninstallErrorToast(String(error)); - } finally { - setIsUninstalling(false); - } - }; - - return { - isInstalling, - isUninstalling, - handleInstall, - handleUninstall - }; -} diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index 0b71ee9..73cfa0c 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -1,16 +1,26 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { checkLsfgVkInstalled, getLosslessScalingBranchStatus, - type SteamBranchStatus + installLsfgVk, + uninstallLsfgVk, + type SteamBranchStatus, } from "../api/lsfgApi"; +import { + showInstallErrorToast, + showInstallSuccessToast, + showUninstallErrorToast, + showUninstallSuccessToast, +} from "../utils/toastUtils"; -export function useInstallationStatus() { - const [isInstalled, setIsInstalled] = useState(false); - const [installationStatus, setInstallationStatus] = useState(""); - const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); - const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); +export function useInstallation(reloadConfig?: () => Promise) { + const [isInstalled, setIsInstalled] = useState(false); + const [installationStatus, setInstallationStatus] = useState(""); + const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); + const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); const [steamBranchStatus, setSteamBranchStatus] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [isUninstalling, setIsUninstalling] = useState(false); const checkInstallation = async () => { try { @@ -25,13 +35,9 @@ export function useInstallationStatus() { setIsInstalled(status.installed); setLosslessScalingInstalled(status.lossless_scaling_installed); setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed"); - if (status.installed) { - setInstallationStatus("lsfg-vk Installed"); - } else { - setInstallationStatus("lsfg-vk Not Installed"); - } + setInstallationStatus(status.installed ? "lsfg-vk Installed" : "lsfg-vk Not Installed"); return status.installed; - } catch (error) { + } catch { setSteamBranchStatus(null); setLosslessScalingInstalled(false); setLosslessScalingStatus("Lossless Scaling Not Installed"); @@ -41,17 +47,64 @@ export function useInstallationStatus() { }; useEffect(() => { - checkInstallation(); + void checkInstallation(); }, []); + const install = async () => { + setIsInstalling(true); + setInstallationStatus("Installing lsfg-vk..."); + try { + const result = await installLsfgVk(); + if (!result.success) { + setInstallationStatus(`Installation failed: ${result.error}`); + showInstallErrorToast(result.error); + return; + } + setIsInstalled(true); + setInstallationStatus("lsfg-vk installed"); + showInstallSuccessToast(); + await reloadConfig?.(); + await checkInstallation(); + } catch (error) { + setInstallationStatus(`Installation failed: ${error}`); + showInstallErrorToast(String(error)); + } finally { + setIsInstalling(false); + } + }; + + const uninstall = async () => { + setIsUninstalling(true); + setInstallationStatus("Uninstalling lsfg-vk..."); + try { + const result = await uninstallLsfgVk(); + if (!result.success) { + setInstallationStatus(`Uninstallation failed: ${result.error}`); + showUninstallErrorToast(result.error); + return; + } + setIsInstalled(false); + setInstallationStatus("lsfg-vk uninstalled successfully!"); + await checkInstallation(); + showUninstallSuccessToast(); + } catch (error) { + setInstallationStatus(`Uninstallation failed: ${error}`); + showUninstallErrorToast(String(error)); + } finally { + setIsUninstalling(false); + } + }; + return { isInstalled, installationStatus, - setIsInstalled, - setInstallationStatus, losslessScalingInstalled, losslessScalingStatus, steamBranchStatus, - checkInstallation + isInstalling, + isUninstalling, + install, + uninstall, + checkInstallation, }; } diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index e00b32d..0af1bbf 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -12,27 +12,14 @@ export const LEGACY_WRAPPER_TOKENS = new Set([ ]); const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/; - const MANAGED_ENV_KEYS = new Set([ - "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", + "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", ]); - const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i; -interface LaunchToken { - raw: string; - value: string; -} - +interface LaunchToken { raw: string; value: string; } export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; @@ -40,42 +27,33 @@ export interface SteamLaunchOptionsSnapshot { target: string; details: SteamAppDetails; } - export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; originalExecutable?: string; commandTokenAdded: boolean; } -function validateAppId(appId: number): void { - if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); } -function getSteamApps(): Partial | undefined { - return (globalThis as typeof globalThis & { - SteamClient?: { Apps?: Partial }; - }).SteamClient?.Apps; +function apps(): Partial | undefined { + return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial } }).SteamClient?.Apps; } -interface TimerHost { - setTimeout(handler: () => void, timeout: number): number; - clearTimeout(timeout: number): void; +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } -function timerHost(): TimerHost { - if (typeof window !== "undefined") { - return { - setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), - clearTimeout: (timeout) => window.clearTimeout(timeout), - }; - } +function timer() { + const host = typeof window !== "undefined" ? window : globalThis; return { - setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, - clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number, + clear: (id: number) => host.clearTimeout(id), }; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { +function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { return { appId, nonSteam, @@ -85,73 +63,39 @@ function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamApp }; } -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function registerSteamAppDetails( - appId: number, - onDetails: (details: SteamAppDetails) => boolean | void, -): () => void { +function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void { validateAppId(appId); - const apps = getSteamApps(); - const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); - + const register = apps()?.RegisterForAppDetails; + if (!register) throw new Error("Steam app-details API is unavailable"); let active = true; - let unregisterPending = false; let registration: SteamAppDetailsRegistration | undefined; const unsubscribe = () => { active = false; - if (!registration) { - unregisterPending = true; - return; - } - try { - registration.unregister(); - } catch { - // Steam can invalidate a registration while details are refreshing. - } + try { registration?.unregister(); } catch {} }; - - try { - registration = registerForAppDetails.call(apps, appId, (details) => { - if (!active) return; - if (onDetails(details || {}) === false && active) unsubscribe(); - }); - if (unregisterPending) { - try { - registration.unregister(); - } catch { - // A synchronous callback can invalidate the registration before return. - } - } - } catch (error) { - throw asError(error); - } + registration = register.call(apps(), appId, (details) => { + if (active && onDetails(details || {}) === false) unsubscribe(); + }); + if (!active) unsubscribe(); return unsubscribe; } export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise { return new Promise((resolve, reject) => { - let settled = false; - let timeout: number | undefined; + let done = false; let unsubscribe = () => {}; + const clock = timer(); + const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000); const finish = (error?: unknown, details?: SteamAppDetails) => { - if (settled) return; - settled = true; - if (timeout !== undefined) timerHost().clearTimeout(timeout); + if (done) return; + done = true; + clock.clear(timeout); unsubscribe(); - if (error) { - reject(asError(error)); - return; - } - resolve(snapshotFromDetails(appId, nonSteam, details || {})); + if (error) reject(asError(error)); + else resolve(snapshot(appId, nonSteam, details || {})); }; - - timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { - unsubscribe = registerSteamAppDetails(appId, (details) => { + unsubscribe = registerDetails(appId, (details) => { finish(undefined, details); return false; }); @@ -164,34 +108,24 @@ export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): export function subscribeSteamLaunchOptions( appId: number, nonSteam: boolean, - onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void, + onSnapshot: (value: SteamLaunchOptionsSnapshot) => void, onError: (error: Error) => void, ): () => void { - return registerSteamAppDetails(appId, (details) => { - try { - onSnapshot(snapshotFromDetails(appId, nonSteam, details)); - } catch (error) { - onError(asError(error)); - } + return registerDetails(appId, (details) => { + try { onSnapshot(snapshot(appId, nonSteam, details)); } + catch (error) { onError(asError(error)); } }); } function decodeToken(raw: string): string { let value = ""; let quote: "'" | '"' | null = null; - for (let index = 0; index < raw.length; index += 1) { - const character = raw[index]; - if (character === "\\" && quote !== "'" && index + 1 < raw.length) { - value += raw[index + 1]; - index += 1; - } else if (quote !== null) { - if (character === quote) quote = null; - else value += character; - } else if (character === "'" || character === '"') { - quote = character; - } else { - value += character; - } + for (let i = 0; i < raw.length; i++) { + const c = raw[i]; + if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i]; + else if (quote) { if (c === quote) quote = null; else value += c; } + else if (c === "'" || c === '"') quote = c; + else value += c; } return value; } @@ -201,299 +135,184 @@ function tokenize(options: string): LaunchToken[] { let start = -1; let quote: "'" | '"' | null = null; let escaped = false; - for (let index = 0; index < options.length; index += 1) { - const character = options[index]; - if (start < 0) { - if (/\s/.test(character)) continue; - start = index; - } - if (escaped) escaped = false; - else if (character === "\\" && quote !== "'") escaped = true; - else if (quote !== null) { - if (character === quote) quote = null; - } else if (character === "'" || character === '"') quote = character; - else if (/\s/.test(character)) { - const raw = options.slice(start, index); - tokens.push({ raw, value: decodeToken(raw) }); - start = -1; - } - } - if (start >= 0) { - const raw = options.slice(start); + const push = (end: number) => { + if (start < 0) return; + const raw = options.slice(start, end); tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + }; + for (let i = 0; i < options.length; i++) { + const c = options[i]; + if (start < 0) { if (/\s/.test(c)) continue; start = i; } + if (escaped) escaped = false; + else if (c === "\\" && quote !== "'") escaped = true; + else if (quote) { if (c === quote) quote = null; } + else if (c === "'" || c === '"') quote = c; + else if (/\s/.test(c)) push(i); } + push(options.length); return tokens; } -function serialize(tokens: readonly LaunchToken[]): string { - return tokens.map((token) => token.raw).join(" "); -} - -export function normalizeLaunchOptions(options: string): string { - return serialize(tokenize(options)); -} - -function isCommandToken(token: LaunchToken): boolean { - return token.raw.toLowerCase() === COMMAND_TOKEN; -} +const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); +const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); +const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -function commandIndex(tokens: readonly LaunchToken[]): number { - return tokens.findIndex(isCommandToken); -} +export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); +export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); -function isAssignment(token: LaunchToken): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); -} - -function isLegacyToken(value: string): boolean { - return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); -} - -export function isLegacyWrapperToken(value: string): boolean { - return isLegacyToken(decodeToken(value)); -} - -function isWrapperToken(value: string, wrapperPath: string): boolean { - return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -} - -function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean { - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); +function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean { + const command = commandIndex(tokens); + const prefixEnd = command >= 0 ? command : tokens.length; + const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value)); + if (kept.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...kept); return true; } -function removeLegacyTokens(tokens: LaunchToken[]): boolean { - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -function leadingAssignments(tokens: readonly LaunchToken[]): number { - let count = 0; - while (count < tokens.length && isAssignment(tokens[count])) count += 1; - return count; -} - -function wrapperToken(wrapperPath: string): LaunchToken { - return { raw: wrapperPath, value: wrapperPath }; -} - -export interface LaunchOptionRewrite { - options: string; - commandTokenAdded: boolean; -} - -/** Add one exact wrapper token immediately before Steam's command macro. */ -export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite { +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { const tokens = tokenize(options); - removeLegacyTokens(tokens); - let index = commandIndex(tokens); - if (index >= 0) { - const currentWrapper = tokens[index - 1]; - if (currentWrapper && currentWrapper.value === wrapperPath) { - return { options: serialize(tokens), commandTokenAdded: false }; - } - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath); - tokens.splice(0, tokens.length, ...retained); - index = commandIndex(tokens); - tokens.splice(index, 0, wrapperToken(wrapperPath)); + removeMatchingWrappers(tokens, isLegacyToken); + let command = commandIndex(tokens); + if (command >= 0) { + if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false }; + removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath); + command = commandIndex(tokens); + tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath }); return { options: serialize(tokens), commandTokenAdded: false }; } - - const insertion = leadingAssignments(tokens); - const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); - if (tokens.length !== insertion && !argumentsOnly) { + let insertion = 0; + while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; + if (insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } - tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + tokens.splice(insertion, 0, + { raw: wrapperPath, value: wrapperPath }, + { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, + ); return { options: serialize(tokens), commandTokenAdded: true }; } -/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */ export function removeWrapperLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, commandTokenAdded = false, ): string { const tokens = tokenize(options); - const removed = removeWrapperTokens(tokens, wrapperPath); - if (removed && commandTokenAdded) { - const index = commandIndex(tokens); - if (index >= 0) tokens.splice(index, 1); + if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) { + const command = commandIndex(tokens); + if (command >= 0) tokens.splice(command, 1); } return serialize(tokens); } function encodeAssignmentValue(value: string): string { - if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function cleanDxvkConfigValue(value: string): string | null { - const retained = value - .split(";") - .map((segment) => segment.trim()) - .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment)); - return retained.length > 0 ? retained.join("; ") : null; + return /^[A-Za-z0-9_./:+,%=-]+$/.test(value) + ? value + : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */ export function cleanupPluginAssignments(options: string): string { const tokens = tokenize(options); - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained: LaunchToken[] = []; - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { - const token = tokens[tokenIndex]; - if (tokenIndex >= prefixEnd || !isAssignment(token)) { - retained.push(token); - continue; - } - const separator = token.value.indexOf("="); - const key = token.value.slice(0, separator); + const command = commandIndex(tokens); + const prefixEnd = command >= 0 ? command : tokens.length; + return serialize(tokens.flatMap((token, i) => { + if (i >= prefixEnd || !isAssignment(token)) return [token]; + const split = token.value.indexOf("="); + const key = token.value.slice(0, split); if (key === "DXVK_CONFIG") { - const cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); - if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); - continue; + const value = token.value.slice(split + 1).split(";").map((part) => part.trim()) + .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; "); + return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : []; } - if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); - } - return serialize(retained); + return MANAGED_ENV_KEYS.has(key) ? [] : [token]; + })); } export function cleanupLegacyLaunchOptions(options: string): string { const tokens = tokenize(options); - removeLegacyTokens(tokens); + removeMatchingWrappers(tokens, isLegacyToken); return serialize(tokens); } - -export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { - const tokens = tokenize(options); - removeWrapperTokens(tokens, wrapperPath); - return cleanupPluginAssignments(serialize(tokens)); -} - -export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { - return cleanupPluginLaunchOptions(options, wrapperPath); -} - +export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) => + cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath)); +export const cleanupLegacyWrapper = cleanupPluginLaunchOptions; export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { const tokens = tokenize(options); - const index = commandIndex(tokens); - return index > 0 && tokens[index - 1].value === wrapperPath; -} - -function delay(milliseconds: number): Promise { - return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); -} - -async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise { - const apps = getSteamApps(); - const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; - if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`); - await Promise.resolve(setter.call(apps, appId, options)); + const command = commandIndex(tokens); + return command > 0 && tokens[command - 1].value === wrapperPath; } -async function setShortcutExecutable(appId: number, executable: string): Promise { - const apps = getSteamApps(); - if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); - await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); +const queues = new Map>(); +function queued(appId: number, nonSteam: boolean, operation: () => Promise): Promise { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; + const previous = queues.get(key) || Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const cleanup = current.then( + () => { if (queues.get(key) === cleanup) queues.delete(key); }, + () => { if (queues.get(key) === cleanup) queues.delete(key); }, + ); + queues.set(key, cleanup); + return current; } -async function waitForSnapshot( +async function waitFor( appId: number, nonSteam: boolean, - matches: (snapshot: SteamLaunchOptionsSnapshot) => boolean, + matches: (value: SteamLaunchOptionsSnapshot) => boolean, message: string, ): Promise { const deadline = Date.now() + 5000; let lastError: Error | null = null; while (Date.now() <= deadline) { try { - const snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (matches(snapshot)) return snapshot; - } catch (error) { - lastError = asError(error); - } - if (Date.now() >= deadline) break; - await delay(100); + const value = await readSteamLaunchOptions(appId, nonSteam); + if (matches(value)) return value; + } catch (error) { lastError = asError(error); } + if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100)); } - if (lastError) throw new Error(`${message}: ${lastError.message}`); - throw new Error(`${message} before the readback timeout`); + throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`); } -async function writeLaunchOptionsAndVerify( +async function writeVerified( appId: number, nonSteam: boolean, previous: string, next: string, + write: (value: string) => Promise, + read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise { + const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; try { - await setSteamLaunchOptions(appId, nonSteam, next); - return await waitForSnapshot( - appId, - nonSteam, - (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next), - message, - ); - } catch (error) { - const failure = asError(error); - try { - await setSteamLaunchOptions(appId, nonSteam, previous); - await waitForSnapshot( - appId, - nonSteam, - (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous), - "Steam did not restore the previous launch options", - ); - } catch (rollbackError) { - throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); - } - throw failure; - } -} - -async function writeShortcutExecutableAndVerify( - appId: number, - previous: string, - next: string, - message: string, -): Promise { - try { - await setShortcutExecutable(appId, next); - return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + await write(next); + return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); } catch (error) { const failure = asError(error); try { - await setShortcutExecutable(appId, previous); - await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target"); - } catch (rollbackError) { - throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + await write(previous); + await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + } catch (rollback) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); } throw failure; } } -const operationQueues = new Map>(); +const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; +const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; -function queueSteamOperation(appId: number, nonSteam: boolean, operation: () => Promise): Promise { - const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; - const previous = operationQueues.get(key) || Promise.resolve(); - const queued = previous.catch(() => undefined).then(operation); - const cleanup = queued.then( - () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, - () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, - ); - operationQueues.set(key, cleanup); - return queued; +function writeOptions(appId: number, nonSteam: boolean, value: string): Promise { + const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions; + if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`)); + return Promise.resolve(setter.call(apps(), appId, value)); +} +function writeTarget(appId: number, value: string): Promise { + const setter = apps()?.SetShortcutExe; + if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); + return Promise.resolve(setter.call(apps(), appId, value)); } export function updateSteamLaunchOptions( @@ -501,11 +320,14 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise { - return queueSteamOperation(appId, nonSteam, async () => { + return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); + return next === current.options ? current : writeVerified( + appId, nonSteam, current.options, next, + (value) => writeOptions(appId, nonSteam, value), readOptions, + "Steam did not accept the launch options", + ); }); } @@ -515,33 +337,41 @@ export function installWrapperIntegration( wrapperPath: string, commandTokenAdded = false, ): Promise { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); + return queued(appId, nonSteam, async () => { + let current = await readSteamLaunchOptions(appId, nonSteam); if (nonSteam) { if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); } - const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleanedOptions !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options"); - } - if (current.target === wrapperPath) { - return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false }; + const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); + if (cleaned !== current.options) { + current = await writeVerified( + appId, true, current.options, cleaned, + (value) => writeOptions(appId, true, value), readOptions, + "Steam did not accept shortcut launch options", + ); } + if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false }; const originalExecutable = current.target; - const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target"); - return { snapshot, originalExecutable, commandTokenAdded: false }; + const value = await writeVerified( + appId, true, originalExecutable, wrapperPath, + (target) => writeTarget(appId, target), readTarget, + "Steam did not accept the shortcut Target", + ); + return { snapshot: value, originalExecutable, commandTokenAdded: false }; } const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); - if (rewrite.options === current.options) { - return { snapshot: current, commandTokenAdded }; - } - const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options"); - return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + const value = await writeVerified( + appId, false, current.options, rewrite.options, + (options) => writeOptions(appId, false, options), readOptions, + "Steam did not accept the launch options", + ); + return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; }); } @@ -552,8 +382,8 @@ export function removeWrapperIntegration( originalExecutable?: string, commandTokenAdded = false, ): Promise { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); + return queued(appId, nonSteam, async () => { + let current = await readSteamLaunchOptions(appId, nonSteam); if (nonSteam) { if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); @@ -563,34 +393,32 @@ export function removeWrapperIntegration( } const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); + current = await writeVerified( + appId, true, current.options, cleaned, + (value) => writeOptions(appId, true, value), readOptions, + "Steam did not clean shortcut launch options", + ); } - if (current.target === originalExecutable) { - return readSteamLaunchOptions(appId, true); - } - return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target"); + if (current.target === originalExecutable) return current; + return writeVerified( + appId, true, wrapperPath, originalExecutable, + (target) => writeTarget(appId, target), readTarget, + "Steam did not restore the shortcut Target", + ); } - - const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); - const next = cleanupPluginAssignments(withoutWrapper); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); + const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); + return next === current.options ? current : writeVerified( + appId, false, current.options, next, + (options) => writeOptions(appId, false, options), readOptions, + "Steam did not clean the launch options", + ); }); } -export function cleanupLegacySteamLaunchOptions( +export const cleanupLegacySteamLaunchOptions = ( appId: number, nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, -): Promise { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); - }); -} +) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath)); -export function getDefaultWrapperPath(): string { - return DEFAULT_WRAPPER_PATH; -} +export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH; diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index cbbbc55..3468064 100644 --- a/src/utils/toastUtils.ts +++ b/src/utils/toastUtils.ts @@ -1,8 +1,3 @@ -/** - * Centralized toast notification utilities - * Provides consistent success/error messaging patterns - */ - import { toaster } from "@decky/api"; export interface ToastOptions { @@ -10,84 +5,44 @@ export interface ToastOptions { body: string; } -/** - * Show a success toast notification - */ -export function showSuccessToast(title: string, body: string): void { - toaster.toast({ - title, - body - }); -} +const showToast = (title: string, body: string): void => toaster.toast({ title, body }); +export const showSuccessToast = showToast; +export const showErrorToast = showToast; -/** - * Show an error toast notification - */ -export function showErrorToast(title: string, body: string): void { - toaster.toast({ - title, - body - }); -} - -/** - * Standard success messages for common operations - */ export const ToastMessages = { INSTALL_SUCCESS: { title: "Installation Complete", - body: "lsfg-vk has been installed successfully" + body: "lsfg-vk has been installed successfully", }, INSTALL_ERROR: { title: "Installation Failed", - body: "Unknown error occurred" + body: "Unknown error occurred", }, UNINSTALL_SUCCESS: { - title: "Uninstallation Complete", - body: "lsfg-vk has been uninstalled successfully" + title: "Uninstallation Complete", + body: "lsfg-vk has been uninstalled successfully", }, UNINSTALL_ERROR: { title: "Uninstallation Failed", - body: "Unknown error occurred" + body: "Unknown error occurred", }, CONFIG_UPDATE_ERROR: { title: "Update Failed", - body: "Failed to update configuration" - } + body: "Failed to update configuration", + }, } as const; -/** - * Show a toast with dynamic error message - */ -export function showErrorToastWithMessage(title: string, error: unknown): void { - const errorMessage = error instanceof Error ? error.message : String(error); - showErrorToast(title, errorMessage); -} +export const showErrorToastWithMessage = (title: string, error: unknown): void => + showErrorToast(title, error instanceof Error ? error.message : String(error)); -/** - * Show installation success toast - */ -export function showInstallSuccessToast(): void { +export const showInstallSuccessToast = (): void => showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body); -} -/** - * Show installation error toast - */ -export function showInstallErrorToast(error?: string): void { +export const showInstallErrorToast = (error?: string): void => showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body); -} -/** - * Show uninstallation success toast - */ -export function showUninstallSuccessToast(): void { +export const showUninstallSuccessToast = (): void => showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body); -} -/** - * Show uninstallation error toast - */ -export function showUninstallErrorToast(error?: string): void { +export const showUninstallErrorToast = (error?: string): void => showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body); -} -- 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(-) 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 6a927e30f743fac0a5451381ceed7f1d83e94a37 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 07:47:39 -0400 Subject: fix: clean up simple migration type errors --- src/hooks/useLsfgHooks.ts | 4 ++-- src/utils/toastUtils.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index 73cfa0c..9beb749 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -57,7 +57,7 @@ export function useInstallation(reloadConfig?: () => Promise) { const result = await installLsfgVk(); if (!result.success) { setInstallationStatus(`Installation failed: ${result.error}`); - showInstallErrorToast(result.error); + showInstallErrorToast(result.error ?? undefined); return; } setIsInstalled(true); @@ -80,7 +80,7 @@ export function useInstallation(reloadConfig?: () => Promise) { const result = await uninstallLsfgVk(); if (!result.success) { setInstallationStatus(`Uninstallation failed: ${result.error}`); - showUninstallErrorToast(result.error); + showUninstallErrorToast(result.error ?? undefined); return; } setIsInstalled(false); diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index 3468064..c41f4c0 100644 --- a/src/utils/toastUtils.ts +++ b/src/utils/toastUtils.ts @@ -5,7 +5,9 @@ export interface ToastOptions { body: string; } -const showToast = (title: string, body: string): void => toaster.toast({ title, body }); +const showToast = (title: string, body: string): void => { + toaster.toast({ title, body }); +}; export const showSuccessToast = showToast; export const showErrorToast = showToast; -- cgit v1.2.3 From 840ea2802551a786de9b59f7aa00f04c5f335e2a Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 07:57:03 -0400 Subject: fix: keep Flatpak runtime controls simple --- src/components/Content.tsx | 1 - src/components/SetupTab.tsx | 26 +++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index d92ef86..e5216ca 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -91,7 +91,6 @@ export function Content() { isUninstalling={isUninstalling} onInstall={() => void install()} onUninstall={() => void uninstall()} - flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")} /> ); diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index e936049..624be54 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -19,12 +19,10 @@ interface SetupTabProps { isUninstalling: boolean; onInstall: () => void; onUninstall: () => void; - flatpakRelevant: boolean; } -function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { +function FlatpakSupportDiagnostics() { const [status, setStatus] = useState(null); - const [advanced, setAdvanced] = useState(false); const [operation, setOperation] = useState(null); const refresh = async () => { @@ -44,10 +42,10 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { }; useEffect(() => { - if (relevant) void refresh(); - }, [relevant]); + void refresh(); + }, []); - if (!relevant || !status?.available) return null; + if (!status?.available) return null; const setEnabled = async (branch: string, enabled: boolean) => { setOperation(`${enabled ? "enable" : "disable"}-${branch}`); @@ -63,19 +61,14 @@ function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { }; return ( - + - - setAdvanced((value) => !value)}> - {advanced ? "Hide runtime details" : "Show runtime details"} - - - {advanced && status.supported_branches.map((branch) => { + {status.supported_branches.map((branch) => { const installed = status.installed_branches.includes(branch); const pending = operation?.endsWith(`-${branch}`); return ( @@ -105,7 +98,6 @@ export function SetupTab(props: SetupTabProps) { isUninstalling, onInstall, onUninstall, - flatpakRelevant, } = props; const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; const buttonLabel = isInstalling @@ -146,7 +138,7 @@ export function SetupTab(props: SetupTabProps) { - + ); } -- cgit v1.2.3 From e6efa4f0ff32d520982860180a29472068aab8e2 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 09:53:55 -0400 Subject: simplify unhooked game behavior on mount --- src/components/ConfigurationTab.tsx | 14 ------------- src/components/Content.tsx | 9 ++++---- src/components/NowPlayingTab.tsx | 42 ++++++------------------------------- 3 files changed, 10 insertions(+), 55 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 8f8690c..8150819 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -37,7 +37,6 @@ export function ConfigurationTab({ const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null); const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false); const enableRef = useRef(null); - const promptedRunningAppId = useRef(null); const closeDetails = useCallback(() => { setFocusFpsMultiplier(false); setFocusDetailAction(null); @@ -60,18 +59,6 @@ export function ConfigurationTab({ return () => cancelAnimationFrame(frame); }, [focusDetailAction]); - useEffect(() => { - if (!runningGame || runningGame.configured) { - promptedRunningAppId.current = null; - return; - } - if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) { - promptedRunningAppId.current = runningGame.appid; - setFocusDetailAction("enable"); - setDetailAppId(runningGame.appid); - } - }, [detailAppId, runningGame?.appid, runningGame?.configured]); - const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null; if (detailAppId === null) { @@ -106,7 +93,6 @@ export function ConfigurationTab({ : "Game is no longer available"; const handleProfileAction = async () => { if (selectedTarget?.configured) { - promptedRunningAppId.current = detailAppId; await onReset(); setFocusConfiguredToggle(true); closeDetails(); diff --git a/src/components/Content.tsx b/src/components/Content.tsx index e5216ca..578f984 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -56,15 +56,15 @@ export function Content() { setTab("Setup"); return; } - setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current); - }, [runningGame?.appid, setupComplete]); + setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Games") : current); + }, [runningGame?.appid, runningGame?.configured, setupComplete]); useEffect(() => { if (!setupComplete) return; const appid = runningGame?.appid || null; const previous = previousRunningAppId.current; previousRunningAppId.current = appid; - if (appid && appid !== previous) setTab("NowPlaying"); + if (appid && appid !== previous) setTab(runningGame?.configured ? "NowPlaying" : "Games"); else if (!appid && previous) { setTab((current) => current === "NowPlaying" ? "Games" : current); } @@ -96,7 +96,7 @@ export function Content() { const tabs = setupComplete ? [ - ...(runningGame ? [{ + ...(runningGame?.configured ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: ( @@ -104,7 +104,6 @@ export function Content() { game={runningGame} config={config} onConfigChange={(field, value) => handleConfigChange(field, value)} - onEnable={enable} onRepair={repair} /> ), diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 5bce5c4..57858db 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -11,7 +11,6 @@ interface Props { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], ) => Promise; - onEnable: (appid: string) => Promise; onRepair: (appid: string) => Promise; } @@ -24,25 +23,13 @@ export function NowPlayingTab({ game, config, onConfigChange, - onEnable, onRepair, }: Props) { const [busy, setBusy] = useState(false); const supportNeedsRepair = - game.configured && game.transport.kind === "flatpak" && game.flatpakSupport?.support_status !== "ready"; - const handleEnable = async () => { - if (busy) return; - setBusy(true); - try { - await onEnable(game.appid); - } finally { - setBusy(false); - } - }; - const handleRepair = async () => { if (busy) return; setBusy(true); @@ -60,22 +47,7 @@ export function NowPlayingTab({ - {!game.configured && ( - - - - - - void handleEnable()}> - {busy ? "Enabling..." : "Enable LSFG-VK"} - - - - )} - {game.configured && supportNeedsRepair && ( + {supportNeedsRepair && ( )} - {game.configured && ( - - )} + ); } -- cgit v1.2.3 From 209ab92cc3baeb15ce808fc53ce9041a761d8df4 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 10:33:08 -0400 Subject: only warn quit on real steam games --- src/components/ConfigurationTab.tsx | 30 +++++++++++++++++++++++++++--- src/types.d.ts | 1 + 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 8150819..5ae4a87 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; @@ -91,13 +91,37 @@ export function ConfigurationTab({ const profileDescription = selectedTarget ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` : "Game is no longer available"; + const enableProfile = async (appid: string, quitRunningGame = false) => { + if (!(await onEnable(appid))) return; + if (quitRunningGame) SteamClient.Apps.TerminateApp(appid, false); + setFocusFpsMultiplier(true); + }; const handleProfileAction = async () => { if (selectedTarget?.configured) { await onReset(); setFocusConfiguredToggle(true); closeDetails(); - } else if (detailAppId && await onEnable(detailAppId)) { - setFocusFpsMultiplier(true); + } else if (detailAppId) { + const isRunningUnconfigured = runningGame?.appid === detailAppId + && runningGame.nonSteam === false + && runningGame.transport.kind === "host" + && selectedTarget?.nonSteam === false + && selectedTarget?.transport.kind === "host" + && !runningGame.configured; + if (isRunningUnconfigured) { + showModal( + void enableProfile(detailAppId, true)} + onCancel={() => void enableProfile(detailAppId)} + />, + ); + } else { + await enableProfile(detailAppId); + } } }; diff --git a/src/types.d.ts b/src/types.d.ts index df433e0..e5db40d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -32,6 +32,7 @@ interface SteamApps { SetAppLaunchOptions(appId: number, options: string): void | Promise; SetShortcutLaunchOptions(appId: number, options: string): void | Promise; SetShortcutExe(appId: number, executable: string): void | Promise; + TerminateApp(appId: string, param1: boolean): void; GetAllShortcuts?(): Promise; } -- 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 +++++++++++++---- src/components/SetupTab.tsx | 128 +++++-------------------- src/hooks/useGameConfiguration.ts | 39 ++++---- src/hooks/usePerAppWorkarounds.ts | 35 ++++--- src/utils/steamLaunchOptions.ts | 24 +++-- tests/steamLaunchOptions.test.ts | 62 +++++++++++- tests/test_flatpak_service.py | 174 ++++++++++++++++++---------------- 7 files changed, 306 insertions(+), 244 deletions(-) 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, diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 624be54..d769200 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,13 +1,6 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; -import { useEffect, useState } from "react"; -import { - getFlatpakSupportStatus, - setFlatpakExtensionEnabled, - type FlatpakExtensionStatus, - type SteamBranchStatus, -} from "../api/lsfgApi"; +import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; +import { type SteamBranchStatus } from "../api/lsfgApi"; import t from "../i18n/i18n"; -import { showErrorToast } from "../utils/toastUtils"; interface SetupTabProps { isInstalled: boolean; @@ -21,72 +14,6 @@ interface SetupTabProps { onUninstall: () => void; } -function FlatpakSupportDiagnostics() { - const [status, setStatus] = useState(null); - const [operation, setOperation] = useState(null); - - const refresh = async () => { - try { - setStatus(await getFlatpakSupportStatus()); - } catch (error) { - setStatus({ - success: false, - message: "", - error: String(error), - available: false, - extension_id: "", - supported_branches: [], - installed_branches: [], - }); - } - }; - - useEffect(() => { - void refresh(); - }, []); - - if (!status?.available) return null; - - const setEnabled = async (branch: string, enabled: boolean) => { - setOperation(`${enabled ? "enable" : "disable"}-${branch}`); - try { - const result = await setFlatpakExtensionEnabled(branch, enabled); - if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); - await refresh(); - } catch (error) { - showErrorToast("Flatpak runtime update failed", String(error)); - } finally { - setOperation(null); - } - }; - - return ( - - - - - {status.supported_branches.map((branch) => { - const installed = status.installed_branches.includes(branch); - const pending = operation?.endsWith(`-${branch}`); - return ( - - void setEnabled(branch, enabled)} - disabled={operation !== null} - /> - - ); - })} - - ); -} - export function SetupTab(props: SetupTabProps) { const { isInstalled, @@ -109,36 +36,33 @@ export function SetupTab(props: SetupTabProps) { : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); return ( - <> - + + + + + + + + {steamBranchStatus?.installed && ( - - - - {steamBranchStatus?.installed && ( - - - - )} - - - {buttonLabel} - - - - - + )} + + + {buttonLabel} + + + ); } diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index c70d589..e120426 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -48,11 +48,6 @@ function selectShortcutExecutable( .map((candidate) => candidate?.trim()) .find((candidate) => candidate && candidate.startsWith("/")); if (absolute) return absolute; - - // Steam's app-details API can report a Flatpak Target as just "flatpak" - // even when the shortcut's canonical VDF executable is /usr/bin/flatpak. - // Keep the stored original executable absolute so SetShortcutExe and the - // generated dispatcher agree on the same direct transport. if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; return candidates.map((candidate) => candidate?.trim()).find(Boolean); } @@ -158,25 +153,26 @@ export function useGameConfiguration() { const oldShortcutExe = existing.shortcut_exe || undefined; const oldCommandTokenAdded = existing.command_token_added === true; const oldTransport = existing.transport || target.transport; - if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) { + const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; + if (usesShortcutTarget && oldState && current.target === wrapperPath && !oldShortcutExe) { throw new Error("Managed shortcut Target has no saved original executable"); } - if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { + if (usesShortcutTarget && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { throw new Error("Shortcut Target changed externally; refusing to replace it"); } - if (target.nonSteam && !oldState && current.target === wrapperPath) { + if (usesShortcutTarget && !oldState && current.target === wrapperPath) { throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); } const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = target.nonSteam + const originalExecutable = usesShortcutTarget ? selectShortcutExecutable( target, oldShortcutExe, - target.transport.kind === "flatpak" ? target.executable : undefined, + target.executable, current.target, ) : undefined; - const initialIntegration = target.nonSteam + const initialIntegration = usesShortcutTarget ? current.target === wrapperPath : hasWrapperLaunchIntegration(current.options, wrapperPath); const initialStateResult = await setWorkaroundState( @@ -190,16 +186,22 @@ export function useGameConfiguration() { let integration: Awaited> | null = null; try { - integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded); + integration = await installWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + oldCommandTokenAdded, + target.transport.kind, + ); const finalStateResult = await setWorkaroundState( target.appid, state, - target.nonSteam + usesShortcutTarget ? (selectShortcutExecutable( target, integration.originalExecutable, originalExecutable, - target.transport.kind === "flatpak" ? target.executable : undefined, + target.executable, ) || null) : null, integration.commandTokenAdded, @@ -215,15 +217,16 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - target.nonSteam + usesShortcutTarget ? (selectShortcutExecutable( target, integration?.originalExecutable, originalExecutable, - target.transport.kind === "flatpak" ? target.executable : undefined, + target.executable, ) || undefined) : undefined, integration?.commandTokenAdded ?? oldCommandTokenAdded, + target.transport.kind, ); } catch (rollbackError) { showErrorToast("Workaround rollback failed", asError(rollbackError).message); @@ -257,6 +260,7 @@ export function useGameConfiguration() { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; if (existing.state) { await removeWrapperIntegration( appId, @@ -264,10 +268,11 @@ export function useGameConfiguration() { wrapperPath, existing.shortcut_exe || undefined, existing.command_token_added === true, + target.transport.kind, ); } else { const current = await readSteamLaunchOptions(appId, target.nonSteam); - if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { + if (usesShortcutTarget && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); } await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index e9e44e1..ebb12e2 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -73,22 +73,31 @@ function selectShortcutExecutable( return candidates.map((candidate) => candidate?.trim()).find(Boolean); } +function usesShortcutTarget(nonSteam: boolean, transport: TargetTransport): boolean { + return nonSteam && transport.kind === "flatpak"; +} + function integrationIsInstalled( steam: SteamLaunchOptionsSnapshot, nonSteam: boolean, + transport: TargetTransport, wrapperPath: string, ): boolean { - return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath); + return usesShortcutTarget(nonSteam, transport) + ? steam.target === wrapperPath + : hasWrapperLaunchIntegration(steam.options, wrapperPath); } function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited>, nonSteam: boolean, + transport: TargetTransport, ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); - if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) { + const selectedTransport = result.transport || transport; + if (usesShortcutTarget(nonSteam, selectedTransport) && steam.target === wrapperPath && !result.shortcut_exe) { throw new Error("Managed shortcut Target has no saved original executable"); } return { @@ -96,10 +105,10 @@ function makeSnapshot( state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath), + integrationInstalled: integrationIsInstalled(steam, nonSteam, selectedTransport, wrapperPath), commandTokenAdded: result.command_token_added === true, shortcutExe: result.shortcut_exe, - transport: result.transport || { kind: "host" }, + transport: selectedTransport, }; } @@ -110,10 +119,11 @@ async function adoptWorkaroundState( steam: SteamLaunchOptionsSnapshot, wrapperPath: string, ): Promise { - if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { + const shortcutTarget = usesShortcutTarget(nonSteam, transport); + if (shortcutTarget && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); } - const originalExecutable = nonSteam + const originalExecutable = shortcutTarget ? selectShortcutExecutable(transport, steam.target) : null; const initial = await setWorkaroundState( @@ -130,18 +140,20 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, + false, + transport.kind, ); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - nonSteam + shortcutTarget ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) : null, integration.commandTokenAdded, transport, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); - return makeSnapshot(integration.snapshot, finalized, nonSteam); + return makeSnapshot(integration.snapshot, finalized, nonSteam, transport); } catch (error) { let rollbackSucceeded = true; if (integration) { @@ -150,10 +162,11 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, - nonSteam + shortcutTarget ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) : undefined, integration?.commandTokenAdded ?? false, + transport.kind, ); } catch { // Leave the owned integration in place rather than guessing at cleanup. @@ -194,7 +207,7 @@ export function usePerAppWorkarounds( result.wrapper_path || getDefaultWrapperPath(), ); } - return makeSnapshot(steam, result, nonSteam); + return makeSnapshot(steam, result, nonSteam, transport); }, [appId, nonSteam, numericAppId, transport]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { @@ -230,7 +243,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath), + integrationInstalled: integrationIsInstalled(steam, nonSteam, current.transport, current.wrapperPath), } : current); }, (subscriptionError) => { diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 0af1bbf..65541d8 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -172,7 +172,11 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { +export function installWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + allowCommandArgs = false, +) { const tokens = tokenize(options); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); @@ -185,7 +189,7 @@ export function installWrapperLaunchOption(options: string, wrapperPath = DEFAUL } let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { + if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } tokens.splice(insertion, 0, @@ -336,10 +340,11 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, + transport: "host" | "flatpak" = "host", ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { + if (nonSteam && transport === "flatpak") { if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); @@ -364,11 +369,11 @@ export function installWrapperIntegration( const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); + const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; const value = await writeVerified( - appId, false, current.options, rewrite.options, - (options) => writeOptions(appId, false, options), readOptions, + appId, nonSteam, current.options, rewrite.options, + (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; @@ -381,10 +386,11 @@ export function removeWrapperIntegration( wrapperPath: string, originalExecutable?: string, commandTokenAdded = false, + transport: "host" | "flatpak" = "host", ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { + if (nonSteam && transport === "flatpak") { if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); } @@ -408,8 +414,8 @@ export function removeWrapperIntegration( } const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( - appId, false, current.options, next, - (options) => writeOptions(appId, false, options), readOptions, + appId, nonSteam, current.options, next, + (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not clean the launch options", ); }); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 3170fe8..215e3f7 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -37,6 +37,10 @@ test("normalizes blank and argument-only fields while refusing ambiguous launche options: `FOO=bar ${wrapper} %command% --windowed`, commandTokenAdded: true, }); + assert.deepEqual(installWrapperLaunchOption('FOO=bar "/home/deck/game.AppImage"', wrapper, true), { + options: 'FOO=bar ~/.lsfg %command% "/home/deck/game.AppImage"', + commandTokenAdded: true, + }); assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/); assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); }); @@ -122,11 +126,11 @@ test("reads the matching app-details field and installs/removes Steam integratio assert.equal(appWrites.length, 1); assert.equal(shortcutWrites.length, 0); - const shortcut = await installWrapperIntegration(43, true, wrapper); + const shortcut = await installWrapperIntegration(43, true, wrapper, false, "flatpak"); assert.equal(shortcut.originalExecutable, "/usr/bin/example-game"); assert.equal(shortcut.snapshot.target, wrapper); assert.deepEqual(targetWrites, [wrapper]); - const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable); + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, "flatpak"); assert.equal(restored.target, "/usr/bin/example-game"); assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]); assert.equal(shortcutWrites.length, 0); @@ -143,6 +147,54 @@ test("reads the matching app-details field and installs/removes Steam integratio } }); +test("uses shortcut launch options for a host shortcut without changing its Target", async () => { + const previousWindow = (globalThis as Record).window; + const previousSteamClient = (globalThis as Record).SteamClient; + const originalOptions = 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"'; + let shortcutOptions = originalOptions; + let shortcutTarget = "env"; + const shortcutWrites: string[] = []; + const targetWrites: string[] = []; + const apps = { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions }); + return { unregister() {} }; + }, + SetShortcutLaunchOptions(_appId: number, options: string) { + shortcutWrites.push(options); + shortcutOptions = options; + }, + SetShortcutExe(_appId: number, executable: string) { + targetWrites.push(executable); + shortcutTarget = executable; + }, + }; + (globalThis as Record).window = { setTimeout, clearTimeout }; + (globalThis as Record).SteamClient = { Apps: apps }; + try { + const installed = await installWrapperIntegration(44, true, wrapper, false, "host"); + assert.equal(installed.originalExecutable, undefined); + assert.equal(installed.snapshot.target, "env"); + assert.equal(installed.snapshot.options, 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"'); + assert.deepEqual(targetWrites, []); + assert.deepEqual(shortcutWrites, [installed.snapshot.options]); + + const secondInstall = await installWrapperIntegration(44, true, wrapper, false, "host"); + assert.equal(secondInstall.snapshot.options, installed.snapshot.options); + assert.deepEqual(shortcutWrites, [installed.snapshot.options]); + + const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, "host"); + assert.equal(restored.target, "env"); + assert.equal(restored.options, originalOptions); + assert.deepEqual(targetWrites, []); + } finally { + if (previousWindow === undefined) delete (globalThis as Record).window; + else (globalThis as Record).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record).SteamClient; + else (globalThis as Record).SteamClient = previousSteamClient; + } +}); + test("fails closed when shortcut Target ownership or setters are unavailable", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; @@ -156,8 +208,8 @@ test("fails closed when shortcut Target ownership or setters are unavailable", a }, }; try { - await assert.rejects(installWrapperIntegration(99, true, wrapper), /Target API is unavailable/); - await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original"), /Target changed externally/); + await assert.rejects(installWrapperIntegration(99, true, wrapper, false, "flatpak"), /Target API is unavailable/); + await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, "flatpak"), /Target changed externally/); } finally { if (previousWindow === undefined) delete (globalThis as Record).window; else (globalThis as Record).window = previousWindow; @@ -198,7 +250,7 @@ test("restores launch options and shortcut Target when a setter fails after chan assert.equal(appOptions, "FOO=bar %command%"); assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); - await assert.rejects(installWrapperIntegration(43, true, wrapper), /simulated Target write failure/); + await assert.rejects(installWrapperIntegration(43, true, wrapper, false, "flatpak"), /simulated Target write failure/); assert.equal(shortcutTarget, "/usr/bin/original"); assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]); } finally { diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 70ba228..d5baf61 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -26,7 +26,12 @@ class FlatpakServiceTests(unittest.TestCase): self.service.config_dir = self.home / ".config/lsfg-vk" self.service.config_file_path = self.service.config_dir / "conf.toml" self.service.check_flatpak_available = Mock(return_value=True) - self.service._run_flatpak_command = Mock() + self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command) + self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08" + self.runtime_metadata = "" + self.user_branches = set() + self.system_branches = set() + self.install_branch = "24.08" self.bundle = self.home / "lsfg-vk-24.08.flatpak" self.bundle.write_bytes(b"bundle") self.service._bundled_extension_path = Mock(return_value=self.bundle) @@ -42,6 +47,22 @@ class FlatpakServiceTests(unittest.TestCase): def _extension_line(branch): return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + def _run_flatpak_command(self, args, **_kwargs): + if args[0] == "info" and args[1] == "--show-runtime": + return self._result(self.runtime_ref + "\n") + if args[0] == "info" and args[1] == "--show-metadata": + return self._result(self.runtime_metadata) + if args[0] == "list": + branches = self.user_branches if "--user" in args else self.system_branches + return self._result("".join(self._extension_line(branch) for branch in sorted(branches))) + if args[0] == "install": + self.user_branches.add(self.install_branch) + return self._result() + if args[0] == "uninstall": + self.user_branches.discard(args[-1].rsplit("/", 1)[-1]) + return self._result() + raise AssertionError(f"Unexpected Flatpak command: {args}") + def test_runtime_branch_mapping_is_strict_and_branch_specific(self): self.assertEqual( FlatpakService.runtime_branch_from_ref( @@ -62,11 +83,20 @@ class FlatpakServiceTests(unittest.TestCase): "org.freedesktop.Platform/x86_64/26.08" ) + def test_runtime_branch_mapping_reads_documented_gl_metadata(self): + metadata = """ +[Extension org.freedesktop.Platform.GL] +versions=25.08;25.08-extra;1.4 +version=1.4 +""" + self.assertEqual(FlatpakService.runtime_branch_from_metadata(metadata), "25.08") + with self.assertRaises(ValueError): + FlatpakService.runtime_branch_from_metadata( + "[Extension org.freedesktop.Platform.GL]\nversions=26.08;26.08-extra;1.4\n" + ) + def test_resolve_reads_required_runtime_instead_of_any_installed_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("23.08")), - ] + self.user_branches = {"23.08"} response = self.service.resolve_app_support("com.example.Game") @@ -80,22 +110,48 @@ class FlatpakServiceTests(unittest.TestCase): ) self.assertEqual( self.service._run_flatpak_command.call_args_list[1].args[0], - ["list", "--runtime", "--columns=application,arch,branch"], + ["list", "--user", "--runtime", "--columns=application,arch,branch"], + ) + self.assertEqual( + self.service._run_flatpak_command.call_args_list[2].args[0], + ["list", "--system", "--runtime", "--columns=application,arch,branch"], ) - def test_install_records_only_a_new_user_owned_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result(""), - self._result(""), - self._result(self._extension_line("24.08")), - ] + def test_resolve_maps_kde_and_gnome_runtimes_from_gl_metadata(self): + metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" + for runtime in ("org.kde.Platform/x86_64/6.10", "org.gnome.Platform/x86_64/49"): + with self.subTest(runtime=runtime): + self.service._run_flatpak_command.reset_mock() + self.runtime_ref = runtime + self.runtime_metadata = metadata + response = self.service.resolve_app_support("com.example.Game") + self.assertEqual(response["runtime_branch"], "25.08") + self.assertEqual(response["support_status"], "needs-runtime") + self.assertEqual( + self.service._run_flatpak_command.call_args_list[1].args[0], + ["info", "--show-metadata", runtime], + ) + + def test_system_extension_is_ready_without_installing_a_user_copy(self): + self.system_branches = {"24.08"} + + response = self.service.ensure_app_support("com.example.Game") + + self.assertTrue(response["success"]) + self.assertEqual(response["support_status"], "ready") + self.assertEqual( + [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], + ["info", "list", "list"], + ) + self.assertFalse(any(call.args[0][0] == "install" for call in self.service._run_flatpak_command.call_args_list)) + def test_install_records_only_a_new_user_owned_branch(self): response = self.service.install_extension("24.08") self.assertTrue(response["success"]) self.assertTrue(response["enabled"]) self.assertTrue(response["installed"]) - install_args = self.service._run_flatpak_command.call_args_list[1].args[0] + install_args = self.service._run_flatpak_command.call_args_list[2].args[0] self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"]) self.assertEqual( json.loads(self.service.ownership_path.read_text(encoding="utf-8")), @@ -103,9 +159,7 @@ class FlatpakServiceTests(unittest.TestCase): ) def test_preexisting_branch_is_not_claimed_or_removed(self): - self.service._run_flatpak_command.return_value = self._result( - self._extension_line("24.08") - ) + self.user_branches = {"24.08"} install_response = self.service.install_extension("24.08") cleanup_response = self.service.remove_plugin_owned_extensions() @@ -115,15 +169,10 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(install_response["installed"]) self.assertFalse(self.service.ownership_path.exists()) self.assertTrue(cleanup_response["success"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 1) - - def test_extension_toggle_installs_and_uninstalls_preexisting_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result(self._extension_line("24.08")), - self._result(self._extension_line("24.08")), - self._result(""), - self._result(""), - ] + self.assertEqual(self.service._run_flatpak_command.call_count, 2) + + def test_extension_toggle_preserves_preexisting_branch(self): + self.user_branches = {"24.08"} enable_response = self.service.set_extension_enabled("24.08", True) disable_response = self.service.set_extension_enabled("24.08", False) @@ -132,36 +181,36 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(enable_response["enabled"]) self.assertTrue(enable_response["installed"]) self.assertTrue(disable_response["success"]) - self.assertFalse(disable_response["enabled"]) - self.assertFalse(disable_response["installed"]) - self.assertTrue(disable_response["removed"]) + self.assertTrue(disable_response["enabled"]) + self.assertTrue(disable_response["installed"]) + self.assertFalse(disable_response["removed"]) + self.assertEqual(self.user_branches, {"24.08"}) self.assertEqual( [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["list", "list", "uninstall", "list"], + ["list", "list", "list", "list"], ) - def test_extension_toggle_removes_owned_branch_and_can_repeat_disable(self): + def test_extension_toggle_removes_owned_user_branch_but_preserves_system_branch(self): self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) self.service.ownership_path.write_text( json.dumps({"version": 1, "plugin_owned_branches": ["24.08"]}), encoding="utf-8", ) - self.service._run_flatpak_command.side_effect = [ - self._result(self._extension_line("24.08")), - self._result(""), - self._result(""), - self._result(""), - ] + self.user_branches = {"24.08"} + self.system_branches = {"24.08"} disable_response = self.service.set_extension_enabled("24.08", False) repeat_response = self.service.set_extension_enabled("24.08", False) self.assertTrue(disable_response["success"]) - self.assertFalse(disable_response["enabled"]) + self.assertTrue(disable_response["enabled"]) self.assertTrue(disable_response["removed"]) self.assertTrue(repeat_response["success"]) - self.assertFalse(repeat_response["enabled"]) - self.assertFalse(repeat_response["installed"]) + self.assertTrue(repeat_response["enabled"]) + self.assertTrue(repeat_response["installed"]) + self.assertEqual(self.user_branches, set()) + self.assertEqual(self.system_branches, {"24.08"}) + self.assertFalse(self.service.ownership_path.exists()) uninstall_commands = [ call.args[0] for call in self.service._run_flatpak_command.call_args_list @@ -190,23 +239,16 @@ class FlatpakServiceTests(unittest.TestCase): self.assertEqual(self.service._run_flatpak_command.call_count, 0) def test_ensure_app_support_installs_only_the_app_runtime_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(""), - self._result(""), - self._result(""), - self._result(""), - self._result(self._extension_line("24.08")), - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("24.08")), - ] - response = self.service.ensure_app_support("com.example.Game") self.assertTrue(response["success"]) self.assertEqual(response["support_status"], "ready") self.assertEqual(response["runtime_branch"], "24.08") - install_args = self.service._run_flatpak_command.call_args_list[4].args[0] + install_args = next( + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ) self.assertEqual(install_args[0], "install") self.assertIn("--user", install_args) self.assertNotIn("23.08", install_args) @@ -216,19 +258,6 @@ class FlatpakServiceTests(unittest.TestCase): ) def test_two_shortcuts_using_one_flatpak_share_one_extension_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(""), - self._result(""), - self._result(""), - self._result(""), - self._result(self._extension_line("24.08")), - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("24.08")), - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("24.08")), - ] - first = self.service.ensure_app_support("net.pcsx2.PCSX2") second = self.service.ensure_app_support("net.pcsx2.PCSX2.Dev") @@ -251,22 +280,7 @@ class FlatpakServiceTests(unittest.TestCase): json.dumps({"version": 1, "plugin_owned_branches": ["23.08", "24.08"]}), encoding="utf-8", ) - self.service._run_flatpak_command.side_effect = [ - self._result( - "\n".join( - [ - "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "23.08"]), - "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]), - ] - ) - + "\n" - ), - self._result(""), - self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"), - self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"), - self._result(""), - self._result(""), - ] + self.user_branches = {"23.08", "24.08"} response = self.service.remove_plugin_owned_extensions() -- 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 ++- src/hooks/useGameConfiguration.ts | 176 ++++++++++------------------------ src/hooks/usePerAppWorkarounds.ts | 83 ++++------------ src/utils/steamLaunchOptions.ts | 95 +++++++++++++----- tests/steamLaunchOptions.test.ts | 20 ++-- tests/test_wrapper_service.py | 19 ++++ 6 files changed, 176 insertions(+), 228 deletions(-) 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) diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index e120426..3260019 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -3,7 +3,7 @@ import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions"; +import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -40,18 +40,6 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta return Array.from(games.values()); } -function selectShortcutExecutable( - target: GameTarget, - ...candidates: Array -): string | undefined { - const absolute = candidates - .map((candidate) => candidate?.trim()) - .find((candidate) => candidate && candidate.startsWith("/")); - if (absolute) return absolute; - if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; - return candidates.map((candidate) => candidate?.trim()).find(Boolean); -} - const DEFAULT_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: true, @@ -144,110 +132,59 @@ export function useGameConfiguration() { const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; const appId = Number(target.appid); + let integration: Awaited> | null = null; + let newState = false; + let stateWriteAttempted = false; + let wrapperPath = getDefaultWrapperPath(); try { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); - const current = await readSteamLaunchOptions(appId, target.nonSteam); - const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); - const oldState = existing.state; - const oldShortcutExe = existing.shortcut_exe || undefined; - const oldCommandTokenAdded = existing.command_token_added === true; - const oldTransport = existing.transport || target.transport; - const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; - if (usesShortcutTarget && oldState && current.target === wrapperPath && !oldShortcutExe) { - throw new Error("Managed shortcut Target has no saved original executable"); - } - if (usesShortcutTarget && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { - throw new Error("Shortcut Target changed externally; refusing to replace it"); - } - if (usesShortcutTarget && !oldState && current.target === wrapperPath) { - throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); - } - const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = usesShortcutTarget - ? selectShortcutExecutable( - target, - oldShortcutExe, - target.executable, - current.target, - ) - : undefined; - const initialIntegration = usesShortcutTarget - ? current.target === wrapperPath - : hasWrapperLaunchIntegration(current.options, wrapperPath); - const initialStateResult = await setWorkaroundState( + wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const state = existing.state || { ...DEFAULT_WORKAROUND_STATE }; + const commandTokenAdded = existing.command_token_added === true; + newState = !existing.state; + integration = await installWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + commandTokenAdded, + target.transport, + target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined, + ); + stateWriteAttempted = true; + const saved = await setWorkaroundState( target.appid, state, - originalExecutable || null, - oldCommandTokenAdded, + integration.originalExecutable ?? null, + integration.commandTokenAdded, target.transport, ); - if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state"); - - let integration: Awaited> | null = null; - try { - integration = await installWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - oldCommandTokenAdded, - target.transport.kind, - ); - const finalStateResult = await setWorkaroundState( - target.appid, - state, - usesShortcutTarget - ? (selectShortcutExecutable( - target, - integration.originalExecutable, - originalExecutable, - target.executable, - ) || null) - : null, - integration.commandTokenAdded, - target.transport, - ); - if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state"); - return true; - } catch (error) { - let rollbackSucceeded = true; - if (!initialIntegration && integration) { - try { - await removeWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - usesShortcutTarget - ? (selectShortcutExecutable( - target, - integration?.originalExecutable, - originalExecutable, - target.executable, - ) || undefined) - : undefined, - integration?.commandTokenAdded ?? oldCommandTokenAdded, - target.transport.kind, - ); - } catch (rollbackError) { - showErrorToast("Workaround rollback failed", asError(rollbackError).message); - rollbackSucceeded = false; - } + if (!saved.success) throw new Error(saved.error || "Could not save workaround state"); + return true; + } catch (error) { + let rollbackSucceeded = true; + if (integration?.changed) { + try { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + integration.originalExecutable, + integration.commandTokenAdded, + target.transport, + ); + } catch (rollbackError) { + showErrorToast("Workaround rollback failed", asError(rollbackError).message); + rollbackSucceeded = false; } - if (rollbackSucceeded) { - const restored = oldState - ? await setWorkaroundState( - target.appid, - oldState, - oldShortcutExe || null, - oldCommandTokenAdded, - oldTransport, - ) - : await removeWorkaroundState(target.appid); - if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state"); + } + if (rollbackSucceeded && newState && stateWriteAttempted) { + const restored = await removeWorkaroundState(target.appid); + if (!restored.success) { + showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state"); + rollbackSucceeded = false; } - throw error; } - } catch (error) { showErrorToast("Could not initialize workarounds", asError(error).message); return false; } @@ -260,23 +197,14 @@ export function useGameConfiguration() { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); - const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; - if (existing.state) { - await removeWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - existing.shortcut_exe || undefined, - existing.command_token_added === true, - target.transport.kind, - ); - } else { - const current = await readSteamLaunchOptions(appId, target.nonSteam); - if (usesShortcutTarget && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { - throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); - } - await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); - } + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined, + existing.command_token_added === true, + target.transport, + ); const removed = await removeWorkaroundState(target.appid); if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); return true; diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index ebb12e2..29e1181 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -7,10 +7,10 @@ import { type WorkaroundState, } from "../api/lsfgApi"; import { + assertKnownShortcutTarget, getDefaultWrapperPath, - hasWrapperLaunchIntegration, installWrapperIntegration, - isLegacyWrapperToken, + isWrapperIntegrationInstalled, readSteamLaunchOptions, removeWrapperIntegration, subscribeSteamLaunchOptions, @@ -61,33 +61,6 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function selectShortcutExecutable( - transport: TargetTransport, - ...candidates: Array -): string | undefined { - const absolute = candidates - .map((candidate) => candidate?.trim()) - .find((candidate) => candidate && candidate.startsWith("/")); - if (absolute) return absolute; - if (transport.kind === "flatpak") return "/usr/bin/flatpak"; - return candidates.map((candidate) => candidate?.trim()).find(Boolean); -} - -function usesShortcutTarget(nonSteam: boolean, transport: TargetTransport): boolean { - return nonSteam && transport.kind === "flatpak"; -} - -function integrationIsInstalled( - steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - transport: TargetTransport, - wrapperPath: string, -): boolean { - return usesShortcutTarget(nonSteam, transport) - ? steam.target === wrapperPath - : hasWrapperLaunchIntegration(steam.options, wrapperPath); -} - function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited>, @@ -97,17 +70,16 @@ function makeSnapshot( if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); const selectedTransport = result.transport || transport; - if (usesShortcutTarget(nonSteam, selectedTransport) && steam.target === wrapperPath && !result.shortcut_exe) { - throw new Error("Managed shortcut Target has no saved original executable"); - } + const shortcutExe = selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined; + assertKnownShortcutTarget(steam, nonSteam, selectedTransport, wrapperPath, shortcutExe); return { steam, state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: integrationIsInstalled(steam, nonSteam, selectedTransport, wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, selectedTransport, wrapperPath), commandTokenAdded: result.command_token_added === true, - shortcutExe: result.shortcut_exe, + shortcutExe, transport: selectedTransport, }; } @@ -116,24 +88,8 @@ async function adoptWorkaroundState( appId: string, nonSteam: boolean, transport: TargetTransport, - steam: SteamLaunchOptionsSnapshot, wrapperPath: string, ): Promise { - const shortcutTarget = usesShortcutTarget(nonSteam, transport); - if (shortcutTarget && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { - throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); - } - const originalExecutable = shortcutTarget - ? selectShortcutExecutable(transport, steam.target) - : null; - const initial = await setWorkaroundState( - appId, - DEFAULT_WORKAROUND_STATE, - originalExecutable, - false, - transport, - ); - if (!initial.success) throw new Error(initial.error || "Could not create workaround state"); let integration: Awaited> | null = null; try { integration = await installWrapperIntegration( @@ -141,14 +97,12 @@ async function adoptWorkaroundState( nonSteam, wrapperPath, false, - transport.kind, + transport, ); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - shortcutTarget - ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) - : null, + integration.originalExecutable ?? null, integration.commandTokenAdded, transport, ); @@ -156,17 +110,15 @@ async function adoptWorkaroundState( return makeSnapshot(integration.snapshot, finalized, nonSteam, transport); } catch (error) { let rollbackSucceeded = true; - if (integration) { + if (integration?.changed) { try { await removeWrapperIntegration( Number(appId), nonSteam, wrapperPath, - shortcutTarget - ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) - : undefined, - integration?.commandTokenAdded ?? false, - transport.kind, + integration.originalExecutable, + integration.commandTokenAdded, + transport, ); } catch { // Leave the owned integration in place rather than guessing at cleanup. @@ -203,7 +155,6 @@ export function usePerAppWorkarounds( appId, nonSteam, transport, - steam, result.wrapper_path || getDefaultWrapperPath(), ); } @@ -243,7 +194,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: integrationIsInstalled(steam, nonSteam, current.transport, current.wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.transport, current.wrapperPath), } : current); }, (subscriptionError) => { @@ -278,22 +229,24 @@ export function usePerAppWorkarounds( setError(null); const nextState = { ...current.state, [field]: value } as WorkaroundState; try { + const shortcutExe = current.transport.kind === "flatpak" ? current.shortcutExe ?? null : null; const result = await setWorkaroundState( appId, nextState, - current.shortcutExe ?? null, + shortcutExe, current.commandTokenAdded, current.transport, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); + const selectedTransport = result.transport || current.transport; applySnapshot({ ...current, state: result.state, wrapperPath: result.wrapper_path || current.wrapperPath, wrapperOwned: result.wrapper_owned === true, - shortcutExe: result.shortcut_exe, + shortcutExe: selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined, commandTokenAdded: result.command_token_added === true, - transport: result.transport || current.transport, + transport: selectedTransport, }); return true; } catch (updateError) { diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 65541d8..959ce9e 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,3 +1,5 @@ +import type { TargetTransport } from "../api/lsfgApi"; + const DEFAULT_WRAPPER_PATH = "~/.lsfg"; const COMMAND_TOKEN = "%command%"; @@ -31,6 +33,7 @@ export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; originalExecutable?: string; commandTokenAdded: boolean; + changed: boolean; } function asError(error: unknown): Error { @@ -159,6 +162,13 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); +const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak"; + +function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { + if (transport.kind !== "flatpak") return undefined; + const value = candidate?.trim(); + return value ? (value.startsWith("/") ? value : "/usr/bin/flatpak") : undefined; +} export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); @@ -172,10 +182,10 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption( +function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, - allowCommandArgs = false, + shortcutLaunchOptions = false, ) { const tokens = tokenize(options); removeMatchingWrappers(tokens, isLegacyToken); @@ -189,7 +199,7 @@ export function installWrapperLaunchOption( } let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { + if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } tokens.splice(insertion, 0, @@ -199,6 +209,10 @@ export function installWrapperLaunchOption( return { options: serialize(tokens), commandTokenAdded: true }; } +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { + return installLaunchOption(options, wrapperPath); +} + export function removeWrapperLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, @@ -249,6 +263,29 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU return command > 0 && tokens[command - 1].value === wrapperPath; } +export function isWrapperIntegrationInstalled( + steam: SteamLaunchOptionsSnapshot, + nonSteam: boolean, + transport: TargetTransport, + wrapperPath = DEFAULT_WRAPPER_PATH, +): boolean { + return usesShortcutTarget(nonSteam, transport) + ? steam.target === wrapperPath + : hasWrapperLaunchIntegration(steam.options, wrapperPath); +} + +export function assertKnownShortcutTarget( + steam: SteamLaunchOptionsSnapshot, + nonSteam: boolean, + transport: TargetTransport, + wrapperPath: string, + originalExecutable?: string | null, +): void { + if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) { + throw new Error("Managed shortcut Target has no saved original executable"); + } +} + const queues = new Map>(); function queued(appId: number, nonSteam: boolean, operation: () => Promise): Promise { const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; @@ -340,43 +377,52 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", + transport: TargetTransport = { kind: "host" }, + originalExecutable?: string, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { + if (usesShortcutTarget(nonSteam, transport)) { if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); } const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { + const launchOptionsChanged = cleaned !== current.options; + if (launchOptionsChanged) { current = await writeVerified( appId, true, current.options, cleaned, (value) => writeOptions(appId, true, value), readOptions, "Steam did not accept shortcut launch options", ); } - if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false }; - const originalExecutable = current.target; + const savedOriginal = selectFlatpakExecutable(transport, originalExecutable); + if (current.target === wrapperPath) { + if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable"); + return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged }; + } + if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) { + throw new Error("Shortcut Target changed externally; refusing to replace it"); + } + const currentOriginal = selectFlatpakExecutable(transport, current.target); const value = await writeVerified( - appId, true, originalExecutable, wrapperPath, + appId, true, current.target, wrapperPath, (target) => writeTarget(appId, target), readTarget, "Steam did not accept the shortcut Target", ); - return { snapshot: value, originalExecutable, commandTokenAdded: false }; + return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true }; } const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); - if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); + if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false }; const value = await writeVerified( appId, nonSteam, current.options, rewrite.options, (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not accept the launch options", ); - return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true }; }); } @@ -386,17 +432,11 @@ export function removeWrapperIntegration( wrapperPath: string, originalExecutable?: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", + transport: TargetTransport = { kind: "host" }, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (current.target !== wrapperPath && current.target !== originalExecutable) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } + if (usesShortcutTarget(nonSteam, transport)) { const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { current = await writeVerified( @@ -405,7 +445,18 @@ export function removeWrapperIntegration( "Steam did not clean shortcut launch options", ); } - if (current.target === originalExecutable) return current; + if (current.target !== wrapperPath) { + if (isWrapperToken(current.target, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } + if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) { + throw new Error("Shortcut Target changed externally; refusing to restore it"); + } + return current; + } + if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { + throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + } return writeVerified( appId, true, wrapperPath, originalExecutable, (target) => writeTarget(appId, target), readTarget, diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 215e3f7..456d9d0 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -37,10 +37,6 @@ test("normalizes blank and argument-only fields while refusing ambiguous launche options: `FOO=bar ${wrapper} %command% --windowed`, commandTokenAdded: true, }); - assert.deepEqual(installWrapperLaunchOption('FOO=bar "/home/deck/game.AppImage"', wrapper, true), { - options: 'FOO=bar ~/.lsfg %command% "/home/deck/game.AppImage"', - commandTokenAdded: true, - }); assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/); assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); }); @@ -126,11 +122,11 @@ test("reads the matching app-details field and installs/removes Steam integratio assert.equal(appWrites.length, 1); assert.equal(shortcutWrites.length, 0); - const shortcut = await installWrapperIntegration(43, true, wrapper, false, "flatpak"); + const shortcut = await installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }); assert.equal(shortcut.originalExecutable, "/usr/bin/example-game"); assert.equal(shortcut.snapshot.target, wrapper); assert.deepEqual(targetWrites, [wrapper]); - const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, "flatpak"); + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }); assert.equal(restored.target, "/usr/bin/example-game"); assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]); assert.equal(shortcutWrites.length, 0); @@ -172,18 +168,18 @@ test("uses shortcut launch options for a host shortcut without changing its Targ (globalThis as Record).window = { setTimeout, clearTimeout }; (globalThis as Record).SteamClient = { Apps: apps }; try { - const installed = await installWrapperIntegration(44, true, wrapper, false, "host"); + const installed = await installWrapperIntegration(44, true, wrapper, false, { kind: "host" }); assert.equal(installed.originalExecutable, undefined); assert.equal(installed.snapshot.target, "env"); assert.equal(installed.snapshot.options, 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"'); assert.deepEqual(targetWrites, []); assert.deepEqual(shortcutWrites, [installed.snapshot.options]); - const secondInstall = await installWrapperIntegration(44, true, wrapper, false, "host"); + const secondInstall = await installWrapperIntegration(44, true, wrapper, false, { kind: "host" }); assert.equal(secondInstall.snapshot.options, installed.snapshot.options); assert.deepEqual(shortcutWrites, [installed.snapshot.options]); - const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, "host"); + const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, { kind: "host" }); assert.equal(restored.target, "env"); assert.equal(restored.options, originalOptions); assert.deepEqual(targetWrites, []); @@ -208,8 +204,8 @@ test("fails closed when shortcut Target ownership or setters are unavailable", a }, }; try { - await assert.rejects(installWrapperIntegration(99, true, wrapper, false, "flatpak"), /Target API is unavailable/); - await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, "flatpak"), /Target changed externally/); + await assert.rejects(installWrapperIntegration(99, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target API is unavailable/); + await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target changed externally/); } finally { if (previousWindow === undefined) delete (globalThis as Record).window; else (globalThis as Record).window = previousWindow; @@ -250,7 +246,7 @@ test("restores launch options and shortcut Target when a setter fails after chan assert.equal(appOptions, "FOO=bar %command%"); assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); - await assert.rejects(installWrapperIntegration(43, true, wrapper, false, "flatpak"), /simulated Target write failure/); + await assert.rejects(installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /simulated Target write failure/); assert.equal(shortcutTarget, "/usr/bin/original"); assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]); } finally { diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py index 5010d23..0632d93 100644 --- a/tests/test_wrapper_service.py +++ b/tests/test_wrapper_service.py @@ -170,6 +170,25 @@ class WrapperServiceTests(unittest.TestCase): self.assertIn("ARG:com.example.Game", args) self.assertIn("ARG:--windowed", args) + def test_host_transport_does_not_store_shortcut_target(self): + self.service.set( + "123", + self._state(), + "/usr/bin/flatpak", + False, + {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, + ) + response = self.service.set( + "123", + self._state(), + "/usr/bin/ignored", + False, + {"kind": "host"}, + ) + self.assertTrue(response["success"]) + self.assertIsNone(response["shortcut_exe"]) + self.assertIsNone(self.service.get("123")["shortcut_exe"]) + def test_flatpak_transport_rejects_non_run_invocation(self): fake_flatpak = self.home / ".local/bin/flatpak" fake_flatpak.parent.mkdir(parents=True, exist_ok=True) -- 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 +- src/utils/steamLaunchOptions.ts | 9 +++++++-- tests/steamLaunchOptions.test.ts | 17 +++++++++-------- tests/test_steam_service.py | 7 +++++++ 4 files changed, 24 insertions(+), 11 deletions(-) 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"} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 959ce9e..82cf116 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -166,8 +166,10 @@ const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => no function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { if (transport.kind !== "flatpak") return undefined; - const value = candidate?.trim(); - return value ? (value.startsWith("/") ? value : "/usr/bin/flatpak") : undefined; + const value = candidate?.trim() ? decodeToken(candidate.trim()) : ""; + if (value === "flatpak") return "/usr/bin/flatpak"; + if (value === "/usr/bin/flatpak") return value; + return undefined; } export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); @@ -405,6 +407,9 @@ export function installWrapperIntegration( throw new Error("Shortcut Target changed externally; refusing to replace it"); } const currentOriginal = selectFlatpakExecutable(transport, current.target); + if (!currentOriginal || (originalExecutable && !savedOriginal)) { + throw new Error("Flatpak shortcut Target is not a supported executable"); + } const value = await writeVerified( appId, true, current.target, wrapperPath, (target) => writeTarget(appId, target), readTarget, diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 456d9d0..c799907 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -83,7 +83,7 @@ test("reads the matching app-details field and installs/removes Steam integratio const previousSteamClient = (globalThis as Record).SteamClient; let appOptions = "FOO=bar %command%"; let shortcutOptions = "--windowed"; - let shortcutTarget = "/usr/bin/example-game"; + let shortcutTarget = '"flatpak"'; const appWrites: string[] = []; const shortcutWrites: string[] = []; const targetWrites: string[] = []; @@ -123,12 +123,12 @@ test("reads the matching app-details field and installs/removes Steam integratio assert.equal(shortcutWrites.length, 0); const shortcut = await installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }); - assert.equal(shortcut.originalExecutable, "/usr/bin/example-game"); + assert.equal(shortcut.originalExecutable, "/usr/bin/flatpak"); assert.equal(shortcut.snapshot.target, wrapper); assert.deepEqual(targetWrites, [wrapper]); const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }); - assert.equal(restored.target, "/usr/bin/example-game"); - assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]); + assert.equal(restored.target, "/usr/bin/flatpak"); + assert.deepEqual(targetWrites, [wrapper, "/usr/bin/flatpak"]); assert.equal(shortcutWrites.length, 0); const cleaned = await removeWrapperIntegration(42, false, wrapper, undefined, installed.commandTokenAdded); @@ -198,12 +198,13 @@ test("fails closed when shortcut Target ownership or setters are unavailable", a (globalThis as Record).SteamClient = { Apps: { RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: "/usr/bin/other", strShortcutLaunchOptions: "" }); + callback({ strShortcutExe: _appId === 99 ? "/usr/bin/flatpak" : "garbage", strShortcutLaunchOptions: "" }); return { unregister() {} }; }, }, }; try { + await assert.rejects(installWrapperIntegration(98, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /supported executable/); await assert.rejects(installWrapperIntegration(99, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target API is unavailable/); await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target changed externally/); } finally { @@ -218,7 +219,7 @@ test("restores launch options and shortcut Target when a setter fails after chan const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; let appOptions = "FOO=bar %command%"; - let shortcutTarget = "/usr/bin/original"; + let shortcutTarget = "/usr/bin/flatpak"; const appWrites: string[] = []; const targetWrites: string[] = []; const apps = { @@ -247,8 +248,8 @@ test("restores launch options and shortcut Target when a setter fails after chan assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); await assert.rejects(installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /simulated Target write failure/); - assert.equal(shortcutTarget, "/usr/bin/original"); - assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]); + assert.equal(shortcutTarget, "/usr/bin/flatpak"); + assert.deepEqual(targetWrites, [wrapper, "/usr/bin/flatpak"]); } finally { if (previousWindow === undefined) delete (globalThis as Record).window; else (globalThis as Record).window = previousWindow; diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 9924186..636c87f 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -23,6 +23,13 @@ class SteamTransportTests(unittest.TestCase): ), {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, ) + self.assertEqual( + classify_shortcut_transport( + "flatpak", + "run com.example.PCSX2 --fullscreen", + ), + {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, + ) self.assertEqual( classify_shortcut_transport( "/usr/bin/flatpak run com.example.PCSX2", -- 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 + src/api/lsfgApi.ts | 14 ++++ src/components/ConfigFileTab.tsx | 119 ++++++++++++++++++++++++++++------ src/components/ConfigurationTab.tsx | 50 +++++++++----- src/components/Content.tsx | 34 +++++++++- 6 files changed, 217 insertions(+), 40 deletions(-) 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"', diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index b96acec..567e1ae 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -105,6 +105,19 @@ export interface FileContentResult extends ApiResult { path?: string; } +export interface DebugFileContent { + id: string; + label: string; + path: string; + exists: boolean; + content?: string | null; + error?: string | null; +} + +export interface DebugFileContentsResult extends ApiResult { + files?: DebugFileContent[]; +} + export interface FlatpakExtensionStatus extends ApiResult { message: string; available: boolean; @@ -143,3 +156,4 @@ export const setWorkaroundState = callable<[ TargetTransport | null | undefined, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); +export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents"); diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx index e3cc09d..07408d7 100644 --- a/src/components/ConfigFileTab.tsx +++ b/src/components/ConfigFileTab.tsx @@ -1,13 +1,79 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; import { useEffect, useState } from "react"; -import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; -import { getConfigFileContent, FileContentResult } from "../api/lsfgApi"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi"; import t from "../i18n/i18n"; +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch { + // Persisting the view preference is optional. + } + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +function DebugFileSection({ file }: { file: DebugFileContent }) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`); + const status = file.exists ? "Present" : "Not present"; + + return ( + <> + + + + +
+ + {collapsed ? : } + +
+
+ {!collapsed && ( + + {file.exists && file.content !== null && file.content !== undefined ? ( +
+              {file.content}
+            
+ ) : ( + + )} +
+ )} + + ); +} + export function ConfigFileTab() { - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); useEffect(() => { - getConfigFileContent().then(setResult).catch((error) => { + getDebugFileContents().then(setResult).catch((error) => { setResult({ success: false, error: String(error) }); }); }, []); @@ -23,24 +89,35 @@ export function ConfigFileTab() { } return ( - - {result.error && ( - - - - )} - {result.success && result.content && ( - <> - - - + <> + + + {result.error && ( -
-              {result.content}
-            
+
- - )} -
+ )} + {result.success && result.files?.map((file) => ( + + ))} +
+ ); } diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 5ae4a87..4e2d93d 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, ToggleField, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; @@ -11,6 +11,8 @@ interface ConfigurationTabProps { config: ConfigurationData; targets: GameTarget[]; runningGame: GameTarget | null; + showDebugTab: boolean; + onShowDebugTabChange: (value: boolean) => void; onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; onEnable: (appid: string) => Promise; @@ -24,6 +26,8 @@ export function ConfigurationTab({ config, targets, runningGame, + showDebugTab, + onShowDebugTabChange, onSelect, onConfigChange, onEnable, @@ -63,22 +67,34 @@ export function ConfigurationTab({ if (detailAppId === null) { return ( - - { - setFocusConfiguredToggle(false); - setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); - onSelect(appid); - setDetailAppId(appid); - }} - onEnableAll={onEnableAll} - onResetAll={onResetAll} - focusConfiguredToggle={focusConfiguredToggle} - onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} - /> - + <> + + { + setFocusConfiguredToggle(false); + setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); + onSelect(appid); + setDetailAppId(appid); + }} + onEnableAll={onEnableAll} + onResetAll={onResetAll} + focusConfiguredToggle={focusConfiguredToggle} + onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} + /> + + + + + + + ); } diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 578f984..43720c0 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -17,6 +17,29 @@ const tabIcons = { setup: , }; +const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1"; + +function usePersistentBoolean(key: string, defaultValue: boolean) { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored === null ? defaultValue : stored === "true"; + } catch { + return defaultValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(value)); + } catch { + // Persisting the visibility preference is optional. + } + }, [key, value]); + + return [value, setValue] as const; +} + export function Content() { const { config, @@ -43,6 +66,7 @@ export function Content() { uninstall, } = useInstallation(reload); const [tab, setTab] = useState("Setup"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); const previousRunningAppId = useRef(null); const setupComplete = isInstalled && @@ -74,6 +98,10 @@ export function Content() { if (isInstalled) void reload(); }, [isInstalled, reload]); + useEffect(() => { + if (!showDebugTab && tab === "ConfigFile") setTab("Games"); + }, [showDebugTab, tab]); + const handleConfigChange = async ( fieldName: keyof ConfigurationData, value: boolean | number | string | string[], @@ -116,6 +144,8 @@ export function Content() { config={config} targets={targets} runningGame={runningGame} + showDebugTab={showDebugTab} + onShowDebugTabChange={setShowDebugTab} onSelect={setSelectedAppId} onConfigChange={(field, value) => handleConfigChange(field, value, true)} onEnable={enable} @@ -126,7 +156,7 @@ export function Content() { /> ), }, - { id: "ConfigFile", title: tabIcons.configFile, content: }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: }] : []), { id: "Setup", title: tabIcons.setup, content: setup }, ] : [{ id: "Setup", title: tabIcons.setup, content: setup }]; @@ -137,7 +167,7 @@ export function Content() { style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }} > - +
); } -- 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(-) 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(-) 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(-) 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(-) 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 9f09fe0438c52922ac4f36eddb8c6e5808eb26b2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:39:09 -0400 Subject: refactor: expose explicit flatpak app api --- src/api/lsfgApi.ts | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 567e1ae..3e93620 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -30,10 +30,6 @@ export interface SteamBranchStatus extends ApiResult { } export type LsfgConfig = ConfigurationData; -export type TargetTransport = - | { kind: "host" } - | { kind: "flatpak"; flatpakAppId: string }; -export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; export interface GameConfigEntry { appid: string; @@ -45,11 +41,10 @@ export interface InstalledGame { appid: string; name: string; nonSteam: boolean; - transport: TargetTransport; + directFlatpak?: boolean; executable?: string; arguments?: string; startDir?: string; - flatpakSupport?: FlatpakTargetSupport; } export interface GlobalConfig { @@ -57,15 +52,6 @@ export interface GlobalConfig { no_fp16: boolean; } -export interface FlatpakTargetSupport extends ApiResult { - flatpak_app_id?: string; - runtime?: string | null; - runtime_branch?: string | null; - support_status: FlatpakTargetSupportStatus; - extension_installed: boolean; - installed_branches: string[]; -} - export interface WorkaroundState { dxvkFrameRate: number; disableGamescopeWsi: boolean; @@ -82,7 +68,6 @@ export interface WorkaroundStateResult extends ApiResult { wrapper_owned?: boolean; shortcut_exe?: string | null; command_token_added?: boolean; - transport?: TargetTransport | null; } export interface GameConfigsResult extends ApiResult { @@ -133,14 +118,38 @@ export interface FlatpakExtensionToggleResult extends ApiResult { installed: boolean; } +export interface FlatpakApp { + app_id: string; + app_name: string; + runtime?: string | null; + runtime_branch?: string | null; + runtime_ready: boolean; + prepared: boolean; + owned: boolean; + error?: string | null; +} + +export interface FlatpakAppsResult extends ApiResult { + apps?: FlatpakApp[]; +} + +export interface FlatpakAppResult extends ApiResult { + app_id: string; + runtime?: string | null; + runtime_branch?: string | null; + prepared: boolean; + owned: boolean; +} + export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); -export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); -export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); +export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps"); +export const prepareFlatpakApp = callable<[string], FlatpakAppResult>("prepare_flatpak_app"); +export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app"); export const setFlatpakExtensionEnabled = callable<[string, boolean], FlatpakExtensionToggleResult>("set_flatpak_extension_enabled"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); @@ -153,7 +162,6 @@ export const setWorkaroundState = callable<[ WorkaroundState, string | null | undefined, boolean, - TargetTransport | null | undefined, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents"); -- 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(-) 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 6c22d5ee540bc4c71a72c1e83ffc40e6b0494bc1 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:40:57 -0400 Subject: refactor: use explicit direct flatpak target wrapper --- src/utils/steamLaunchOptions.ts | 114 +++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 66 deletions(-) diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 82cf116..08ae136 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,3 @@ -import type { TargetTransport } from "../api/lsfgApi"; - const DEFAULT_WRAPPER_PATH = "~/.lsfg"; const COMMAND_TOKEN = "%command%"; @@ -31,7 +29,6 @@ export interface SteamLaunchOptionsSnapshot { } export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; - originalExecutable?: string; commandTokenAdded: boolean; changed: boolean; } @@ -162,14 +159,28 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak"; -function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { - if (transport.kind !== "flatpak") return undefined; - const value = candidate?.trim() ? decodeToken(candidate.trim()) : ""; - if (value === "flatpak") return "/usr/bin/flatpak"; - if (value === "/usr/bin/flatpak") return value; - return undefined; +function flatpakExecutable(value: string): string | undefined { + const decoded = decodeToken(value.trim()); + return decoded === "flatpak" || decoded === "/usr/bin/flatpak" ? "/usr/bin/flatpak" : undefined; +} + +function wrappedFlatpakExecutable(target: string, wrapperPath: string, includeLegacy = true): string | undefined { + const tokens = tokenize(target); + if (tokens.length !== 2) return undefined; + const wrapper = tokens[0].value; + if (wrapper !== wrapperPath && !(includeLegacy && isLegacyToken(wrapper))) return undefined; + return flatpakExecutable(tokens[1].value); +} + +function directFlatpakExecutable(target: string, wrapperPath: string): string | undefined { + const tokens = tokenize(target); + if (tokens.length === 1) return flatpakExecutable(tokens[0].value); + return wrappedFlatpakExecutable(target, wrapperPath); +} + +function managedFlatpakTarget(wrapperPath: string, executable: string): string { + return `${wrapperPath} "${executable.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); @@ -268,24 +279,14 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU export function isWrapperIntegrationInstalled( steam: SteamLaunchOptionsSnapshot, nonSteam: boolean, - transport: TargetTransport, + directFlatpak = false, wrapperPath = DEFAULT_WRAPPER_PATH, ): boolean { - return usesShortcutTarget(nonSteam, transport) - ? steam.target === wrapperPath - : hasWrapperLaunchIntegration(steam.options, wrapperPath); -} - -export function assertKnownShortcutTarget( - steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - transport: TargetTransport, - wrapperPath: string, - originalExecutable?: string | null, -): void { - if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) { - throw new Error("Managed shortcut Target has no saved original executable"); + if (nonSteam && directFlatpak) { + const tokens = tokenize(steam.target); + return tokens.length === 2 && tokens[0].value === wrapperPath && flatpakExecutable(tokens[1].value) !== undefined; } + return hasWrapperLaunchIntegration(steam.options, wrapperPath); } const queues = new Map>(); @@ -328,7 +329,7 @@ async function writeVerified( read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise { - const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; + const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => normalizeLaunchOptions(value); try { await write(next); return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); @@ -379,16 +380,11 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: TargetTransport = { kind: "host" }, - originalExecutable?: string, + directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (usesShortcutTarget(nonSteam, transport)) { - if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); - if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { - throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); - } + if (nonSteam && directFlatpak) { const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); const launchOptionsChanged = cleaned !== current.options; if (launchOptionsChanged) { @@ -398,24 +394,18 @@ export function installWrapperIntegration( "Steam did not accept shortcut launch options", ); } - const savedOriginal = selectFlatpakExecutable(transport, originalExecutable); - if (current.target === wrapperPath) { - if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable"); - return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged }; - } - if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) { - throw new Error("Shortcut Target changed externally; refusing to replace it"); - } - const currentOriginal = selectFlatpakExecutable(transport, current.target); - if (!currentOriginal || (originalExecutable && !savedOriginal)) { - throw new Error("Flatpak shortcut Target is not a supported executable"); + const executable = directFlatpakExecutable(current.target, wrapperPath); + if (!executable) throw new Error("Flatpak shortcut Target is not a supported direct Flatpak executable"); + const target = managedFlatpakTarget(wrapperPath, executable); + if (normalizeLaunchOptions(current.target) === normalizeLaunchOptions(target)) { + return { snapshot: current, commandTokenAdded: false, changed: launchOptionsChanged }; } const value = await writeVerified( - appId, true, current.target, wrapperPath, - (target) => writeTarget(appId, target), readTarget, + appId, true, current.target, target, + (next) => writeTarget(appId, next), readTarget, "Steam did not accept the shortcut Target", ); - return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true }; + return { snapshot: value, commandTokenAdded: false, changed: true }; } const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); @@ -435,13 +425,12 @@ export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, - originalExecutable?: string, commandTokenAdded = false, - transport: TargetTransport = { kind: "host" }, + directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (usesShortcutTarget(nonSteam, transport)) { + if (nonSteam && directFlatpak) { const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); if (cleaned !== current.options) { current = await writeVerified( @@ -450,23 +439,16 @@ export function removeWrapperIntegration( "Steam did not clean shortcut launch options", ); } - if (current.target !== wrapperPath) { - if (isWrapperToken(current.target, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } - return current; - } - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); + const wrapped = wrappedFlatpakExecutable(current.target, wrapperPath); + if (wrapped) { + return writeVerified( + appId, true, current.target, wrapped, + (target) => writeTarget(appId, target), readTarget, + "Steam did not restore the shortcut Target", + ); } - return writeVerified( - appId, true, wrapperPath, originalExecutable, - (target) => writeTarget(appId, target), readTarget, - "Steam did not restore the shortcut Target", - ); + if (flatpakExecutable(current.target)) return current; + throw new Error("Shortcut Target changed externally; refusing to restore it"); } const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( -- 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(-) 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 3817fb2ddd47baff55b9e4030731dbdda6948a3b Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:41:39 -0400 Subject: refactor: simplify workaround api types --- src/api/lsfgApi.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 3e93620..aeaa502 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -66,7 +66,6 @@ export interface WorkaroundStateResult extends ApiResult { state?: WorkaroundState | null; wrapper_path?: string; wrapper_owned?: boolean; - shortcut_exe?: string | null; command_token_added?: boolean; } @@ -160,7 +159,6 @@ export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get export const setWorkaroundState = callable<[ string, WorkaroundState, - string | null | undefined, boolean, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); -- cgit v1.2.3 From 0df91099f4199dada43a7c804bb848722a493df2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:42:13 -0400 Subject: refactor: decouple game profiles from flatpak setup --- src/hooks/useGameConfiguration.ts | 57 ++++++++++----------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 3260019..4131d0f 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; @@ -23,7 +23,7 @@ async function getSteamShortcuts(): Promise { appid: String(appid >>> 0), name, nonSteam: true, - transport: { kind: "host" }, + directFlatpak: false, }]; }); } catch { @@ -80,6 +80,7 @@ export function useGameConfiguration() { previousQuickAccessVisible.current = quickAccessVisible; if (initialLoad || becameVisible) void load(); }, [load, quickAccessVisible]); + useEffect(() => { const poll = () => { if (!configsLoaded) return; @@ -90,7 +91,7 @@ export function useGameConfiguration() { const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); setRunningGame((current) => current?.appid === appid ? current : { - ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }), + ...(installed || { appid, name, nonSteam: false, directFlatpak: false }), name, configured: games.some((game) => game.appid === appid), }); @@ -99,6 +100,7 @@ export function useGameConfiguration() { const interval = window.setInterval(poll, 2000); return () => window.clearInterval(interval); }, [configsLoaded, games, installedGames]); + useEffect(() => { const appid = runningGame?.appid || null; if (appid !== previousRunningAppId.current) { @@ -109,26 +111,13 @@ export function useGameConfiguration() { const targets = useMemo(() => { const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true }); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, directFlatpak: false, configured: true }); if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); return configured; }, [games, installedGames, runningGame]); const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; - const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise => { - if (target.transport.kind !== "flatpak") return true; - const result = await ensureFlatpakSupport(target.transport.flatpakAppId); - if (!result.success || result.support_status !== "ready") { - showErrorToast( - "Flatpak support unavailable", - result.error || result.message || "The required Flatpak runtime extension is not ready", - ); - return false; - } - return true; - }, []); - const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; const appId = Number(target.appid); @@ -148,16 +137,13 @@ export function useGameConfiguration() { target.nonSteam, wrapperPath, commandTokenAdded, - target.transport, - target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined, + target.directFlatpak === true, ); stateWriteAttempted = true; const saved = await setWorkaroundState( target.appid, state, - integration.originalExecutable ?? null, integration.commandTokenAdded, - target.transport, ); if (!saved.success) throw new Error(saved.error || "Could not save workaround state"); return true; @@ -169,9 +155,8 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - integration.originalExecutable, integration.commandTokenAdded, - target.transport, + target.directFlatpak === true, ); } catch (rollbackError) { showErrorToast("Workaround rollback failed", asError(rollbackError).message); @@ -201,9 +186,8 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined, existing.command_token_added === true, - target.transport, + target.directFlatpak === true, ); const removed = await removeWorkaroundState(target.appid); if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); @@ -217,9 +201,6 @@ export function useGameConfiguration() { const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; - // The profile owns its wrapper integration. Keep this check on every - // configuration save so an external edit is detected before the profile - // is changed; toggles update the sidecar only. if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return; const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); if (result.success) await load(); @@ -228,19 +209,17 @@ export function useGameConfiguration() { const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; - if (!(await ensureTargetFlatpakSupport(target))) return false; if (!(await ensureTargetWorkarounds(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); else await removeTargetWorkarounds(target); return result.success; - }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const enableAll = useCallback(async (): Promise => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; - for (const target of available) { - if (!(await ensureTargetFlatpakSupport(target))) return; if (!(await ensureTargetWorkarounds(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { @@ -253,20 +232,11 @@ export function useGameConfiguration() { } } await load(); - }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const repair = useCallback(async (appid: string): Promise => { const target = targets.find((item) => item.appid === appid); if (!target) return false; - if (target.transport.kind === "flatpak") { - const support = await repairFlatpakSupport(target.transport.flatpakAppId); - if (!support.success || support.support_status !== "ready") { - showErrorToast( - "Flatpak support unavailable", - support.error || support.message || "The required Flatpak runtime extension is not ready", - ); - return false; - } - } const success = await ensureTargetWorkarounds(target); if (success) await load(); return success; @@ -284,6 +254,7 @@ export function useGameConfiguration() { } } }, [load, removeTargetWorkarounds, selectedAppId, targets]); + const resetAll = useCallback(async () => { for (const target of targets.filter((item) => item.configured)) { if (!(await removeTargetWorkarounds(target))) return; -- cgit v1.2.3 From 05bde72a64bd3fbf675e15e273043090f79d0a49 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:44:10 -0400 Subject: refactor: remove flatpak transport from workaround hook --- src/hooks/usePerAppWorkarounds.ts | 48 +++++++++++++++------------------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index 29e1181..30bbc6b 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -3,11 +3,9 @@ import { getWorkaroundState, removeWorkaroundState, setWorkaroundState, - type TargetTransport, type WorkaroundState, } from "../api/lsfgApi"; import { - assertKnownShortcutTarget, getDefaultWrapperPath, installWrapperIntegration, isWrapperIntegrationInstalled, @@ -45,8 +43,7 @@ export interface WorkaroundSnapshot { wrapperOwned: boolean; integrationInstalled: boolean; commandTokenAdded: boolean; - shortcutExe?: string | null; - transport: TargetTransport; + directFlatpak: boolean; } interface PerAppWorkarounds { @@ -65,29 +62,25 @@ function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited>, nonSteam: boolean, - transport: TargetTransport, + directFlatpak: boolean, ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); - const selectedTransport = result.transport || transport; - const shortcutExe = selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined; - assertKnownShortcutTarget(steam, nonSteam, selectedTransport, wrapperPath, shortcutExe); return { steam, state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, selectedTransport, wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, directFlatpak, wrapperPath), commandTokenAdded: result.command_token_added === true, - shortcutExe, - transport: selectedTransport, + directFlatpak, }; } async function adoptWorkaroundState( appId: string, nonSteam: boolean, - transport: TargetTransport, + directFlatpak: boolean, wrapperPath: string, ): Promise { let integration: Awaited> | null = null; @@ -97,17 +90,15 @@ async function adoptWorkaroundState( nonSteam, wrapperPath, false, - transport, + directFlatpak, ); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - integration.originalExecutable ?? null, integration.commandTokenAdded, - transport, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); - return makeSnapshot(integration.snapshot, finalized, nonSteam, transport); + return makeSnapshot(integration.snapshot, finalized, nonSteam, directFlatpak); } catch (error) { let rollbackSucceeded = true; if (integration?.changed) { @@ -116,12 +107,10 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, - integration.originalExecutable, integration.commandTokenAdded, - transport, + directFlatpak, ); } catch { - // Leave the owned integration in place rather than guessing at cleanup. rollbackSucceeded = false; } } @@ -136,7 +125,7 @@ async function adoptWorkaroundState( export function usePerAppWorkarounds( appId: string, nonSteam: boolean, - transport: TargetTransport = { kind: "host" }, + directFlatpak = false, ): PerAppWorkarounds { const [status, setStatus] = useState("loading"); const [snapshot, setSnapshot] = useState(null); @@ -154,12 +143,12 @@ export function usePerAppWorkarounds( return adoptWorkaroundState( appId, nonSteam, - transport, + directFlatpak, result.wrapper_path || getDefaultWrapperPath(), ); } - return makeSnapshot(steam, result, nonSteam, transport); - }, [appId, nonSteam, numericAppId, transport]); + return makeSnapshot(steam, result, nonSteam, directFlatpak); + }, [appId, directFlatpak, nonSteam, numericAppId]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { setSnapshot(next); @@ -194,7 +183,12 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.transport, current.wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled( + steam, + nonSteam, + current.directFlatpak, + current.wrapperPath, + ), } : current); }, (subscriptionError) => { @@ -229,24 +223,18 @@ export function usePerAppWorkarounds( setError(null); const nextState = { ...current.state, [field]: value } as WorkaroundState; try { - const shortcutExe = current.transport.kind === "flatpak" ? current.shortcutExe ?? null : null; const result = await setWorkaroundState( appId, nextState, - shortcutExe, current.commandTokenAdded, - current.transport, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); - const selectedTransport = result.transport || current.transport; applySnapshot({ ...current, state: result.state, wrapperPath: result.wrapper_path || current.wrapperPath, wrapperOwned: result.wrapper_owned === true, - shortcutExe: selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined, commandTokenAdded: result.command_token_added === true, - transport: selectedTransport, }); return true; } catch (updateError) { -- cgit v1.2.3 From c46d41b22df8a6830fe4483ba1b71e246b4ae5e6 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:44:26 -0400 Subject: refactor: pass direct flatpak launch shape --- src/components/GameConfigurationControls.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 6bea2e0..a7e3fec 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -10,7 +10,7 @@ interface Props { autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; - workaroundTarget?: Pick; + workaroundTarget?: Pick; onRepairWorkaround?: () => Promise; } @@ -36,7 +36,7 @@ export function GameConfigurationControls({ )} -- cgit v1.2.3 From 1ce6d55a1d44eeb0eb04751e124970f2f1f9acbb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:45:03 -0400 Subject: refactor: decouple workaround controls from flatpak transport --- src/components/WorkaroundsSection.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index 5587392..050992c 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -1,7 +1,6 @@ import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import type { TargetTransport } from "../api/lsfgApi"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; @@ -9,7 +8,7 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; - transport: TargetTransport; + directFlatpak?: boolean; onRepair?: () => Promise; } @@ -73,17 +72,15 @@ function usePersistentCollapsed() { useEffect(() => { try { localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); - } catch { - // Persisting the view preference is optional. - } + } catch {} }, [collapsed]); return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, directFlatpak = false, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); - const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, directFlatpak); const [repairing, setRepairing] = useState(false); const state = snapshot?.state; const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; -- cgit v1.2.3 From da9166ca8c6d90f402352e47ec28d67f2a02cf14 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:45:27 -0400 Subject: refactor: remove per-game flatpak support ui --- src/components/ConfigurationTab.tsx | 50 +++++++++++++------------------------ 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 4e2d93d..18575b4 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -100,8 +100,8 @@ export function ConfigurationTab({ const profileLabel = selectedTarget?.name || "Game profile"; const profileTransport = selectedTarget - ? selectedTarget.transport.kind === "flatpak" - ? "Non-Steam · Flatpak" + ? selectedTarget.directFlatpak + ? "Non-Steam · Direct Flatpak" : selectedTarget.nonSteam ? "Non-Steam" : "Steam" : "Game"; const profileDescription = selectedTarget @@ -120,9 +120,7 @@ export function ConfigurationTab({ } else if (detailAppId) { const isRunningUnconfigured = runningGame?.appid === detailAppId && runningGame.nonSteam === false - && runningGame.transport.kind === "host" && selectedTarget?.nonSteam === false - && selectedTarget?.transport.kind === "host" && !runningGame.configured; if (isRunningUnconfigured) { showModal( @@ -147,22 +145,22 @@ export function ConfigurationTab({
- - - + + +
)} - {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && ( - - - void onRepair(selectedTarget.appid)} - > - Repair Flatpak support - - - - )} {selectedTarget?.configured && ( Date: Thu, 10 Sep 2026 15:45:36 -0400 Subject: refactor: remove per-game flatpak repair state --- src/components/NowPlayingTab.tsx | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 57858db..1bc331f 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,5 +1,4 @@ -import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useState } from "react"; +import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; @@ -11,11 +10,10 @@ interface Props { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], ) => Promise; - onRepair: (appid: string) => Promise; } function targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; + if (game.directFlatpak) return "Non-Steam · Direct Flatpak"; return game.nonSteam ? "Non-Steam" : "Steam"; } @@ -23,23 +21,7 @@ export function NowPlayingTab({ game, config, onConfigChange, - onRepair, }: Props) { - const [busy, setBusy] = useState(false); - const supportNeedsRepair = - game.transport.kind === "flatpak" && - game.flatpakSupport?.support_status !== "ready"; - - const handleRepair = async () => { - if (busy) return; - setBusy(true); - try { - await onRepair(game.appid); - } finally { - setBusy(false); - } - }; - return ( @@ -47,21 +29,6 @@ export function NowPlayingTab({ - {supportNeedsRepair && ( - - - - - - void handleRepair()}> - {busy ? "Repairing..." : "Repair Flatpak support"} - - - - )} Date: Thu, 10 Sep 2026 15:45:58 -0400 Subject: feat: add explicit flatpak setup --- src/components/FlatpakSetupSection.tsx | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/components/FlatpakSetupSection.tsx diff --git a/src/components/FlatpakSetupSection.tsx b/src/components/FlatpakSetupSection.tsx new file mode 100644 index 0000000..0119250 --- /dev/null +++ b/src/components/FlatpakSetupSection.tsx @@ -0,0 +1,110 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; +import { useCallback, useEffect, useState } from "react"; +import { + getFlatpakApps, + prepareFlatpakApp, + removeFlatpakApp, + type FlatpakApp, +} from "../api/lsfgApi"; +import { showErrorToast } from "../utils/toastUtils"; + +interface Props { + enabled: boolean; +} + +export function FlatpakSetupSection({ enabled }: Props) { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(false); + const [busyApp, setBusyApp] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!enabled) { + setApps([]); + setError(null); + return; + } + setLoading(true); + try { + const result = await getFlatpakApps(); + if (!result.success) throw new Error(result.error || "Could not list Flatpak applications"); + setApps(result.apps || []); + setError(null); + } catch (loadError) { + const message = loadError instanceof Error ? loadError.message : String(loadError); + setError(message); + } finally { + setLoading(false); + } + }, [enabled]); + + useEffect(() => { + void load(); + }, [load]); + + const toggle = async (app: FlatpakApp) => { + if (busyApp || (app.prepared && !app.owned)) return; + setBusyApp(app.app_id); + try { + const result = app.prepared + ? await removeFlatpakApp(app.app_id) + : await prepareFlatpakApp(app.app_id); + if (!result.success) throw new Error(result.error || "Flatpak setup failed"); + await load(); + } catch (operationError) { + const message = operationError instanceof Error ? operationError.message : String(operationError); + showErrorToast("Flatpak setup failed", message); + } finally { + setBusyApp(""); + } + }; + + if (!enabled) return null; + + return ( + + + + + {error && ( + + + + )} + {apps.map((app) => { + const busy = busyApp === app.app_id; + const description = [ + app.app_id, + app.runtime_branch ? `runtime ${app.runtime_branch}` : null, + app.error, + ].filter(Boolean).join(" · "); + const label = busy + ? "Working..." + : app.prepared + ? app.owned ? "Remove" : "Prepared externally" + : app.error ? "Unavailable" : "Prepare"; + return ( + + + void toggle(app)} + > + {label} + + + + ); + })} + + void load()}> + {loading ? "Refreshing..." : "Refresh Flatpaks"} + + + + ); +} -- cgit v1.2.3 From 644e624e6e8745f58b4f3bee4a672f180992ad1b Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:46:18 -0400 Subject: feat: add flatpak setup to setup tab --- src/components/SetupTab.tsx | 52 ++++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index d769200..1a62c2a 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,6 +1,7 @@ import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; import { type SteamBranchStatus } from "../api/lsfgApi"; import t from "../i18n/i18n"; +import { FlatpakSetupSection } from "./FlatpakSetupSection"; interface SetupTabProps { isInstalled: boolean; @@ -36,33 +37,36 @@ export function SetupTab(props: SetupTabProps) { : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); return ( - - - - - - - - {steamBranchStatus?.installed && ( + <> + - )} - - - {buttonLabel} - - - + + + + {steamBranchStatus?.installed && ( + + + + )} + + + {buttonLabel} + + + + + ); } -- cgit v1.2.3 From f83e6cacec84ccc8e17be50d68e228aab996cee5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:46:51 -0400 Subject: refactor: remove now-playing flatpak repair coupling --- src/components/Content.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 43720c0..8f83e85 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -32,9 +32,7 @@ function usePersistentBoolean(key: string, defaultValue: boolean) { useEffect(() => { try { localStorage.setItem(key, String(value)); - } catch { - // Persisting the visibility preference is optional. - } + } catch {} }, [key, value]); return [value, setValue] as const; @@ -132,7 +130,6 @@ export function Content() { game={runningGame} config={config} onConfigChange={(field, value) => handleConfigChange(field, value)} - onRepair={repair} /> ), }] : []), -- cgit v1.2.3 From eb14f3b6fdb135e1c9fd608244aa67fc3eb6b662 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:47:27 -0400 Subject: test: cover explicit direct flatpak detection --- tests/test_steam_service.py | 99 +++++++++++---------------------------------- 1 file changed, 23 insertions(+), 76 deletions(-) diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 636c87f..719249d 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -11,73 +11,21 @@ sys.modules.setdefault( ) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) -from lsfg_vk.steam_service import SteamService, classify_shortcut_transport +from lsfg_vk.steam_service import SteamService, is_direct_flatpak_shortcut -class SteamTransportTests(unittest.TestCase): - def test_only_direct_canonical_flatpak_forms_are_classified(self): - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak", - "run com.example.PCSX2 --fullscreen", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "flatpak", - "run com.example.PCSX2 --fullscreen", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak run com.example.PCSX2", - "--fullscreen", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/bash", - "~/launch-game.sh --fullscreen", - ), - {"kind": "host"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak", - "--user run com.example.PCSX2", - ), - {"kind": "host"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak", - "run bash ~/launch-game.sh", - ), - {"kind": "host"}, - ) - self.assertEqual( - classify_shortcut_transport( - "~/.lsfg", - "run --branch=stable --arch=x86_64 com.example.PCSX2", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/home/deck/.lsfg", - "run com.example.PCSX2", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport("~/.lsfg", "--profile high"), - {"kind": "host"}, - ) +class SteamShortcutTests(unittest.TestCase): + def test_only_direct_flatpak_targets_are_special(self): + self.assertTrue(is_direct_flatpak_shortcut("/usr/bin/flatpak")) + self.assertTrue(is_direct_flatpak_shortcut("flatpak")) + self.assertTrue(is_direct_flatpak_shortcut("/usr/bin/flatpak run com.example.Game")) + self.assertTrue(is_direct_flatpak_shortcut('~/.lsfg "/usr/bin/flatpak"')) + self.assertFalse(is_direct_flatpak_shortcut("/usr/bin/bash")) + self.assertFalse(is_direct_flatpak_shortcut("/home/deck/Emulation/tools/launchers/retroarch.sh")) + self.assertFalse(is_direct_flatpak_shortcut("/home/deck/Emulation/tools/launchers/ppsspp.sh")) + self.assertFalse(is_direct_flatpak_shortcut("/home/deck/AppImages/dusk.appimage")) - def test_shortcut_data_preserves_transport_inputs(self): + def test_shortcut_data_preserves_launch_shape_without_flatpak_identity(self): game = SteamService._shortcut_game( { "appid": 123456, @@ -89,28 +37,27 @@ class SteamTransportTests(unittest.TestCase): ) self.assertEqual(game["appid"], "123456") - self.assertEqual(game["transport"], { - "kind": "flatpak", - "flatpakAppId": "net.pcsx2.PCSX2", - }) + self.assertTrue(game["directFlatpak"]) + self.assertNotIn("transport", game) self.assertEqual(game["executable"], "/usr/bin/flatpak") self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen") self.assertEqual(game["startDir"], "/home/deck/Games") - def test_wrapped_flatpak_shortcut_remains_a_flatpak_target(self): + def test_emudeck_launcher_is_ordinary_non_steam(self): game = SteamService._shortcut_game( { "appid": 987654, - "AppName": "Wrapped Flatpak", - "Exe": "~/.lsfg", - "LaunchOptions": "run --branch=stable --arch=x86_64 com.example.Game", + "AppName": "1080 Snowboarding", + "Exe": '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', + "LaunchOptions": "", } ) - self.assertEqual(game["transport"], { - "kind": "flatpak", - "flatpakAppId": "com.example.Game", - }) + self.assertFalse(game["directFlatpak"]) + self.assertEqual( + game["executable"], + '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', + ) if __name__ == "__main__": -- cgit v1.2.3 From c6a2abf2f0cab99a8a41709a4bbf6e391a112b4f Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:48:00 -0400 Subject: test: simplify workaround wrapper coverage --- tests/test_wrapper_service.py | 149 +++++++----------------------------------- 1 file changed, 23 insertions(+), 126 deletions(-) diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py index 0632d93..5c291c7 100644 --- a/tests/test_wrapper_service.py +++ b/tests/test_wrapper_service.py @@ -1,4 +1,3 @@ -import os import subprocess import sys import tempfile @@ -24,10 +23,10 @@ class WrapperServiceTests(unittest.TestCase): self.home.mkdir(parents=True) self.service = WrapperService() self.service.user_home = self.home - self.service.local_bin_dir = self.home / ".local/bin" self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" self.service.sidecar_path = self.service.config_dir / "workarounds.json" - self.service.wrapper_path = self.service.local_bin_dir / "lsfg" + self.service.wrapper_path = self.home / ".lsfg" def tearDown(self): self.tempdir.cleanup() @@ -59,7 +58,7 @@ class WrapperServiceTests(unittest.TestCase): self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8")) self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True)) - def test_dispatch_clears_managed_values_preserves_other_environment_and_appends_config(self): + def test_dispatch_exports_appid_config_and_workarounds(self): self.service.set( "123", self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True), @@ -71,12 +70,16 @@ class WrapperServiceTests(unittest.TestCase): "DXVK_CONFIG": "dxgi.syncInterval = 0", "DXVK_FRAME_RATE": "5", "ENABLE_GAMESCOPE_WSI": "1", + "DISABLE_LSFGVK": "1", + "DISABLE_LSFG": "1", "DISABLE_VKBASALT": "0", "MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe", "MANGOHUD": "1", }, ) values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + self.assertEqual(values["SteamAppId"], "123") + self.assertEqual(values["LSFGVK_CONFIG"], str(self.service.config_file_path)) self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0") self.assertEqual(values["DXVK_HDR"], "0") self.assertEqual(values["SteamDeck"], "0") @@ -88,6 +91,20 @@ class WrapperServiceTests(unittest.TestCase): self.assertEqual(values["MANGOHUD"], "1") self.assertNotIn("DXVK_FRAME_RATE", values) self.assertNotIn("ENABLE_VKBASALT", values) + self.assertNotIn("DISABLE_LSFGVK", values) + self.assertNotIn("DISABLE_LSFG", values) + + def test_wrapper_is_transport_agnostic(self): + self.service.set("123", self._state()) + fake = self.home / "target" + fake.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n", encoding="utf-8") + fake.chmod(0o755) + result = self._run(123, str(fake), "run", "org.example.Game") + self.assertEqual(result.stdout.splitlines(), ["run", "org.example.Game"]) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertNotIn("flatpakAppId", content) + self.assertNotIn("shortcut_exe", content) + self.assertNotIn("--filesystem", content) def test_appid_fallback_and_unmatched_passthrough(self): self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False)) @@ -101,7 +118,7 @@ class WrapperServiceTests(unittest.TestCase): ) fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line) self.assertEqual(fallback_values["SteamDeck"], "0") - self.assertEqual(fallback_values["SteamGameId"], "456") + self.assertEqual(fallback_values["SteamAppId"], "456") passthrough = subprocess.run( [str(self.service.wrapper_path), "/usr/bin/env"], @@ -114,139 +131,19 @@ class WrapperServiceTests(unittest.TestCase): self.assertEqual(passthrough_values["KEEP"], "yes") self.assertEqual(passthrough_values["DXVK_HDR"], "1") - def test_flatpak_shortcut_receives_env_arguments_and_original_target(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text( - "#!/bin/sh\n" - "printf 'ARG:%s\\n' \"$@\"\n", - encoding="utf-8", - ) - fake_flatpak.chmod(0o755) - self.service.set( - "123", - self._state(dxvkFrameRate=20, enableZink=True), - str(fake_flatpak), - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - result = self._run(123, "run", "com.example.Game", "--windowed", env={"DXVK_CONFIG": "foo=1"}) - args = result.stdout.splitlines() - self.assertEqual(args[0], "ARG:run") - self.assertIn("ARG:--filesystem=" + str(self.service.config_dir) + ":rw", args) - self.assertIn("ARG:--filesystem=" + str(self.home / ".local/share/Steam/steamapps/common/Lossless Scaling") + ":ro", args) - self.assertIn("ARG:--env=LSFGVK_CONFIG=" + str(self.service.config_file_path), args) - self.assertIn("ARG:--env=LSFGVK_FLATPAK=1", args) - self.assertIn("ARG:--env=SteamAppId=123", args) - self.assertIn("ARG:--env=ENABLE_GAMESCOPE_WSI=0", args) - self.assertIn("ARG:--env=DXVK_HDR=0", args) - self.assertIn("ARG:--env=__GLX_VENDOR_LIBRARY_NAME=mesa", args) - self.assertIn("ARG:--env=MESA_LOADER_DRIVER_OVERRIDE=zink", args) - self.assertIn("ARG:--env=GALLIUM_DRIVER=zink", args) - self.assertIn("ARG:--env=DXVK_CONFIG=foo=1; dxvk.maxFrameRate = 20", args) - self.assertIn("ARG:com.example.Game", args) - self.assertIn("ARG:--windowed", args) - - def test_flatpak_full_executable_form_is_preserved(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text( - "#!/bin/sh\n" - "printf 'ARG:%s\\n' \"$@\"\n", - encoding="utf-8", - ) - fake_flatpak.chmod(0o755) - response = self.service.set( - "123", - self._state(), - f"{fake_flatpak} run com.example.Game", - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - self.assertTrue(response["success"]) - result = self._run(123, "--windowed") - args = result.stdout.splitlines() - self.assertEqual(args[0], "ARG:run") - self.assertIn("ARG:com.example.Game", args) - self.assertIn("ARG:--windowed", args) - - def test_host_transport_does_not_store_shortcut_target(self): - self.service.set( - "123", - self._state(), - "/usr/bin/flatpak", - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - response = self.service.set( - "123", - self._state(), - "/usr/bin/ignored", - False, - {"kind": "host"}, - ) - self.assertTrue(response["success"]) - self.assertIsNone(response["shortcut_exe"]) - self.assertIsNone(self.service.get("123")["shortcut_exe"]) - - def test_flatpak_transport_rejects_non_run_invocation(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - fake_flatpak.chmod(0o755) - response = self.service.set( - "123", - self._state(), - str(fake_flatpak), - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - self.assertTrue(response["success"]) - result = subprocess.run( - [str(self.service.wrapper_path), "bash", "launch-game.sh"], - env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 64) - self.assertIn("direct flatpak run", result.stderr) - - def test_flatpak_transport_rejects_external_app_id_change(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - fake_flatpak.chmod(0o755) - response = self.service.set( - "123", - self._state(), - str(fake_flatpak), - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - self.assertTrue(response["success"]) - result = subprocess.run( - [str(self.service.wrapper_path), "run", "com.other.Game"], - env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 64) - self.assertIn("application ID changed externally", result.stderr) - def test_invalid_state_and_foreign_wrapper_fail_closed(self): invalid = self.service.set("0", self.service.default_state()) self.assertFalse(invalid["success"]) invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61}) self.assertFalse(invalid["success"]) - self.service.local_bin_dir.mkdir(parents=True, exist_ok=True) self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") response = self.service.set("123", self.service.default_state()) self.assertFalse(response["success"]) self.assertIn("unowned", response["error"]) self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") - def test_remove_keeps_a_safe_owned_passthrough_wrapper(self): + def test_remove_keeps_safe_passthrough_wrapper(self): self.service.set("123", self.service.default_state()) response = self.service.remove("123") self.assertTrue(response["success"]) -- cgit v1.2.3 From 8b856c63bf626d0d86445a4715dce963aa1d5f16 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:48:47 -0400 Subject: test: cover explicit flatpak app preparation --- tests/test_flatpak_service.py | 390 ++++++++++++++++++++---------------------- 1 file changed, 183 insertions(+), 207 deletions(-) diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index d5baf61..208e05b 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -25,16 +25,19 @@ class FlatpakServiceTests(unittest.TestCase): self.service.user_home = self.home self.service.config_dir = self.home / ".config/lsfg-vk" self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) self.service.check_flatpak_available = Mock(return_value=True) self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command) self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08" self.runtime_metadata = "" self.user_branches = set() self.system_branches = set() - self.install_branch = "24.08" - self.bundle = self.home / "lsfg-vk-24.08.flatpak" + self.apps = {"com.example.Game": "Example Game"} + self.bundle = self.home / "lsfg-vk.flatpak" self.bundle.write_bytes(b"bundle") self.service._bundled_extension_path = Mock(return_value=self.bundle) + self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling" + self.service._dll_directory = Mock(return_value=self.dll_dir) def tearDown(self): self.tempdir.cleanup() @@ -47,252 +50,225 @@ class FlatpakServiceTests(unittest.TestCase): def _extension_line(branch): return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + @staticmethod + def _parse_override(content): + section = None + filesystems = [] + unset_environment = [] + environment = {} + other = [] + for raw in content.splitlines(): + line = raw.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.extend(item for item in value.split(";") if item) + elif section == "Context" and key == "unset-environment": + unset_environment.extend(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + else: + other.append((section, key, value)) + return filesystems, unset_environment, environment, other + + @staticmethod + def _serialize_override(filesystems, unset_environment, environment): + lines = ["[Context]"] + if filesystems: + lines.append("filesystems=" + ";".join(filesystems) + ";") + if unset_environment: + lines.append("unset-environment=" + ";".join(unset_environment) + ";") + if environment: + lines.append("") + lines.append("[Environment]") + lines.extend(f"{key}={value}" for key, value in environment.items()) + return "\n".join(lines) + "\n" + + def _apply_override(self, args): + app_id = args[-1] + path = self.service._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + filesystems, unset_environment, environment, _ = self._parse_override(content) + for arg in args[2:-1]: + if arg.startswith("--filesystem="): + value = arg.split("=", 1)[1] + if value not in filesystems: + filesystems.append(value) + elif arg.startswith("--env="): + key, value = arg.split("=", 1)[1].split("=", 1) + environment[key] = value + if key in unset_environment: + unset_environment.remove(key) + elif arg.startswith("--unset-env="): + key = arg.split("=", 1)[1] + environment.pop(key, None) + if key not in unset_environment: + unset_environment.append(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override(filesystems, unset_environment, environment), + encoding="utf-8", + ) + return self._result() + def _run_flatpak_command(self, args, **_kwargs): - if args[0] == "info" and args[1] == "--show-runtime": + if args[:2] == ["info", "--show-runtime"]: return self._result(self.runtime_ref + "\n") - if args[0] == "info" and args[1] == "--show-metadata": + if args[:2] == ["info", "--show-metadata"]: return self._result(self.runtime_metadata) + if args[:2] == ["list", "--app"]: + return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items())) if args[0] == "list": branches = self.user_branches if "--user" in args else self.system_branches return self._result("".join(self._extension_line(branch) for branch in sorted(branches))) if args[0] == "install": - self.user_branches.add(self.install_branch) + self.user_branches.add(self.runtime_ref.rsplit("/", 1)[-1]) return self._result() if args[0] == "uninstall": self.user_branches.discard(args[-1].rsplit("/", 1)[-1]) return self._result() + if args[:3] == ["override", "--user", "--show"]: + path = self.service._override_path(args[-1]) + return self._result(path.read_text(encoding="utf-8") if path.exists() else "") + if args[:2] == ["override", "--user"]: + return self._apply_override(args) raise AssertionError(f"Unexpected Flatpak command: {args}") - def test_runtime_branch_mapping_is_strict_and_branch_specific(self): - self.assertEqual( - FlatpakService.runtime_branch_from_ref( - "org.freedesktop.Platform/x86_64/24.08" - ), - "24.08", - ) - self.assertEqual( - FlatpakService.runtime_branch_from_ref( - "org.freedesktop.Platform//25.08" - ), - "25.08", - ) - with self.assertRaises(ValueError): - FlatpakService.runtime_branch_from_ref("org.gnome.Sdk/x86_64/46") - with self.assertRaises(ValueError): - FlatpakService.runtime_branch_from_ref( - "org.freedesktop.Platform/x86_64/26.08" - ) - - def test_runtime_branch_mapping_reads_documented_gl_metadata(self): - metadata = """ -[Extension org.freedesktop.Platform.GL] -versions=25.08;25.08-extra;1.4 -version=1.4 -""" - self.assertEqual(FlatpakService.runtime_branch_from_metadata(metadata), "25.08") - with self.assertRaises(ValueError): - FlatpakService.runtime_branch_from_metadata( - "[Extension org.freedesktop.Platform.GL]\nversions=26.08;26.08-extra;1.4\n" - ) - - def test_resolve_reads_required_runtime_instead_of_any_installed_branch(self): - self.user_branches = {"23.08"} - - response = self.service.resolve_app_support("com.example.Game") + def test_resolves_freedesktop_and_derived_runtimes(self): + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "24.08") + + self.runtime_ref = "org.kde.Platform/x86_64/6.10" + self.runtime_metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "25.08") + + def test_prepare_app_installs_runtime_and_persists_narrow_override(self): + response = self.service.prepare_app("com.example.Game") self.assertTrue(response["success"]) + self.assertTrue(response["prepared"]) + self.assertTrue(response["owned"]) self.assertEqual(response["runtime_branch"], "24.08") - self.assertEqual(response["support_status"], "needs-runtime") - self.assertFalse(response["extension_installed"]) - self.assertEqual( - self.service._run_flatpak_command.call_args_list[0].args[0], - ["info", "--show-runtime", "com.example.Game"], - ) - self.assertEqual( - self.service._run_flatpak_command.call_args_list[1].args[0], - ["list", "--user", "--runtime", "--columns=application,arch,branch"], - ) - self.assertEqual( - self.service._run_flatpak_command.call_args_list[2].args[0], - ["list", "--system", "--runtime", "--columns=application,arch,branch"], - ) - - def test_resolve_maps_kde_and_gnome_runtimes_from_gl_metadata(self): - metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" - for runtime in ("org.kde.Platform/x86_64/6.10", "org.gnome.Platform/x86_64/49"): - with self.subTest(runtime=runtime): - self.service._run_flatpak_command.reset_mock() - self.runtime_ref = runtime - self.runtime_metadata = metadata - response = self.service.resolve_app_support("com.example.Game") - self.assertEqual(response["runtime_branch"], "25.08") - self.assertEqual(response["support_status"], "needs-runtime") - self.assertEqual( - self.service._run_flatpak_command.call_args_list[1].args[0], - ["info", "--show-metadata", runtime], - ) - - def test_system_extension_is_ready_without_installing_a_user_copy(self): + self.assertEqual(self.user_branches, {"24.08"}) + status = self.service._app_override_status("com.example.Game") + self.assertTrue(status["prepared"]) + content = self.service._override_path("com.example.Game").read_text(encoding="utf-8") + self.assertIn(str(self.service.config_dir) + ":ro", content) + self.assertIn(str(self.dll_dir) + ":ro", content) + self.assertIn("LSFGVK_CONFIG=" + str(self.service.config_file_path), content) + self.assertIn("LSFGVK_FLATPAK=1", content) + self.assertNotIn("ENABLE_GAMESCOPE_WSI", content) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], ["24.08"]) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_prepare_is_idempotent(self): + first = self.service.prepare_app("com.example.Game") + first_content = self.service._override_path("com.example.Game").read_bytes() + second = self.service.prepare_app("com.example.Game") + + self.assertTrue(first["success"]) + self.assertTrue(second["success"]) + self.assertEqual(first_content, self.service._override_path("com.example.Game").read_bytes()) + install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"] + self.assertEqual(len(install_calls), 1) + + def test_preinstalled_runtime_is_not_owned(self): self.system_branches = {"24.08"} - - response = self.service.ensure_app_support("com.example.Game") + response = self.service.prepare_app("com.example.Game") self.assertTrue(response["success"]) - self.assertEqual(response["support_status"], "ready") - self.assertEqual( - [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["info", "list", "list"], + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], []) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_external_preparation_is_preserved(self): + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override( + [str(self.service.config_dir) + ":ro", str(self.dll_dir) + ":ro"], + ["DISABLE_LSFGVK", "DISABLE_LSFG"], + { + "LSFGVK_CONFIG": str(self.service.config_file_path), + "LSFGVK_FLATPAK": "1", + }, + ), + encoding="utf-8", ) - self.assertFalse(any(call.args[0][0] == "install" for call in self.service._run_flatpak_command.call_args_list)) + self.system_branches = {"24.08"} - def test_install_records_only_a_new_user_owned_branch(self): - response = self.service.install_extension("24.08") + response = self.service.prepare_app("com.example.Game") self.assertTrue(response["success"]) - self.assertTrue(response["enabled"]) - self.assertTrue(response["installed"]) - install_args = self.service._run_flatpak_command.call_args_list[2].args[0] - self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"]) - self.assertEqual( - json.loads(self.service.ownership_path.read_text(encoding="utf-8")), - {"version": 1, "plugin_owned_branches": ["24.08"]}, - ) - - def test_preexisting_branch_is_not_claimed_or_removed(self): - self.user_branches = {"24.08"} - - install_response = self.service.install_extension("24.08") - cleanup_response = self.service.remove_plugin_owned_extensions() - - self.assertTrue(install_response["success"]) - self.assertTrue(install_response["enabled"]) - self.assertTrue(install_response["installed"]) + self.assertTrue(response["prepared"]) + self.assertFalse(response["owned"]) self.assertFalse(self.service.ownership_path.exists()) - self.assertTrue(cleanup_response["success"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 2) - - def test_extension_toggle_preserves_preexisting_branch(self): - self.user_branches = {"24.08"} - - enable_response = self.service.set_extension_enabled("24.08", True) - disable_response = self.service.set_extension_enabled("24.08", False) - - self.assertTrue(enable_response["success"]) - self.assertTrue(enable_response["enabled"]) - self.assertTrue(enable_response["installed"]) - self.assertTrue(disable_response["success"]) - self.assertTrue(disable_response["enabled"]) - self.assertTrue(disable_response["installed"]) - self.assertFalse(disable_response["removed"]) - self.assertEqual(self.user_branches, {"24.08"}) - self.assertEqual( - [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["list", "list", "list", "list"], - ) - def test_extension_toggle_removes_owned_user_branch_but_preserves_system_branch(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.write_text( - json.dumps({"version": 1, "plugin_owned_branches": ["24.08"]}), - encoding="utf-8", - ) - self.user_branches = {"24.08"} - self.system_branches = {"24.08"} + def test_remove_restores_exact_previous_override(self): + original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n" + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(original, encoding="utf-8") + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) - disable_response = self.service.set_extension_enabled("24.08", False) - repeat_response = self.service.set_extension_enabled("24.08", False) + response = self.service.remove_app_override("com.example.Game") - self.assertTrue(disable_response["success"]) - self.assertTrue(disable_response["enabled"]) - self.assertTrue(disable_response["removed"]) - self.assertTrue(repeat_response["success"]) - self.assertTrue(repeat_response["enabled"]) - self.assertTrue(repeat_response["installed"]) - self.assertEqual(self.user_branches, set()) - self.assertEqual(self.system_branches, {"24.08"}) + self.assertTrue(response["success"]) + self.assertEqual(path.read_text(encoding="utf-8"), original) self.assertFalse(self.service.ownership_path.exists()) - uninstall_commands = [ - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "uninstall" - ] - self.assertEqual(len(uninstall_commands), 1) - def test_corrupt_ownership_metadata_fails_closed(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.write_text("{not-json", encoding="utf-8") + def test_remove_deletes_override_created_by_plugin(self): + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + self.assertTrue(path.exists()) - response = self.service.remove_plugin_owned_extensions() + response = self.service.remove_app_override("com.example.Game") - self.assertFalse(response["success"]) - self.assertTrue(response["ownership_uncertain"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 0) + self.assertTrue(response["success"]) + self.assertFalse(path.exists()) - def test_dangling_ownership_symlink_fails_closed(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.symlink_to(self.home / "missing-metadata") + def test_remove_fails_closed_after_external_change(self): + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + with path.open("a", encoding="utf-8") as handle: + handle.write("EXTERNAL=1\n") - response = self.service.remove_plugin_owned_extensions() + response = self.service.remove_app_override("com.example.Game") self.assertFalse(response["success"]) - self.assertTrue(response["ownership_uncertain"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 0) + self.assertIn("changed after preparation", response["error"]) + self.assertTrue(path.exists()) + self.assertTrue(self.service.ownership_path.exists()) - def test_ensure_app_support_installs_only_the_app_runtime_branch(self): - response = self.service.ensure_app_support("com.example.Game") + def test_full_cleanup_removes_only_owned_state(self): + self.system_branches = {"23.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + self.assertEqual(self.user_branches, {"24.08"}) - self.assertTrue(response["success"]) - self.assertEqual(response["support_status"], "ready") - self.assertEqual(response["runtime_branch"], "24.08") - install_args = next( - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "install" - ) - self.assertEqual(install_args[0], "install") - self.assertIn("--user", install_args) - self.assertNotIn("23.08", install_args) - self.assertEqual( - json.loads(self.service.ownership_path.read_text(encoding="utf-8")), - {"version": 1, "plugin_owned_branches": ["24.08"]}, - ) + response = self.service.remove_plugin_owned_environment() - def test_two_shortcuts_using_one_flatpak_share_one_extension_branch(self): - first = self.service.ensure_app_support("net.pcsx2.PCSX2") - second = self.service.ensure_app_support("net.pcsx2.PCSX2.Dev") - - self.assertEqual(first["support_status"], "ready") - self.assertEqual(second["support_status"], "ready") - install_commands = [ - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "install" - ] - self.assertEqual(len(install_commands), 1) - self.assertEqual( - json.loads(self.service.ownership_path.read_text(encoding="utf-8")), - {"version": 1, "plugin_owned_branches": ["24.08"]}, - ) + self.assertTrue(response["success"]) + self.assertEqual(response["removed_apps"], ["com.example.Game"]) + self.assertEqual(response["removed_branches"], ["24.08"]) + self.assertEqual(self.user_branches, set()) + self.assertEqual(self.system_branches, {"23.08"}) + self.assertFalse(self.service.ownership_path.exists()) - def test_cleanup_removes_all_owned_branches_without_reusing_stale_metadata(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.write_text( - json.dumps({"version": 1, "plugin_owned_branches": ["23.08", "24.08"]}), - encoding="utf-8", - ) - self.user_branches = {"23.08", "24.08"} + def test_corrupt_ownership_metadata_fails_closed(self): + self.service.ownership_path.write_text("{not-json", encoding="utf-8") - response = self.service.remove_plugin_owned_extensions() + response = self.service.remove_plugin_owned_environment() - self.assertTrue(response["success"]) - self.assertEqual(response["removed_branches"], ["23.08", "24.08"]) - self.assertFalse(self.service.ownership_path.exists()) - uninstall_commands = [ - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "uninstall" - ] - self.assertEqual(len(uninstall_commands), 2) + self.assertFalse(response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 0) if __name__ == "__main__": -- cgit v1.2.3 From 92cffa96763448b83d5e762020631e3346d6e9da Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:49:29 -0400 Subject: test: cover explicit direct flatpak target integration --- tests/steamLaunchOptions.test.ts | 177 ++++++++++++++++----------------------- 1 file changed, 74 insertions(+), 103 deletions(-) diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index c799907..93d37df 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -28,7 +28,7 @@ test("inserts one wrapper immediately before an existing command macro", () => { }); }); -test("normalizes blank and argument-only fields while refusing ambiguous launchers", () => { +test("normalizes blank and argument-only shortcut fields", () => { assert.deepEqual(installWrapperLaunchOption("", wrapper), { options: `${wrapper} %command%`, commandTokenAdded: true, @@ -41,7 +41,7 @@ test("normalizes blank and argument-only fields while refusing ambiguous launche assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); }); -test("preserves assignments, quoting, suffixes, and unrelated values", () => { +test("preserves assignments quoting suffixes and released wrapper cleanup", () => { const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"'; assert.equal( installWrapperLaunchOption(options, wrapper).options, @@ -49,36 +49,28 @@ test("preserves assignments, quoting, suffixes, and unrelated values", () => { ); assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`); assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag"); -}); - -test("cleans current, legacy, and bare Mako wrappers without touching suffix arguments", () => { for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) { assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`); } - assert.equal(cleanupLegacyWrapper(`FOO=bar ${wrapper} %command%`), "FOO=bar %command%"); assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true); assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false); - assert.equal(removeWrapperLaunchOption(`FOO=bar ${wrapper} %command% --arg`, wrapper), "FOO=bar %command% --arg"); }); -test("removes only old plugin assignments and preserves DXVK settings", () => { +test("removes only managed assignments and preserves unrelated values", () => { assert.equal( cleanupPluginAssignments( 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%', ), 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%', ); - assert.equal( - cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), - "%command%", - ); + assert.equal(cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), "%command%"); assert.equal( cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"), "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%", ); }); -test("reads the matching app-details field and installs/removes Steam integration", async () => { +test("uses launch options for Steam and MAKO-style Target wrapping for direct Flatpak", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; let appOptions = "FOO=bar %command%"; @@ -120,18 +112,22 @@ test("reads the matching app-details field and installs/removes Steam integratio assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " ")); assert.equal(installed.commandTokenAdded, false); assert.equal(appWrites.length, 1); - assert.equal(shortcutWrites.length, 0); - const shortcut = await installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }); - assert.equal(shortcut.originalExecutable, "/usr/bin/flatpak"); - assert.equal(shortcut.snapshot.target, wrapper); - assert.deepEqual(targetWrites, [wrapper]); - const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }); + const shortcut = await installWrapperIntegration(43, true, wrapper, false, true); + assert.equal(shortcut.snapshot.target, '~/.lsfg "/usr/bin/flatpak"'); + assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"']); + assert.equal(shortcut.snapshot.options, "--windowed"); + assert.deepEqual(shortcutWrites, []); + + const second = await installWrapperIntegration(43, true, wrapper, false, true); + assert.equal(second.changed, false); + assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"']); + + const restored = await removeWrapperIntegration(43, true, wrapper, false, true); assert.equal(restored.target, "/usr/bin/flatpak"); - assert.deepEqual(targetWrites, [wrapper, "/usr/bin/flatpak"]); - assert.equal(shortcutWrites.length, 0); + assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"', "/usr/bin/flatpak"]); - const cleaned = await removeWrapperIntegration(42, false, wrapper, undefined, installed.commandTokenAdded); + const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded); assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " ")); assert.ok(unregisters.includes(42)); assert.ok(unregisters.includes(43)); @@ -143,46 +139,54 @@ test("reads the matching app-details field and installs/removes Steam integratio } }); -test("uses shortcut launch options for a host shortcut without changing its Target", async () => { +test("AppImage and EmuDeck script shortcuts stay launch-option based", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; - const originalOptions = 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"'; - let shortcutOptions = originalOptions; - let shortcutTarget = "env"; - const shortcutWrites: string[] = []; - const targetWrites: string[] = []; - const apps = { - RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions }); - return { unregister() {} }; + const cases = [ + { + target: "env", + options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"', + expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"', }, - SetShortcutLaunchOptions(_appId: number, options: string) { - shortcutWrites.push(options); - shortcutOptions = options; + { + target: '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', + options: "", + expected: "~/.lsfg %command%", }, - SetShortcutExe(_appId: number, executable: string) { - targetWrites.push(executable); - shortcutTarget = executable; - }, - }; + ]; (globalThis as Record).window = { setTimeout, clearTimeout }; - (globalThis as Record).SteamClient = { Apps: apps }; try { - const installed = await installWrapperIntegration(44, true, wrapper, false, { kind: "host" }); - assert.equal(installed.originalExecutable, undefined); - assert.equal(installed.snapshot.target, "env"); - assert.equal(installed.snapshot.options, 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"'); - assert.deepEqual(targetWrites, []); - assert.deepEqual(shortcutWrites, [installed.snapshot.options]); - - const secondInstall = await installWrapperIntegration(44, true, wrapper, false, { kind: "host" }); - assert.equal(secondInstall.snapshot.options, installed.snapshot.options); - assert.deepEqual(shortcutWrites, [installed.snapshot.options]); - - const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, { kind: "host" }); - assert.equal(restored.target, "env"); - assert.equal(restored.options, originalOptions); - assert.deepEqual(targetWrites, []); + for (const [index, item] of cases.entries()) { + let shortcutTarget = item.target; + let shortcutOptions = item.options; + const targetWrites: string[] = []; + const shortcutWrites: string[] = []; + (globalThis as Record).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions }); + return { unregister() {} }; + }, + SetShortcutLaunchOptions(_appId: number, options: string) { + shortcutWrites.push(options); + shortcutOptions = options; + }, + SetShortcutExe(_appId: number, executable: string) { + targetWrites.push(executable); + shortcutTarget = executable; + }, + }, + }; + const installed = await installWrapperIntegration(100 + index, true, wrapper, false, false); + assert.equal(installed.snapshot.target, item.target); + assert.equal(installed.snapshot.options, item.expected); + assert.deepEqual(targetWrites, []); + assert.deepEqual(shortcutWrites, [item.expected]); + const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded, false); + assert.equal(restored.target, item.target); + assert.equal(restored.options, item.options); + assert.deepEqual(targetWrites, []); + } } finally { if (previousWindow === undefined) delete (globalThis as Record).window; else (globalThis as Record).window = previousWindow; @@ -191,65 +195,32 @@ test("uses shortcut launch options for a host shortcut without changing its Targ } }); -test("fails closed when shortcut Target ownership or setters are unavailable", async () => { +test("direct Flatpak fails closed and rolls Target writes back", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; + let shortcutTarget = "/usr/bin/flatpak"; + const targetWrites: string[] = []; (globalThis as Record).window = { setTimeout, clearTimeout }; (globalThis as Record).SteamClient = { Apps: { RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: _appId === 99 ? "/usr/bin/flatpak" : "garbage", strShortcutLaunchOptions: "" }); + callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" }); return { unregister() {} }; }, + SetShortcutExe(_appId: number, executable: string) { + targetWrites.push(executable); + shortcutTarget = executable; + if (executable.startsWith(wrapper)) throw new Error("simulated Target write failure"); + }, }, }; try { - await assert.rejects(installWrapperIntegration(98, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /supported executable/); - await assert.rejects(installWrapperIntegration(99, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target API is unavailable/); - await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target changed externally/); - } finally { - if (previousWindow === undefined) delete (globalThis as Record).window; - else (globalThis as Record).window = previousWindow; - if (previousSteamClient === undefined) delete (globalThis as Record).SteamClient; - else (globalThis as Record).SteamClient = previousSteamClient; - } -}); - -test("restores launch options and shortcut Target when a setter fails after changing them", async () => { - const previousWindow = (globalThis as Record).window; - const previousSteamClient = (globalThis as Record).SteamClient; - let appOptions = "FOO=bar %command%"; - let shortcutTarget = "/usr/bin/flatpak"; - const appWrites: string[] = []; - const targetWrites: string[] = []; - const apps = { - RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { - callback(appId === 42 - ? { strLaunchOptions: appOptions } - : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" }); - return { unregister() {} }; - }, - SetAppLaunchOptions(_appId: number, options: string) { - appWrites.push(options); - appOptions = options; - if (options.includes(wrapper)) throw new Error("simulated launch-option write failure"); - }, - SetShortcutExe(_appId: number, executable: string) { - targetWrites.push(executable); - shortcutTarget = executable; - if (executable === wrapper) throw new Error("simulated Target write failure"); - }, - }; - (globalThis as Record).window = { setTimeout, clearTimeout }; - (globalThis as Record).SteamClient = { Apps: apps }; - try { - await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch-option write failure/); - assert.equal(appOptions, "FOO=bar %command%"); - assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); - - await assert.rejects(installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /simulated Target write failure/); + await assert.rejects(installWrapperIntegration(43, true, wrapper, false, true), /simulated Target write failure/); assert.equal(shortcutTarget, "/usr/bin/flatpak"); - assert.deepEqual(targetWrites, [wrapper, "/usr/bin/flatpak"]); + assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"', "/usr/bin/flatpak"]); + + shortcutTarget = "garbage"; + await assert.rejects(installWrapperIntegration(43, true, wrapper, false, true), /supported direct Flatpak/); } finally { if (previousWindow === undefined) delete (globalThis as Record).window; else (globalThis as Record).window = previousWindow; -- cgit v1.2.3 From 91365a978911e17097f7363216271b98354399ec Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:35 -0400 Subject: refactor: remove flatpak transport labels --- src/components/GameConfigurationSelector.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 92eacba..1c95820 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -28,16 +28,14 @@ function usePersistentCollapsed(key: string) { useEffect(() => { try { localStorage.setItem(key, String(collapsed)); - } catch { - // Persisting the view preference is optional. - } + } catch {} }, [collapsed, key]); return [collapsed, () => setCollapsed((value) => !value)] as const; } function targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; + if (game.directFlatpak) return "Non-Steam · Direct Flatpak"; return game.nonSteam ? "Non-Steam" : "Steam"; } @@ -137,7 +135,7 @@ export function GameConfigurationSelector({ showModal( void onEnableAll()} -- 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(-) 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 f6f77d775977ad17cdc73338d0f90d05a13cbfc2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:53:02 -0400 Subject: fix: normalize released flatpak target paths --- src/utils/steamLaunchOptions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 08ae136..685dcf9 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -162,7 +162,9 @@ const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value function flatpakExecutable(value: string): string | undefined { const decoded = decodeToken(value.trim()); - return decoded === "flatpak" || decoded === "/usr/bin/flatpak" ? "/usr/bin/flatpak" : undefined; + return decoded === "flatpak" || decoded === "/usr/bin/flatpak" || decoded === "usr/bin/flatpak" + ? "/usr/bin/flatpak" + : undefined; } function wrappedFlatpakExecutable(target: string, wrapperPath: string, includeLegacy = true): string | undefined { -- cgit v1.2.3 From 9e5afad9226475bffe6f2cceb9e5405d16d17a65 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:54:01 -0400 Subject: test: retain released flatpak target recognition --- tests/test_steam_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 719249d..3f0283b 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -20,6 +20,8 @@ class SteamShortcutTests(unittest.TestCase): self.assertTrue(is_direct_flatpak_shortcut("flatpak")) self.assertTrue(is_direct_flatpak_shortcut("/usr/bin/flatpak run com.example.Game")) self.assertTrue(is_direct_flatpak_shortcut('~/.lsfg "/usr/bin/flatpak"')) + self.assertTrue(is_direct_flatpak_shortcut('~/lsfg "usr/bin/flatpak"')) + self.assertTrue(is_direct_flatpak_shortcut('~/.local/bin/mako-run "/usr/bin/flatpak"')) self.assertFalse(is_direct_flatpak_shortcut("/usr/bin/bash")) self.assertFalse(is_direct_flatpak_shortcut("/home/deck/Emulation/tools/launchers/retroarch.sh")) self.assertFalse(is_direct_flatpak_shortcut("/home/deck/Emulation/tools/launchers/ppsspp.sh")) -- cgit v1.2.3 From 8a74b326b65d56808cf3ddb464e55ab0d1a45074 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:03 -0400 Subject: test: cover flatpak cleanup on plugin uninstall --- tests/test_plugin_migration.py | 49 ++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py index a392f85..ab036b0 100644 --- a/tests/test_plugin_migration.py +++ b/tests/test_plugin_migration.py @@ -6,7 +6,7 @@ from unittest.mock import Mock class PluginMigrationTests(unittest.TestCase): - def test_migration_only_runs_decky_path_migrations(self): + def _load_plugin(self): decky = types.SimpleNamespace( DECKY_HOME="/decky", DECKY_USER_HOME="/home/deck", @@ -19,10 +19,24 @@ class PluginMigrationTests(unittest.TestCase): previous_tomllib = sys.modules.get("tomllib") sys.modules["decky"] = decky sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock()) - try: - sys.path.insert(0, "py_modules") - from lsfg_vk.plugin import Plugin + sys.path.insert(0, "py_modules") + from lsfg_vk.plugin import Plugin + return Plugin, decky, previous_decky, previous_tomllib + def _restore(self, previous_decky, previous_tomllib): + sys.path.remove("py_modules") + if previous_decky is None: + sys.modules.pop("decky", None) + else: + sys.modules["decky"] = previous_decky + if previous_tomllib is None: + sys.modules.pop("tomllib", None) + else: + sys.modules["tomllib"] = previous_tomllib + + def test_migration_only_runs_decky_path_migrations(self): + Plugin, decky, previous_decky, previous_tomllib = self._load_plugin() + try: plugin = Plugin.__new__(Plugin) plugin.installation_service = Mock() plugin.flatpak_service = Mock() @@ -33,17 +47,24 @@ class PluginMigrationTests(unittest.TestCase): decky.migrate_settings.assert_called_once() decky.migrate_runtime.assert_called_once() plugin.installation_service.install.assert_not_called() - plugin.flatpak_service.migrate_v2.assert_not_called() + plugin.flatpak_service.prepare_app.assert_not_called() + finally: + self._restore(previous_decky, previous_tomllib) + + def test_uninstall_cleans_owned_flatpak_state(self): + Plugin, _decky, previous_decky, previous_tomllib = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} + + asyncio.run(plugin._uninstall()) + + plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() + plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() finally: - sys.path.remove("py_modules") - if previous_decky is None: - sys.modules.pop("decky", None) - else: - sys.modules["decky"] = previous_decky - if previous_tomllib is None: - sys.modules.pop("tomllib", None) - else: - sys.modules["tomllib"] = previous_tomllib + self._restore(previous_decky, previous_tomllib) if __name__ == "__main__": -- 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(-) 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 e00f9ee6268fab5a1fdf46bca435a3f03e07ff53 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:56:06 -0400 Subject: refactor: trim flatpak api surface --- src/api/lsfgApi.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index aeaa502..e5ce2bf 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -102,21 +102,6 @@ export interface DebugFileContentsResult extends ApiResult { files?: DebugFileContent[]; } -export interface FlatpakExtensionStatus extends ApiResult { - message: string; - available: boolean; - extension_id: string; - supported_branches: string[]; - installed_branches: string[]; -} - -export interface FlatpakExtensionToggleResult extends ApiResult { - message: string; - runtime_branch: string; - enabled: boolean; - installed: boolean; -} - export interface FlatpakApp { app_id: string; app_name: string; @@ -145,11 +130,9 @@ export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); -export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps"); export const prepareFlatpakApp = callable<[string], FlatpakAppResult>("prepare_flatpak_app"); export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app"); -export const setFlatpakExtensionEnabled = callable<[string, boolean], FlatpakExtensionToggleResult>("set_flatpak_extension_enabled"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); -- cgit v1.2.3 From f42e96bef11f7e6adec4d4272cce6af65f7f2895 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:56:15 -0400 Subject: chore: remove obsolete flatpak target image --- assets/flatpak-target.png | Bin 78195 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 assets/flatpak-target.png diff --git a/assets/flatpak-target.png b/assets/flatpak-target.png deleted file mode 100644 index 773e567..0000000 Binary files a/assets/flatpak-target.png and /dev/null differ -- cgit v1.2.3 From 3681bbb57c8f66bb789b493fe8bcd920245136a3 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:57:46 -0400 Subject: test: isolate app override restoration from runtime ownership --- tests/test_flatpak_service.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 208e05b..065871b 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -213,6 +213,7 @@ class FlatpakServiceTests(unittest.TestCase): self.assertFalse(self.service.ownership_path.exists()) def test_remove_restores_exact_previous_override(self): + self.system_branches = {"24.08"} original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n" path = self.service._override_path("com.example.Game") path.parent.mkdir(parents=True, exist_ok=True) @@ -226,6 +227,7 @@ class FlatpakServiceTests(unittest.TestCase): self.assertFalse(self.service.ownership_path.exists()) def test_remove_deletes_override_created_by_plugin(self): + self.system_branches = {"24.08"} self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) path = self.service._override_path("com.example.Game") self.assertTrue(path.exists()) @@ -234,8 +236,10 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(response["success"]) self.assertFalse(path.exists()) + self.assertFalse(self.service.ownership_path.exists()) def test_remove_fails_closed_after_external_change(self): + self.system_branches = {"24.08"} self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) path = self.service._override_path("com.example.Game") with path.open("a", encoding="utf-8") as handle: -- 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(-) 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(-) 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 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(+) 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(-) 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 33ea22e3e45410c0e099cb8d495c4f2bae05cf34 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:30:52 -0400 Subject: feat: add flatpak profile client APIs --- src/api/lsfgApi.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index e5ce2bf..850efa9 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -63,6 +63,7 @@ export interface WorkaroundState { export interface WorkaroundStateResult extends ApiResult { appid?: string; + app_id?: string; state?: WorkaroundState | null; wrapper_path?: string; wrapper_owned?: boolean; @@ -110,6 +111,12 @@ export interface FlatpakApp { runtime_ready: boolean; prepared: boolean; owned: boolean; + enabled: boolean; + profile: string; + config?: LsfgConfig | null; + workarounds: WorkaroundState; + active?: boolean; + pid?: string; error?: string | null; } @@ -117,12 +124,8 @@ export interface FlatpakAppsResult extends ApiResult { apps?: FlatpakApp[]; } -export interface FlatpakAppResult extends ApiResult { +export interface FlatpakAppResult extends ApiResult, Partial { app_id: string; - runtime?: string | null; - runtime_branch?: string | null; - prepared: boolean; - owned: boolean; } export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); @@ -131,8 +134,12 @@ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps"); -export const prepareFlatpakApp = callable<[string], FlatpakAppResult>("prepare_flatpak_app"); +export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app"); +export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config"); +export const getFlatpakWorkaroundState = callable<[string], WorkaroundStateResult>("get_flatpak_workaround_state"); +export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state"); export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app"); +export const getRunningFlatpakApps = callable<[], FlatpakAppsResult>("get_running_flatpak_apps"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); -- cgit v1.2.3 From aac2d9d360592ceaf732419e9fddc758cfd2cb42 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:31:13 -0400 Subject: feat: add flatpak profile state hook --- src/hooks/useFlatpakConfiguration.ts | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/hooks/useFlatpakConfiguration.ts diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts new file mode 100644 index 0000000..e8fae99 --- /dev/null +++ b/src/hooks/useFlatpakConfiguration.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + enableFlatpakApp, + getFlatpakApps, + getRunningFlatpakApps, + removeFlatpakApp, + setFlatpakWorkaroundState, + updateFlatpakConfig, + type FlatpakApp, + type LsfgConfig, + type WorkaroundState, +} from "../api/lsfgApi"; +import { showErrorToast } from "../utils/toastUtils"; + +export function useFlatpakConfiguration(enabled: boolean) { + const [apps, setApps] = useState([]); + const [runningApps, setRunningApps] = useState([]); + const [loading, setLoading] = useState(false); + const [busyAppId, setBusyAppId] = useState(""); + + const reload = useCallback(async () => { + if (!enabled) { + setApps([]); + return; + } + setLoading(true); + try { + const result = await getFlatpakApps(); + if (!result.success) throw new Error(result.error || "Could not list Flatpak applications"); + setApps(result.apps || []); + } catch (error) { + showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + } + }, [enabled]); + + const pollRunning = useCallback(async () => { + if (!enabled) { + setRunningApps([]); + return; + } + try { + const result = await getRunningFlatpakApps(); + if (result.success) setRunningApps(result.apps || []); + } catch {} + }, [enabled]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + void pollRunning(); + if (!enabled) return; + const interval = window.setInterval(() => void pollRunning(), 2000); + return () => window.clearInterval(interval); + }, [enabled, pollRunning]); + + const operate = useCallback(async (appId: string, operation: () => Promise<{ success: boolean; error?: string | null }>) => { + if (busyAppId) return false; + setBusyAppId(appId); + try { + const result = await operation(); + if (!result.success) throw new Error(result.error || "Flatpak operation failed"); + await reload(); + await pollRunning(); + return true; + } catch (error) { + showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error)); + return false; + } finally { + setBusyAppId(""); + } + }, [busyAppId, pollRunning, reload]); + + const enableApp = useCallback((appId: string) => operate(appId, () => enableFlatpakApp(appId)), [operate]); + const removeApp = useCallback((appId: string) => operate(appId, () => removeFlatpakApp(appId)), [operate]); + const updateConfig = useCallback( + (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)), + [operate], + ); + const updateWorkarounds = useCallback( + (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)), + [operate], + ); + + const runningApp = useMemo(() => { + if (runningApps.length === 0) return null; + return runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null); + }, [runningApps]); + + return { + apps, + runningApps, + runningApp, + loading, + busyAppId, + reload, + enableApp, + removeApp, + updateConfig, + updateWorkarounds, + }; +} -- cgit v1.2.3 From 4823f1a17d647d4e6e387107a2260a8ebd3015de Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:31:35 -0400 Subject: feat: add flatpak workaround controls --- src/components/FlatpakWorkaroundsSection.tsx | 144 +++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/components/FlatpakWorkaroundsSection.tsx diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx new file mode 100644 index 0000000..7d9d880 --- /dev/null +++ b/src/components/FlatpakWorkaroundsSection.tsx @@ -0,0 +1,144 @@ +import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import type { WorkaroundState } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface Props { + state: WorkaroundState; + disabled?: boolean; + onChange: (state: WorkaroundState) => Promise; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1"; + +export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false"; + } catch { + return true; + } + }); + const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate); + const timer = useRef(null); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed)); + } catch {} + }, [collapsed]); + + useEffect(() => { + setFpsValue(state.dxvkFrameRate); + }, [state.dxvkFrameRate]); + + useEffect(() => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, []); + + const update = (field: keyof WorkaroundState, value: boolean | number) => { + void onChange({ ...state, [field]: value }); + }; + + const updateFps = (value: number) => { + setFpsValue(value); + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { + timer.current = null; + void onChange({ ...state, dxvkFrameRate: value }); + }, 250); + }; + + const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + return ( + <> + +
+ {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} +
+
+ + setCollapsed((value) => !value)} + > + {collapsed ? : } + + + {!collapsed && ( + <> + + + + + update("disableSteamdeckMode", value)} + disabled={disabled} + /> + + + update("disableGamescopeWsi", value)} + disabled={disabled} + /> + + + update("disableHdr", value)} + disabled={disabled} + /> + + + update("disableVkbasalt", value)} + disabled={disabled} + /> + + + update("enableZink", value)} + disabled={disabled} + /> + + + )} + + ); +} -- cgit v1.2.3 From 6671a5c51fef98c07369f5e5be5ce498a317e868 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:31:58 -0400 Subject: feat: add flatpak profiles tab --- src/components/FlatpakTab.tsx | 178 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/components/FlatpakTab.tsx diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx new file mode 100644 index 0000000..6392d59 --- /dev/null +++ b/src/components/FlatpakTab.tsx @@ -0,0 +1,178 @@ +import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; +import { useCallback, useMemo, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { ProfileDetails } from "./ProfileDetails"; + +interface Props { + apps: FlatpakApp[]; + runningApp: FlatpakApp | null; + loading: boolean; + busyAppId: string; + onRefresh: () => Promise; + onEnable: (appId: string) => Promise; + onRemove: (appId: string) => Promise; + onConfigChange: (appId: string, config: LsfgConfig) => Promise; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise; +} + +export function FlatpakTab({ + apps, + runningApp, + loading, + busyAppId, + onRefresh, + onEnable, + onRemove, + onConfigChange, + onWorkaroundChange, +}: Props) { + const [selectedAppId, setSelectedAppId] = useState(null); + const selected = useMemo( + () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null, + [apps, selectedAppId], + ); + const close = useCallback(() => setSelectedAppId(null), []); + + if (!selectedAppId) { + return ( + + + + + {apps.map((app) => { + const status = app.enabled + ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled" + : app.prepared && !app.owned ? "Prepared externally" : "Available"; + return ( + + setSelectedAppId(app.app_id)} + highlightOnFocus + /> + + ); + })} + {apps.length === 0 && !loading && ( + + + + )} + + void onRefresh()}> + {loading ? "Refreshing..." : "Refresh Flatpaks"} + + + + ); + } + + if (!selected) { + return ( + + + Back + + + + + + ); + } + + const busy = busyAppId === selected.app_id; + const config = selected.config; + const external = selected.prepared && !selected.owned; + const profileDescription = [ + selected.app_id, + selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null, + selected.enabled ? `profile ${selected.profile}` : null, + selected.app_id === runningApp?.app_id ? "Running" : null, + ].filter(Boolean).join(" · "); + + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + if (!config) return; + await onConfigChange(selected.app_id, { ...config, [field]: value }); + }; + + return ( + + + +
+ + + + + +
+ {selected.app_name} +
+
+
+
+ {!selected.enabled && ( + + + void onEnable(selected.app_id)} + > + {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"} + + + {selected.error && ( + + + + )} + + )} + {selected.enabled && config && ( + <> + + + onWorkaroundChange(selected.app_id, state)} + /> + + void onRemove(selected.app_id)}> + {busy ? "Removing..." : "Remove Flatpak profile"} + + + + )} + +
+ ); +} -- cgit v1.2.3 From 8090b237a34140343b13efc845f8123730989cbf Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:32:15 -0400 Subject: feat: show running flatpak profiles --- src/components/FlatpakNowPlayingTab.tsx | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/components/FlatpakNowPlayingTab.tsx diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx new file mode 100644 index 0000000..9e5a0db --- /dev/null +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -0,0 +1,39 @@ +import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; + +interface Props { + app: FlatpakApp; + busy: boolean; + onConfigChange: (appId: string, config: LsfgConfig) => Promise; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise; +} + +export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) { + if (!app.config) return null; + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + await onConfigChange(app.app_id, { ...app.config!, [field]: value }); + }; + + return ( + + + + + + + + + onWorkaroundChange(app.app_id, state)} + /> + + ); +} -- cgit v1.2.3 From 0e51abafa2fed9fc829dceb48728d0e8bbf0dcaf Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:32:25 -0400 Subject: refactor: move flatpak setup into profiles tab --- src/components/SetupTab.tsx | 52 +++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 1a62c2a..d769200 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,7 +1,6 @@ import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; import { type SteamBranchStatus } from "../api/lsfgApi"; import t from "../i18n/i18n"; -import { FlatpakSetupSection } from "./FlatpakSetupSection"; interface SetupTabProps { isInstalled: boolean; @@ -37,36 +36,33 @@ export function SetupTab(props: SetupTabProps) { : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); return ( - <> - + + + + + + + + {steamBranchStatus?.installed && ( - - - - {steamBranchStatus?.installed && ( - - - - )} - - - {buttonLabel} - - - - - + )} + + + {buttonLabel} + + + ); } -- cgit v1.2.3 From 64e3a2db3a538b4b538e6b0520943b5a90eff5a5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:32:58 -0400 Subject: feat: add flatpak profiles tab and now playing --- src/components/Content.tsx | 84 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 8f83e85..ffb6eef 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,18 +1,22 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; -import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; +import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallation } from "../hooks/useLsfgHooks"; import { tabStyles } from "../styles"; import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; +import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; +import { FlatpakTab } from "./FlatpakTab"; import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; const tabIcons = { nowPlaying: , games: , + flatpak: , configFile: , setup: , }; @@ -63,38 +67,46 @@ export function Content() { install, uninstall, } = useInstallation(reload); - const [tab, setTab] = useState("Setup"); - const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); - const previousRunningAppId = useRef(null); const setupComplete = isInstalled && losslessScalingInstalled && steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; + const flatpak = useFlatpakConfiguration(setupComplete); + const [tab, setTab] = useState("Setup"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); + const previousRunningWorkload = useRef(null); + const runningFlatpak = flatpak.runningApp; + const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak); + const runningWorkload = runningGame?.configured + ? `steam:${runningGame.appid}` + : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null; useEffect(() => { if (!setupComplete) { setTab("Setup"); return; } - setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Games") : current); - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current); + }, [hasNowPlaying, setupComplete]); useEffect(() => { if (!setupComplete) return; - const appid = runningGame?.appid || null; - const previous = previousRunningAppId.current; - previousRunningAppId.current = appid; - if (appid && appid !== previous) setTab(runningGame?.configured ? "NowPlaying" : "Games"); - else if (!appid && previous) { + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying"); + else if (!runningWorkload && previous) { setTab((current) => current === "NowPlaying" ? "Games" : current); } - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + }, [runningWorkload, setupComplete]); useEffect(() => { - if (isInstalled) void reload(); - }, [isInstalled, reload]); + if (isInstalled) { + void reload(); + void flatpak.reload(); + } + }, [isInstalled, reload, flatpak.reload]); useEffect(() => { if (!showDebugTab && tab === "ConfigFile") setTab("Games"); @@ -120,19 +132,24 @@ export function Content() { /> ); + const nowPlaying = runningGame?.configured ? ( + handleConfigChange(field, value)} + /> + ) : runningFlatpak ? ( + + ) : null; + const tabs = setupComplete ? [ - ...(runningGame?.configured ? [{ - id: "NowPlaying", - title: tabIcons.nowPlaying, - content: ( - handleConfigChange(field, value)} - /> - ), - }] : []), + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []), { id: "Games", title: tabIcons.games, @@ -153,6 +170,23 @@ export function Content() { /> ), }, + { + id: "Flatpak", + title: tabIcons.flatpak, + content: ( + + ), + }, ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: }] : []), { id: "Setup", title: tabIcons.setup, content: setup }, ] -- 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(-) 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 a6c8f95af3a28a7384d1fd4155bbd1e15cf36334 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:34:37 -0400 Subject: refactor: remove flatpak target integration --- src/utils/steamLaunchOptions.ts | 104 ++++------------------------------------ 1 file changed, 8 insertions(+), 96 deletions(-) diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 685dcf9..f03eeab 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -24,7 +24,6 @@ export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; - target: string; details: SteamAppDetails; } export interface WrapperIntegrationResult { @@ -58,7 +57,6 @@ function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): S appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", - target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -160,31 +158,6 @@ const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(tok const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -function flatpakExecutable(value: string): string | undefined { - const decoded = decodeToken(value.trim()); - return decoded === "flatpak" || decoded === "/usr/bin/flatpak" || decoded === "usr/bin/flatpak" - ? "/usr/bin/flatpak" - : undefined; -} - -function wrappedFlatpakExecutable(target: string, wrapperPath: string, includeLegacy = true): string | undefined { - const tokens = tokenize(target); - if (tokens.length !== 2) return undefined; - const wrapper = tokens[0].value; - if (wrapper !== wrapperPath && !(includeLegacy && isLegacyToken(wrapper))) return undefined; - return flatpakExecutable(tokens[1].value); -} - -function directFlatpakExecutable(target: string, wrapperPath: string): string | undefined { - const tokens = tokenize(target); - if (tokens.length === 1) return flatpakExecutable(tokens[0].value); - return wrappedFlatpakExecutable(target, wrapperPath); -} - -function managedFlatpakTarget(wrapperPath: string, executable: string): string { - return `${wrapperPath} "${executable.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); @@ -280,14 +253,9 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU export function isWrapperIntegrationInstalled( steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - directFlatpak = false, + _nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, ): boolean { - if (nonSteam && directFlatpak) { - const tokens = tokenize(steam.target); - return tokens.length === 2 && tokens[0].value === wrapperPath && flatpakExecutable(tokens[1].value) !== undefined; - } return hasWrapperLaunchIntegration(steam.options, wrapperPath); } @@ -328,18 +296,16 @@ async function writeVerified( previous: string, next: string, write: (value: string) => Promise, - read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise { - const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => normalizeLaunchOptions(value); try { await write(next); - return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); + return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message); } catch (error) { const failure = asError(error); try { await write(previous); - await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options"); } catch (rollback) { throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); } @@ -347,19 +313,11 @@ async function writeVerified( } } -const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; -const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; - function writeOptions(appId: number, nonSteam: boolean, value: string): Promise { const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions; if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`)); return Promise.resolve(setter.call(apps(), appId, value)); } -function writeTarget(appId: number, value: string): Promise { - const setter = apps()?.SetShortcutExe; - if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); - return Promise.resolve(setter.call(apps(), appId, value)); -} export function updateSteamLaunchOptions( appId: number, @@ -371,7 +329,7 @@ export function updateSteamLaunchOptions( const next = transform(current.options); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (value) => writeOptions(appId, nonSteam, value), readOptions, + (value) => writeOptions(appId, nonSteam, value), "Steam did not accept the launch options", ); }); @@ -382,41 +340,16 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && directFlatpak) { - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - const launchOptionsChanged = cleaned !== current.options; - if (launchOptionsChanged) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not accept shortcut launch options", - ); - } - const executable = directFlatpakExecutable(current.target, wrapperPath); - if (!executable) throw new Error("Flatpak shortcut Target is not a supported direct Flatpak executable"); - const target = managedFlatpakTarget(wrapperPath, executable); - if (normalizeLaunchOptions(current.target) === normalizeLaunchOptions(target)) { - return { snapshot: current, commandTokenAdded: false, changed: launchOptionsChanged }; - } - const value = await writeVerified( - appId, true, current.target, target, - (next) => writeTarget(appId, next), readTarget, - "Steam did not accept the shortcut Target", - ); - return { snapshot: value, commandTokenAdded: false, changed: true }; - } - + const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false }; const value = await writeVerified( appId, nonSteam, current.options, rewrite.options, - (options) => writeOptions(appId, nonSteam, options), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true }; @@ -428,34 +361,13 @@ export function removeWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - directFlatpak = false, ): Promise { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && directFlatpak) { - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not clean shortcut launch options", - ); - } - const wrapped = wrappedFlatpakExecutable(current.target, wrapperPath); - if (wrapped) { - return writeVerified( - appId, true, current.target, wrapped, - (target) => writeTarget(appId, target), readTarget, - "Steam did not restore the shortcut Target", - ); - } - if (flatpakExecutable(current.target)) return current; - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } + const current = await readSteamLaunchOptions(appId, nonSteam); const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (options) => writeOptions(appId, nonSteam, options), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not clean the launch options", ); }); -- cgit v1.2.3 From 4dc1a3b7cc6b02ed06a8349d85b0eae36af10b0b Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:35:05 -0400 Subject: refactor: remove flatpak mode from steam workarounds --- src/hooks/usePerAppWorkarounds.ts | 35 +++++++---------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index 30bbc6b..c2b8904 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -43,7 +43,6 @@ export interface WorkaroundSnapshot { wrapperOwned: boolean; integrationInstalled: boolean; commandTokenAdded: boolean; - directFlatpak: boolean; } interface PerAppWorkarounds { @@ -62,7 +61,6 @@ function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited>, nonSteam: boolean, - directFlatpak: boolean, ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); @@ -71,34 +69,26 @@ function makeSnapshot( state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, directFlatpak, wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath), commandTokenAdded: result.command_token_added === true, - directFlatpak, }; } async function adoptWorkaroundState( appId: string, nonSteam: boolean, - directFlatpak: boolean, wrapperPath: string, ): Promise { let integration: Awaited> | null = null; try { - integration = await installWrapperIntegration( - Number(appId), - nonSteam, - wrapperPath, - false, - directFlatpak, - ); + integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, integration.commandTokenAdded, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); - return makeSnapshot(integration.snapshot, finalized, nonSteam, directFlatpak); + return makeSnapshot(integration.snapshot, finalized, nonSteam); } catch (error) { let rollbackSucceeded = true; if (integration?.changed) { @@ -108,7 +98,6 @@ async function adoptWorkaroundState( nonSteam, wrapperPath, integration.commandTokenAdded, - directFlatpak, ); } catch { rollbackSucceeded = false; @@ -122,11 +111,7 @@ async function adoptWorkaroundState( } } -export function usePerAppWorkarounds( - appId: string, - nonSteam: boolean, - directFlatpak = false, -): PerAppWorkarounds { +export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { const [status, setStatus] = useState("loading"); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); @@ -143,12 +128,11 @@ export function usePerAppWorkarounds( return adoptWorkaroundState( appId, nonSteam, - directFlatpak, result.wrapper_path || getDefaultWrapperPath(), ); } - return makeSnapshot(steam, result, nonSteam, directFlatpak); - }, [appId, directFlatpak, nonSteam, numericAppId]); + return makeSnapshot(steam, result, nonSteam); + }, [appId, nonSteam, numericAppId]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { setSnapshot(next); @@ -183,12 +167,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: isWrapperIntegrationInstalled( - steam, - nonSteam, - current.directFlatpak, - current.wrapperPath, - ), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath), } : current); }, (subscriptionError) => { -- cgit v1.2.3 From 1de34909f7caa0afc9c5b2745112ce29173eda69 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:35:25 -0400 Subject: refactor: keep steam workarounds transport agnostic --- src/components/WorkaroundsSection.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index 050992c..3de5ec1 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -8,7 +8,6 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; - directFlatpak?: boolean; onRepair?: () => Promise; } @@ -78,9 +77,9 @@ function usePersistentCollapsed() { return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam, directFlatpak = false, onRepair }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); - const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, directFlatpak); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); const [repairing, setRepairing] = useState(false); const state = snapshot?.state; const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; -- cgit v1.2.3 From f4b67511ddbe32948927ce73b5f3e067755a904d Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:35:33 -0400 Subject: refactor: simplify steam workaround target --- src/components/GameConfigurationControls.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index a7e3fec..7025f78 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -10,7 +10,7 @@ interface Props { autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; - workaroundTarget?: Pick; + workaroundTarget?: Pick; onRepairWorkaround?: () => Promise; } @@ -36,7 +36,6 @@ export function GameConfigurationControls({ )} -- cgit v1.2.3 From e6d6ec2a0944be5f3d348e06c5031c94159910df Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:36:02 -0400 Subject: refactor: keep steam profiles flatpak agnostic --- src/hooks/useGameConfiguration.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 4131d0f..8d56a62 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -23,7 +23,6 @@ async function getSteamShortcuts(): Promise { appid: String(appid >>> 0), name, nonSteam: true, - directFlatpak: false, }]; }); } catch { @@ -91,7 +90,7 @@ export function useGameConfiguration() { const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); setRunningGame((current) => current?.appid === appid ? current : { - ...(installed || { appid, name, nonSteam: false, directFlatpak: false }), + ...(installed || { appid, name, nonSteam: false }), name, configured: games.some((game) => game.appid === appid), }); @@ -111,7 +110,7 @@ export function useGameConfiguration() { const targets = useMemo(() => { const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, directFlatpak: false, configured: true }); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true }); if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); return configured; }, [games, installedGames, runningGame]); @@ -137,7 +136,6 @@ export function useGameConfiguration() { target.nonSteam, wrapperPath, commandTokenAdded, - target.directFlatpak === true, ); stateWriteAttempted = true; const saved = await setWorkaroundState( @@ -156,7 +154,6 @@ export function useGameConfiguration() { target.nonSteam, wrapperPath, integration.commandTokenAdded, - target.directFlatpak === true, ); } catch (rollbackError) { showErrorToast("Workaround rollback failed", asError(rollbackError).message); @@ -187,7 +184,6 @@ export function useGameConfiguration() { target.nonSteam, wrapperPath, existing.command_token_added === true, - target.directFlatpak === true, ); const removed = await removeWorkaroundState(target.appid); if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); -- cgit v1.2.3 From 1949dd018bd7492c77736e19f9a25b14367187ff Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:36:31 -0400 Subject: refactor: separate flatpak from games selector --- src/components/GameConfigurationSelector.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 1c95820..1f0bc73 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -35,7 +35,6 @@ function usePersistentCollapsed(key: string) { } function targetDescription(game: GameTarget): string { - if (game.directFlatpak) return "Non-Steam · Direct Flatpak"; return game.nonSteam ? "Non-Steam" : "Steam"; } @@ -135,7 +134,7 @@ export function GameConfigurationSelector({ showModal( void onEnableAll()} -- cgit v1.2.3 From 59b9148054e2f0c7dcd33b0e5393899d7a92f93e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:36:50 -0400 Subject: refactor: keep game profiles steam scoped --- src/components/ConfigurationTab.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 18575b4..a6d4c2a 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -99,11 +99,7 @@ export function ConfigurationTab({ } const profileLabel = selectedTarget?.name || "Game profile"; - const profileTransport = selectedTarget - ? selectedTarget.directFlatpak - ? "Non-Steam · Direct Flatpak" - : selectedTarget.nonSteam ? "Non-Steam" : "Steam" - : "Game"; + const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam"; const profileDescription = selectedTarget ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` : "Game is no longer available"; -- cgit v1.2.3 From eb714e77e494e3af8163ffceac7cb6e6d788440c Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:38:30 -0400 Subject: refactor: remove flatpak transport from steam now playing --- src/components/NowPlayingTab.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 1bc331f..c067dc0 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -13,7 +13,6 @@ interface Props { } function targetDescription(game: GameTarget): string { - if (game.directFlatpak) return "Non-Steam · Direct Flatpak"; return game.nonSteam ? "Non-Steam" : "Steam"; } -- cgit v1.2.3 From a69ab9976905640f816b3a84d13fbc5848ffae65 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:38:38 -0400 Subject: refactor: remove shortcut target api types --- src/types.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/types.d.ts b/src/types.d.ts index e5db40d..ebcf32c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -16,7 +16,6 @@ declare module "*.jpg" { interface SteamAppDetails { strLaunchOptions?: string; strShortcutLaunchOptions?: string; - strShortcutExe?: string; strShortcutStartDir?: string; } @@ -31,7 +30,6 @@ interface SteamApps { ): SteamAppDetailsRegistration; SetAppLaunchOptions(appId: number, options: string): void | Promise; SetShortcutLaunchOptions(appId: number, options: string): void | Promise; - SetShortcutExe(appId: number, executable: string): void | Promise; TerminateApp(appId: string, param1: boolean): void; GetAllShortcuts?(): Promise; } -- cgit v1.2.3 From 2258a5e5e96ad3c3d2e9eb5a3bdb58e747b2d0ad Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:38:57 -0400 Subject: refactor: remove direct flatpak steam metadata --- src/api/lsfgApi.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 850efa9..3643363 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -41,7 +41,6 @@ export interface InstalledGame { appid: string; name: string; nonSteam: boolean; - directFlatpak?: boolean; executable?: string; arguments?: string; startDir?: string; @@ -115,15 +114,23 @@ export interface FlatpakApp { profile: string; config?: LsfgConfig | null; workarounds: WorkaroundState; - active?: boolean; - pid?: string; error?: string | null; } +export interface RunningFlatpakApp { + app_id: string; + active: boolean; + pid?: string; +} + export interface FlatpakAppsResult extends ApiResult { apps?: FlatpakApp[]; } +export interface RunningFlatpakAppsResult extends ApiResult { + apps?: RunningFlatpakApp[]; +} + export interface FlatpakAppResult extends ApiResult, Partial { app_id: string; } @@ -136,10 +143,9 @@ export const getConfigFileContent = callable<[], FileContentResult>("get_config_ export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps"); export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app"); export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config"); -export const getFlatpakWorkaroundState = callable<[string], WorkaroundStateResult>("get_flatpak_workaround_state"); export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state"); export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app"); -export const getRunningFlatpakApps = callable<[], FlatpakAppsResult>("get_running_flatpak_apps"); +export const getRunningFlatpakApps = callable<[], RunningFlatpakAppsResult>("get_running_flatpak_apps"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); -- cgit v1.2.3 From 08404e49b9f21c16f3c69cdcd9eefe0250f594e2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:39:02 -0400 Subject: refactor: remove obsolete flatpak setup section --- src/components/FlatpakSetupSection.tsx | 110 --------------------------------- 1 file changed, 110 deletions(-) delete mode 100644 src/components/FlatpakSetupSection.tsx diff --git a/src/components/FlatpakSetupSection.tsx b/src/components/FlatpakSetupSection.tsx deleted file mode 100644 index 0119250..0000000 --- a/src/components/FlatpakSetupSection.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useCallback, useEffect, useState } from "react"; -import { - getFlatpakApps, - prepareFlatpakApp, - removeFlatpakApp, - type FlatpakApp, -} from "../api/lsfgApi"; -import { showErrorToast } from "../utils/toastUtils"; - -interface Props { - enabled: boolean; -} - -export function FlatpakSetupSection({ enabled }: Props) { - const [apps, setApps] = useState([]); - const [loading, setLoading] = useState(false); - const [busyApp, setBusyApp] = useState(""); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - if (!enabled) { - setApps([]); - setError(null); - return; - } - setLoading(true); - try { - const result = await getFlatpakApps(); - if (!result.success) throw new Error(result.error || "Could not list Flatpak applications"); - setApps(result.apps || []); - setError(null); - } catch (loadError) { - const message = loadError instanceof Error ? loadError.message : String(loadError); - setError(message); - } finally { - setLoading(false); - } - }, [enabled]); - - useEffect(() => { - void load(); - }, [load]); - - const toggle = async (app: FlatpakApp) => { - if (busyApp || (app.prepared && !app.owned)) return; - setBusyApp(app.app_id); - try { - const result = app.prepared - ? await removeFlatpakApp(app.app_id) - : await prepareFlatpakApp(app.app_id); - if (!result.success) throw new Error(result.error || "Flatpak setup failed"); - await load(); - } catch (operationError) { - const message = operationError instanceof Error ? operationError.message : String(operationError); - showErrorToast("Flatpak setup failed", message); - } finally { - setBusyApp(""); - } - }; - - if (!enabled) return null; - - return ( - - - - - {error && ( - - - - )} - {apps.map((app) => { - const busy = busyApp === app.app_id; - const description = [ - app.app_id, - app.runtime_branch ? `runtime ${app.runtime_branch}` : null, - app.error, - ].filter(Boolean).join(" · "); - const label = busy - ? "Working..." - : app.prepared - ? app.owned ? "Remove" : "Prepared externally" - : app.error ? "Unavailable" : "Prepare"; - return ( - - - void toggle(app)} - > - {label} - - - - ); - })} - - void load()}> - {loading ? "Refreshing..." : "Refresh Flatpaks"} - - - - ); -} -- 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(-) 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 5c7c1c3547a9f298301fb2b3a03c0f76f24a2866 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:40:08 -0400 Subject: perf: map running flatpaks from cached app state --- src/hooks/useFlatpakConfiguration.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts index e8fae99..d5e6f7e 100644 --- a/src/hooks/useFlatpakConfiguration.ts +++ b/src/hooks/useFlatpakConfiguration.ts @@ -8,13 +8,14 @@ import { updateFlatpakConfig, type FlatpakApp, type LsfgConfig, + type RunningFlatpakApp, type WorkaroundState, } from "../api/lsfgApi"; import { showErrorToast } from "../utils/toastUtils"; export function useFlatpakConfiguration(enabled: boolean) { const [apps, setApps] = useState([]); - const [runningApps, setRunningApps] = useState([]); + const [runningApps, setRunningApps] = useState([]); const [loading, setLoading] = useState(false); const [busyAppId, setBusyAppId] = useState(""); @@ -87,8 +88,10 @@ export function useFlatpakConfiguration(enabled: boolean) { const runningApp = useMemo(() => { if (runningApps.length === 0) return null; - return runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null); - }, [runningApps]); + const running = runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null); + if (!running) return null; + return apps.find((app) => app.app_id === running.app_id) || null; + }, [apps, runningApps]); return { apps, -- cgit v1.2.3 From af7202363bbe4ec5c3d40197cf88c306fa06ee46 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:40:50 -0400 Subject: test: cover launch-options-only steam integration --- tests/steamLaunchOptions.test.ts | 77 ++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 93d37df..40aeb3c 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -70,21 +70,19 @@ test("removes only managed assignments and preserves unrelated values", () => { ); }); -test("uses launch options for Steam and MAKO-style Target wrapping for direct Flatpak", async () => { +test("uses launch options for Steam and non-Steam shortcuts without a Target API", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; let appOptions = "FOO=bar %command%"; let shortcutOptions = "--windowed"; - let shortcutTarget = '"flatpak"'; const appWrites: string[] = []; const shortcutWrites: string[] = []; - const targetWrites: string[] = []; const unregisters: number[] = []; const apps = { RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { callback(appId === 42 ? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" } - : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); + : { strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); return { unregister: () => unregisters.push(appId) }; }, SetAppLaunchOptions(appId: number, options: string) { @@ -97,11 +95,6 @@ test("uses launch options for Steam and MAKO-style Target wrapping for direct Fl shortcutWrites.push(options); shortcutOptions = options; }, - SetShortcutExe(appId: number, executable: string) { - assert.equal(appId, 43); - targetWrites.push(executable); - shortcutTarget = executable; - }, }; (globalThis as Record).window = { setTimeout, clearTimeout }; (globalThis as Record).SteamClient = { Apps: apps }; @@ -113,19 +106,12 @@ test("uses launch options for Steam and MAKO-style Target wrapping for direct Fl assert.equal(installed.commandTokenAdded, false); assert.equal(appWrites.length, 1); - const shortcut = await installWrapperIntegration(43, true, wrapper, false, true); - assert.equal(shortcut.snapshot.target, '~/.lsfg "/usr/bin/flatpak"'); - assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"']); - assert.equal(shortcut.snapshot.options, "--windowed"); - assert.deepEqual(shortcutWrites, []); + const shortcut = await installWrapperIntegration(43, true, wrapper); + assert.equal(shortcut.snapshot.options, `~/.lsfg %command% --windowed`); + assert.deepEqual(shortcutWrites, [`~/.lsfg %command% --windowed`]); - const second = await installWrapperIntegration(43, true, wrapper, false, true); - assert.equal(second.changed, false); - assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"']); - - const restored = await removeWrapperIntegration(43, true, wrapper, false, true); - assert.equal(restored.target, "/usr/bin/flatpak"); - assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"', "/usr/bin/flatpak"]); + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.commandTokenAdded); + assert.equal(restored.options, "--windowed"); const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded); assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " ")); @@ -139,53 +125,45 @@ test("uses launch options for Steam and MAKO-style Target wrapping for direct Fl } }); -test("AppImage and EmuDeck script shortcuts stay launch-option based", async () => { +test("AppImage EmuDeck and direct Flatpak shortcuts all stay launch-option based", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; const cases = [ { - target: "env", options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"', expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"', }, { - target: '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', options: "", expected: "~/.lsfg %command%", }, + { + options: "run org.example.Game", + expected: "~/.lsfg %command% run org.example.Game", + }, ]; (globalThis as Record).window = { setTimeout, clearTimeout }; try { for (const [index, item] of cases.entries()) { - let shortcutTarget = item.target; let shortcutOptions = item.options; - const targetWrites: string[] = []; const shortcutWrites: string[] = []; (globalThis as Record).SteamClient = { Apps: { RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions }); + callback({ strShortcutLaunchOptions: shortcutOptions }); return { unregister() {} }; }, SetShortcutLaunchOptions(_appId: number, options: string) { shortcutWrites.push(options); shortcutOptions = options; }, - SetShortcutExe(_appId: number, executable: string) { - targetWrites.push(executable); - shortcutTarget = executable; - }, }, }; - const installed = await installWrapperIntegration(100 + index, true, wrapper, false, false); - assert.equal(installed.snapshot.target, item.target); + const installed = await installWrapperIntegration(100 + index, true, wrapper); assert.equal(installed.snapshot.options, item.expected); - assert.deepEqual(targetWrites, []); assert.deepEqual(shortcutWrites, [item.expected]); - const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded, false); - assert.equal(restored.target, item.target); + const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded); assert.equal(restored.options, item.options); - assert.deepEqual(targetWrites, []); } } finally { if (previousWindow === undefined) delete (globalThis as Record).window; @@ -195,32 +173,29 @@ test("AppImage and EmuDeck script shortcuts stay launch-option based", async () } }); -test("direct Flatpak fails closed and rolls Target writes back", async () => { +test("launch option write failure rolls back the original value", async () => { const previousWindow = (globalThis as Record).window; const previousSteamClient = (globalThis as Record).SteamClient; - let shortcutTarget = "/usr/bin/flatpak"; - const targetWrites: string[] = []; + let appOptions = "FOO=bar %command%"; + const writes: string[] = []; (globalThis as Record).window = { setTimeout, clearTimeout }; (globalThis as Record).SteamClient = { Apps: { RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" }); + callback({ strLaunchOptions: appOptions }); return { unregister() {} }; }, - SetShortcutExe(_appId: number, executable: string) { - targetWrites.push(executable); - shortcutTarget = executable; - if (executable.startsWith(wrapper)) throw new Error("simulated Target write failure"); + SetAppLaunchOptions(_appId: number, options: string) { + writes.push(options); + appOptions = options; + if (options.includes(wrapper)) throw new Error("simulated launch option failure"); }, }, }; try { - await assert.rejects(installWrapperIntegration(43, true, wrapper, false, true), /simulated Target write failure/); - assert.equal(shortcutTarget, "/usr/bin/flatpak"); - assert.deepEqual(targetWrites, ['~/.lsfg "/usr/bin/flatpak"', "/usr/bin/flatpak"]); - - shortcutTarget = "garbage"; - await assert.rejects(installWrapperIntegration(43, true, wrapper, false, true), /supported direct Flatpak/); + await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch option failure/); + assert.equal(appOptions, "FOO=bar %command%"); + assert.deepEqual(writes, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); } finally { if (previousWindow === undefined) delete (globalThis as Record).window; else (globalThis as Record).window = previousWindow; -- cgit v1.2.3 From 7b9d1c604099dd21d6c710ca00dfd9b001afc172 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:41:00 -0400 Subject: test: keep steam shortcut discovery flatpak agnostic --- tests/test_steam_service.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 3f0283b..76cdf47 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -11,23 +11,11 @@ sys.modules.setdefault( ) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) -from lsfg_vk.steam_service import SteamService, is_direct_flatpak_shortcut +from lsfg_vk.steam_service import SteamService class SteamShortcutTests(unittest.TestCase): - def test_only_direct_flatpak_targets_are_special(self): - self.assertTrue(is_direct_flatpak_shortcut("/usr/bin/flatpak")) - self.assertTrue(is_direct_flatpak_shortcut("flatpak")) - self.assertTrue(is_direct_flatpak_shortcut("/usr/bin/flatpak run com.example.Game")) - self.assertTrue(is_direct_flatpak_shortcut('~/.lsfg "/usr/bin/flatpak"')) - self.assertTrue(is_direct_flatpak_shortcut('~/lsfg "usr/bin/flatpak"')) - self.assertTrue(is_direct_flatpak_shortcut('~/.local/bin/mako-run "/usr/bin/flatpak"')) - self.assertFalse(is_direct_flatpak_shortcut("/usr/bin/bash")) - self.assertFalse(is_direct_flatpak_shortcut("/home/deck/Emulation/tools/launchers/retroarch.sh")) - self.assertFalse(is_direct_flatpak_shortcut("/home/deck/Emulation/tools/launchers/ppsspp.sh")) - self.assertFalse(is_direct_flatpak_shortcut("/home/deck/AppImages/dusk.appimage")) - - def test_shortcut_data_preserves_launch_shape_without_flatpak_identity(self): + def test_direct_flatpak_shortcut_is_ordinary_non_steam_metadata(self): game = SteamService._shortcut_game( { "appid": 123456, @@ -39,13 +27,15 @@ class SteamShortcutTests(unittest.TestCase): ) self.assertEqual(game["appid"], "123456") - self.assertTrue(game["directFlatpak"]) + self.assertEqual(game["name"], "PCSX2 shortcut") + self.assertTrue(game["nonSteam"]) + self.assertNotIn("directFlatpak", game) self.assertNotIn("transport", game) self.assertEqual(game["executable"], "/usr/bin/flatpak") self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen") self.assertEqual(game["startDir"], "/home/deck/Games") - def test_emudeck_launcher_is_ordinary_non_steam(self): + def test_emudeck_launcher_is_ordinary_non_steam_metadata(self): game = SteamService._shortcut_game( { "appid": 987654, @@ -55,11 +45,19 @@ class SteamShortcutTests(unittest.TestCase): } ) - self.assertFalse(game["directFlatpak"]) + self.assertEqual(game["appid"], "987654") + self.assertTrue(game["nonSteam"]) + self.assertNotIn("directFlatpak", game) self.assertEqual( game["executable"], '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', ) + self.assertEqual(game["arguments"], "") + + def test_shortcut_rejects_invalid_identity(self): + self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"})) + self.assertIsNone(SteamService._shortcut_game({"appid": 1, "AppName": ""})) + self.assertIsNone(SteamService._shortcut_game("bad")) if __name__ == "__main__": -- cgit v1.2.3 From 00451fc89cd6254c656f72a9a8181cfd93ea8936 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:41:21 -0400 Subject: test: cover flatpak selector profiles --- tests/test_configuration_profiles.py | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_configuration_profiles.py diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py new file mode 100644 index 0000000..789842e --- /dev/null +++ b/tests/test_configuration_profiles.py @@ -0,0 +1,80 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.config_schema import ConfigurationManager +from lsfg_vk.configuration import ConfigurationService + + +class ConfigurationProfileTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.runtime = Mock() + self.service = ConfigurationService(runtime_service=self.runtime) + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + + def tearDown(self): + self.tempdir.cleanup() + + def test_selector_only_profile_survives_round_trip(self): + content = """version = 2 + +[global] +allow_fp16 = true + +[[profile]] +name = "flatpak:org.example.Game" +pacing_mode = "vsync" +multiplier = 3 +flow_scale = 0.8 +performance_mode = false +override_present_mode = true +preserve_swapchain_image_count = false +""" + parsed = ConfigurationManager.parse_toml_content_multi_profile(content) + self.assertIn("flatpak:org.example.Game", parsed["profiles"]) + self.assertEqual(parsed["profiles"]["flatpak:org.example.Game"]["active_in"], []) + rendered = ConfigurationManager.generate_toml_content_multi_profile(parsed) + reparsed = ConfigurationManager.parse_toml_content_multi_profile(rendered) + self.assertEqual(reparsed["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_game_reset_all_preserves_flatpak_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_game_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_flatpak_reset_all_preserves_steam_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_flatpak_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertIn("Steam Game", data["profiles"]) + self.assertNotIn("flatpak:org.example.Game", data["profiles"]) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3 From 4e5d7eb600a094d6f52bd915814de85ea6735836 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:41:59 -0400 Subject: test: cover flatpak profile environment integration --- tests/test_flatpak_profile_service.py | 210 ++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/test_flatpak_profile_service.py diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py new file mode 100644 index 0000000..8d58413 --- /dev/null +++ b/tests/test_flatpak_profile_service.py @@ -0,0 +1,210 @@ +import hashlib +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.configuration import ConfigurationService +from lsfg_vk.flatpak_profile_service import FlatpakProfileService + + +class FakeFlatpakService: + def __init__(self, home: Path): + self.user_home = home + self.config_dir = home / ".config/lsfg-vk" + self.config_file_path = self.config_dir / "conf.toml" + self.backup_dir = self.config_dir / "flatpak-overrides" + self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}} + self.commands = [] + self.running = "" + + def _read_state(self): + return self.state + + def _write_state(self, state): + self.state = state + + def _override_path(self, app_id): + return self.user_home / ".local/share/flatpak/overrides" / app_id + + def _backup_path(self, app_id): + return self.backup_dir / f"{app_id}.ini" + + @staticmethod + def _sha256(content): + return hashlib.sha256(content).hexdigest() + + def _snapshot_override(self, app_id): + path = self._override_path(app_id) + if not path.exists(): + return False, b"" + return True, path.read_bytes() + + @staticmethod + def _write_file(path, content, mode=0o644): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + path.chmod(mode) + + def prepare_app(self, app_id): + apps = self.state["prepared_apps"] + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + if existed: + self._write_file(self._backup_path(app_id), original.decode("utf-8")) + apps[app_id] = {"override_existed": existed, "managed_sha256": ""} + entry = apps[app_id] + baseline = "" + if entry["override_existed"]: + baseline = self._backup_path(app_id).read_text(encoding="utf-8") + managed = baseline + "\n[Context]\nfilesystems=/config:ro;/dll:ro;\n[Environment]\nLSFGVK_CONFIG=/config/conf.toml\nLSFGVK_FLATPAK=1\n" + self._write_file(self._override_path(app_id), managed) + entry["managed_sha256"] = self._sha256(managed.encode()) + return {"success": True, "owned": True, "prepared": True, "runtime": "org.freedesktop.Platform/x86_64/24.08", "runtime_branch": "24.08"} + + def remove_app_override(self, app_id): + entry = self.state["prepared_apps"].get(app_id) + if entry is None: + return {"success": True, "prepared": False, "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"]: + return {"success": False, "error": "Flatpak override changed after preparation"} + path = self._override_path(app_id) + backup = self._backup_path(app_id) + if entry["override_existed"]: + self._write_file(path, backup.read_text(encoding="utf-8")) + else: + path.unlink(missing_ok=True) + backup.unlink(missing_ok=True) + self.state["prepared_apps"].pop(app_id) + return {"success": True, "prepared": False, "owned": False} + + def get_flatpak_apps(self): + app_id = "org.example.Game" + return { + "success": True, + "apps": [{ + "app_id": app_id, + "app_name": "Example Game", + "runtime": "org.freedesktop.Platform/x86_64/24.08", + "runtime_branch": "24.08", + "runtime_ready": True, + "prepared": app_id in self.state["prepared_apps"], + "owned": app_id in self.state["prepared_apps"], + "error": None, + }], + } + + def _run_flatpak_command(self, args, **_kwargs): + self.commands.append(args) + if args[:3] == ["override", "--user", "--show"]: + path = self._override_path(args[3]) + return types.SimpleNamespace(returncode=0, stdout=path.read_text(encoding="utf-8") if path.exists() else "", stderr="") + if args[0] == "override": + app_id = args[-1] + path = self._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + env = [item.removeprefix("--env=") for item in args if item.startswith("--env=")] + unset = [item.removeprefix("--unset-env=") for item in args if item.startswith("--unset-env=")] + if unset: + content += "\n[Context]\nunset-environment=" + ";".join(unset) + ";\n" + if env: + content += "\n[Environment]\n" + "\n".join(env) + "\n" + self._write_file(path, content) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + if args[0] == "ps": + return types.SimpleNamespace(returncode=0, stdout=self.running, stderr="") + raise AssertionError(args) + + +class FlatpakProfileServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.flatpak = FakeFlatpakService(self.home) + self.runtime = Mock() + self.configuration = ConfigurationService(runtime_service=self.runtime) + self.configuration.user_home = self.home + self.configuration.config_dir = self.flatpak.config_dir + self.configuration.config_file_path = self.flatpak.config_file_path + self.service = FlatpakProfileService(self.flatpak, self.configuration) + self.app_id = "org.example.Game" + + def tearDown(self): + self.tempdir.cleanup() + + def test_enable_creates_selector_profile_and_default_workarounds(self): + result = self.service.enable_app(self.app_id) + config = self.configuration.get_flatpak_config(self.app_id) + content = self.flatpak._override_path(self.app_id).read_text(encoding="utf-8") + + self.assertTrue(result["success"]) + self.assertTrue(config["exists"]) + self.assertEqual(config["profile"], "flatpak:org.example.Game") + self.assertEqual(config["config"]["active_in"], []) + self.assertIn("LSFGVK_PROFILE=flatpak:org.example.Game", content) + self.assertIn("ENABLE_GAMESCOPE_WSI=0", content) + self.assertIn("DXVK_HDR=0", content) + + def test_workaround_update_rebuilds_from_original_override(self): + baseline = "[Environment]\nDXVK_CONFIG=dxgi.syncInterval = 0\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + state = self.service.default_state() + state.update({"dxvkFrameRate": 30, "disableHdr": False, "enableZink": True}) + result = self.service.set_workaround_state(self.app_id, state) + command = next( + args for args in reversed(self.flatpak.commands) + if args[0] == "override" and any(item.startswith("--env=LSFGVK_PROFILE=") for item in args) + ) + + self.assertTrue(result["success"]) + self.assertIn("--env=DXVK_CONFIG=dxgi.syncInterval = 0; dxvk.maxFrameRate = 30", command) + self.assertNotIn("--env=DXVK_HDR=0", command) + self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command) + + def test_remove_restores_exact_original_override_and_profile(self): + baseline = "[Environment]\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + removed = self.service.remove_app(self.app_id) + + self.assertTrue(removed["success"]) + self.assertEqual(self.flatpak._override_path(self.app_id).read_text(encoding="utf-8"), baseline) + self.assertFalse(self.configuration.get_flatpak_config(self.app_id)["exists"]) + + def test_external_override_change_fails_closed(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + path = self.flatpak._override_path(self.app_id) + path.write_text(path.read_text(encoding="utf-8") + "EXTERNAL=yes\n", encoding="utf-8") + + result = self.service.set_workaround_state(self.app_id, self.service.default_state()) + + self.assertFalse(result["success"]) + self.assertIn("changed after preparation", result["error"]) + + def test_running_detection_uses_owned_selector_state(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n" + + result = self.service.get_running_apps() + + self.assertTrue(result["success"]) + self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234"}]) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3 From b529bc1f0d6a5418cb60ebc6c127796aba08068e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:42:30 -0400 Subject: test: cover flatpak profile uninstall cleanup --- tests/test_plugin_migration.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py index ab036b0..824cbf6 100644 --- a/tests/test_plugin_migration.py +++ b/tests/test_plugin_migration.py @@ -51,17 +51,35 @@ class PluginMigrationTests(unittest.TestCase): finally: self._restore(previous_decky, previous_tomllib) - def test_uninstall_cleans_owned_flatpak_state(self): + def test_uninstall_cleans_owned_flatpak_state_and_profiles(self): Plugin, _decky, previous_decky, previous_tomllib = self._load_plugin() try: plugin = Plugin.__new__(Plugin) plugin.installation_service = Mock() plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} asyncio.run(plugin._uninstall()) plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() + plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() + plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() + finally: + self._restore(previous_decky, previous_tomllib) + + def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self): + Plugin, _decky, previous_decky, previous_tomllib = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"} + + asyncio.run(plugin._uninstall()) + + plugin.configuration_service.reset_all_flatpak_configs.assert_not_called() plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() finally: self._restore(previous_decky, previous_tomllib) -- 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(-) 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 dff6251e620767b28a81915389b16c916aac2cb0 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:44:52 -0400 Subject: refactor: trim unused steam shortcut fields --- src/api/lsfgApi.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 3643363..3d4890e 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -41,9 +41,6 @@ export interface InstalledGame { appid: string; name: string; nonSteam: boolean; - executable?: string; - arguments?: string; - startDir?: string; } export interface GlobalConfig { -- cgit v1.2.3 From 252079e1df34329db1e3dc11c66c8c9d40a3a616 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:45:05 -0400 Subject: test: cover minimal steam shortcut metadata --- tests/test_steam_service.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 76cdf47..004ffb6 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -26,14 +26,11 @@ class SteamShortcutTests(unittest.TestCase): } ) - self.assertEqual(game["appid"], "123456") - self.assertEqual(game["name"], "PCSX2 shortcut") - self.assertTrue(game["nonSteam"]) - self.assertNotIn("directFlatpak", game) - self.assertNotIn("transport", game) - self.assertEqual(game["executable"], "/usr/bin/flatpak") - self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen") - self.assertEqual(game["startDir"], "/home/deck/Games") + self.assertEqual(game, { + "appid": "123456", + "name": "PCSX2 shortcut", + "nonSteam": True, + }) def test_emudeck_launcher_is_ordinary_non_steam_metadata(self): game = SteamService._shortcut_game( @@ -45,14 +42,11 @@ class SteamShortcutTests(unittest.TestCase): } ) - self.assertEqual(game["appid"], "987654") - self.assertTrue(game["nonSteam"]) - self.assertNotIn("directFlatpak", game) - self.assertEqual( - game["executable"], - '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', - ) - self.assertEqual(game["arguments"], "") + self.assertEqual(game, { + "appid": "987654", + "name": "1080 Snowboarding", + "nonSteam": True, + }) def test_shortcut_rejects_invalid_identity(self): self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"})) -- cgit v1.2.3 From 9783834314956ea4765faadff7ae964151a7ff18 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:45:15 -0400 Subject: refactor: trim unused steam detail fields --- src/types.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types.d.ts b/src/types.d.ts index ebcf32c..4adad61 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -16,7 +16,6 @@ declare module "*.jpg" { interface SteamAppDetails { strLaunchOptions?: string; strShortcutLaunchOptions?: string; - strShortcutStartDir?: string; } interface SteamAppDetailsRegistration { -- cgit v1.2.3 From 817cdf3d5344e961df05f408354d0387c731f5ce Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:46:19 -0400 Subject: fix: derive now playing config from running app --- src/hooks/useGameConfiguration.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 8d56a62..17eb867 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -116,6 +116,9 @@ export function useGameConfiguration() { }, [games, installedGames, runningGame]); const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; + const runningConfig = runningGame + ? games.find((game) => game.appid === runningGame.appid)?.config || template + : template; const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { if (!installedGames.some((game) => game.appid === target.appid)) return true; @@ -263,5 +266,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; + return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } -- cgit v1.2.3 From 69f5b6f884064e93f15570d8c7ceda5d610c39dd Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:46:41 -0400 Subject: fix: use running steam config in now playing --- src/components/Content.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index ffb6eef..241ab92 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -45,6 +45,7 @@ function usePersistentBoolean(key: string, defaultValue: boolean) { export function Content() { const { config, + runningConfig, targets, runningGame, setSelectedAppId, @@ -135,8 +136,10 @@ export function Content() { const nowPlaying = runningGame?.configured ? ( handleConfigChange(field, value)} + config={runningConfig} + onConfigChange={async (field, value) => { + await save({ ...runningConfig, [field]: value }, true); + }} /> ) : runningFlatpak ? ( Date: Thu, 10 Sep 2026 16:47:17 -0400 Subject: fix: make steam profile saves app scoped --- src/hooks/useGameConfiguration.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 17eb867..2e39f5e 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -89,11 +89,19 @@ export function useGameConfiguration() { const installed = installedGames.find((game) => game.appid === appid); const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); - setRunningGame((current) => current?.appid === appid ? current : { + const next: GameTarget = { ...(installed || { appid, name, nonSteam: false }), name, configured: games.some((game) => game.appid === appid), - }); + }; + setRunningGame((current) => ( + current?.appid === next.appid + && current.name === next.name + && current.nonSteam === next.nonSteam + && current.configured === next.configured + ? current + : next + )); }; poll(); const interval = window.setInterval(poll, 2000); @@ -197,13 +205,22 @@ export function useGameConfiguration() { } }, [installedGames]); - const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { - const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (!selectedTarget?.name) return; - if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return; - const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); + const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => { + const target = targets.find((item) => item.appid === appid); + if (!target?.name) return false; + if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false; + const result = await updateGameConfig(appid, target.name, next); if (result.success) await load(); - }, [ensureTargetWorkarounds, load, selectedAppId, targets]); + return result.success; + }, [ensureTargetWorkarounds, load, targets]); + + const save = useCallback( + async (next: ConfigurationData, cleanupLaunchOptions = false) => { + if (!selectedAppId) return false; + return saveFor(selectedAppId, next, cleanupLaunchOptions); + }, + [saveFor, selectedAppId], + ); const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); @@ -266,5 +283,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; + return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } -- cgit v1.2.3 From 912237e98462eaa22084809c11446f5e031d08b9 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:47:38 -0400 Subject: fix: save now playing steam profile by app id --- src/components/Content.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 241ab92..a300f6f 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -50,6 +50,7 @@ export function Content() { runningGame, setSelectedAppId, save, + saveFor, enable, enableAll, repair, @@ -138,7 +139,7 @@ export function Content() { game={runningGame} config={runningConfig} onConfigChange={async (field, value) => { - await save({ ...runningConfig, [field]: value }, true); + await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true); }} /> ) : runningFlatpak ? ( -- cgit v1.2.3 From c3778706ab420494479740e19df08c0c3c5b2693 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:48:39 -0400 Subject: fix: preserve config callback return type --- src/components/Content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index a300f6f..ce3c018 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -118,7 +118,9 @@ export function Content() { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], cleanupLaunchOptions = false, - ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + ) => { + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + }; const setup = ( Date: Thu, 10 Sep 2026 19:47:00 -0400 Subject: tests --- tests/test_plugin_migration.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py index 824cbf6..7ab4621 100644 --- a/tests/test_plugin_migration.py +++ b/tests/test_plugin_migration.py @@ -17,13 +17,14 @@ class PluginMigrationTests(unittest.TestCase): ) previous_decky = sys.modules.get("decky") previous_tomllib = sys.modules.get("tomllib") + previous_plugin = sys.modules.pop("lsfg_vk.plugin", None) sys.modules["decky"] = decky sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock()) sys.path.insert(0, "py_modules") from lsfg_vk.plugin import Plugin - return Plugin, decky, previous_decky, previous_tomllib + return Plugin, decky, previous_decky, previous_tomllib, previous_plugin - def _restore(self, previous_decky, previous_tomllib): + def _restore(self, previous_decky, previous_tomllib, previous_plugin): sys.path.remove("py_modules") if previous_decky is None: sys.modules.pop("decky", None) @@ -33,9 +34,13 @@ class PluginMigrationTests(unittest.TestCase): sys.modules.pop("tomllib", None) else: sys.modules["tomllib"] = previous_tomllib + if previous_plugin is None: + sys.modules.pop("lsfg_vk.plugin", None) + else: + sys.modules["lsfg_vk.plugin"] = previous_plugin def test_migration_only_runs_decky_path_migrations(self): - Plugin, decky, previous_decky, previous_tomllib = self._load_plugin() + Plugin, decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() try: plugin = Plugin.__new__(Plugin) plugin.installation_service = Mock() @@ -49,10 +54,10 @@ class PluginMigrationTests(unittest.TestCase): plugin.installation_service.install.assert_not_called() plugin.flatpak_service.prepare_app.assert_not_called() finally: - self._restore(previous_decky, previous_tomllib) + self._restore(previous_decky, previous_tomllib, previous_plugin) def test_uninstall_cleans_owned_flatpak_state_and_profiles(self): - Plugin, _decky, previous_decky, previous_tomllib = self._load_plugin() + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() try: plugin = Plugin.__new__(Plugin) plugin.installation_service = Mock() @@ -66,10 +71,10 @@ class PluginMigrationTests(unittest.TestCase): plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() finally: - self._restore(previous_decky, previous_tomllib) + self._restore(previous_decky, previous_tomllib, previous_plugin) def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self): - Plugin, _decky, previous_decky, previous_tomllib = self._load_plugin() + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() try: plugin = Plugin.__new__(Plugin) plugin.installation_service = Mock() @@ -82,7 +87,7 @@ class PluginMigrationTests(unittest.TestCase): plugin.configuration_service.reset_all_flatpak_configs.assert_not_called() plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() finally: - self._restore(previous_decky, previous_tomllib) + self._restore(previous_decky, previous_tomllib, previous_plugin) if __name__ == "__main__": -- cgit v1.2.3 From 8fd3e86ad0dcd74096e1dcbe2c7f5944b83c9bcc Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 19:59:46 -0400 Subject: chore: add simple Deck deploy recipe --- justfile | 7 ++++-- scripts/deploy-to-deck.sh | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100755 scripts/deploy-to-deck.sh diff --git a/justfile b/justfile index b190631..d3a7674 100644 --- a/justfile +++ b/justfile @@ -1,8 +1,11 @@ default: - echo "Available recipes: build, test, clean" + echo "Available recipes: build, deploy, test, clean" build: - .vscode/build.sh + pnpm build + +deploy: + ./scripts/deploy-to-deck.sh test: scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop diff --git a/scripts/deploy-to-deck.sh b/scripts/deploy-to-deck.sh new file mode 100755 index 0000000..b5cb4f7 --- /dev/null +++ b/scripts/deploy-to-deck.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +deck_host="deck@192.168.0.241" +plugin_root="Decky LSFG-VK" +install_path="/home/deck/homebrew/plugins/Decky LSFG-VK" + +package_dir="$(mktemp -d "${TMPDIR:-/tmp}/decky-plugin-package.XXXXXX")" +run_id="$(basename "$package_dir")" +remote_archive="/tmp/decky-lsfg-vk-${run_id}.zip" +remote_stage="/tmp/decky-lsfg-vk-stage-${run_id}" +trap 'rm -rf -- "$package_dir"' EXIT INT TERM + +pnpm build +./cli/decky plugin build . \ + --output-path "$package_dir" \ + --tmp-output-path "$package_dir/tmp" + +package_zip="$(find "$package_dir" -maxdepth 1 -type f -name '*.zip' -print -quit)" +test -n "$package_zip" + +scp "$package_zip" "$deck_host:$remote_archive" + +remote_script="$(cat <<'REMOTE' +set -euo pipefail + +archive="$1" +stage="$2" +install="$3" +plugin_root="$4" + +cleanup() { + rm -rf -- "$archive" "$stage" +} +trap cleanup EXIT INT TERM + +mkdir -p -- "$stage" +unzip -q "$archive" -d "$stage" +test -f "$stage/$plugin_root/plugin.json" +test -f "$stage/$plugin_root/dist/index.js" + +sudo -v +sudo rm -rf -- "$install" +sudo mv -- "$stage/$plugin_root" "$install" +sudo chown -R deck:deck -- "$install" +sudo systemctl restart plugin_loader.service +sleep 2 +sudo chown -R deck:deck -- "$install" + +test "$(systemctl is-active plugin_loader.service)" = active +echo "Deck is ready to test" +REMOTE +)" + +printf -v remote_command 'bash -c %q -- %q %q %q %q' \ + "$remote_script" "$remote_archive" "$remote_stage" "$install_path" "$plugin_root" + +ssh -tt "$deck_host" "$remote_command" -- 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 --- justfile | 3 +- package.json | 15 --------- py_modules/lsfg_vk/constants.py | 3 -- py_modules/lsfg_vk/flatpak_service.py | 60 +++++++++++++++++++---------------- src/components/FlatpakTab.tsx | 4 +-- tests/test_flatpak_service.py | 41 ++++++++++++++++++++++-- 6 files changed, 75 insertions(+), 51 deletions(-) diff --git a/justfile b/justfile index d3a7674..b608987 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,8 @@ deploy: ./scripts/deploy-to-deck.sh test: - scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop + node --experimental-strip-types --test tests/steamLaunchOptions.test.ts + python3.12 -m unittest discover -s tests -p 'test_*.py' watch: ssh deck@192.168.0.6 "journalctl -f" diff --git a/package.json b/package.json index b5e716c..4a1780a 100644 --- a/package.json +++ b/package.json @@ -52,21 +52,6 @@ "name": "lsfg-vk-2.0.0.tar.xz", "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "sha256hash": "7eff81f96b278fde6fe395c88461c55e611547bf5991d179b9145ec6f5a778b1" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "sha256hash": "cbd663b9021c355dddec61ec51fd4388c12705f16637cadd4b45aead0e5dc427" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "sha256hash": "615e87c03b18368ed1cca97e036f1bf54fff95fc6126aac697bb368f33281cbf" } ], "pnpm": { 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): diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx index 6392d59..c5e27f5 100644 --- a/src/components/FlatpakTab.tsx +++ b/src/components/FlatpakTab.tsx @@ -40,12 +40,12 @@ export function FlatpakTab({ if (!selectedAppId) { return ( - + {/* - + */} {apps.map((app) => { const status = app.enabled ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled" diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 065871b..4d804c7 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -32,10 +32,8 @@ class FlatpakServiceTests(unittest.TestCase): self.runtime_metadata = "" self.user_branches = set() self.system_branches = set() + self.user_extension_origin = "flathub" self.apps = {"com.example.Game": "Example Game"} - self.bundle = self.home / "lsfg-vk.flatpak" - self.bundle.write_bytes(b"bundle") - self.service._bundled_extension_path = Mock(return_value=self.bundle) self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling" self.service._dll_directory = Mock(return_value=self.dll_dir) @@ -120,6 +118,8 @@ class FlatpakServiceTests(unittest.TestCase): return self._result(self.runtime_ref + "\n") if args[:2] == ["info", "--show-metadata"]: return self._result(self.runtime_metadata) + if args[:3] == ["info", "--user", "--show-origin"]: + return self._result(self.user_extension_origin) if args[:2] == ["list", "--app"]: return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items())) if args[0] == "list": @@ -157,6 +157,22 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(response["owned"]) self.assertEqual(response["runtime_branch"], "24.08") self.assertEqual(self.user_branches, {"24.08"}) + install_calls = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ] + self.assertEqual( + install_calls, + [[ + "install", + "--user", + "--noninteractive", + "--or-update", + "flathub", + "org.freedesktop.Platform.VulkanLayer.lsfgvk//24.08", + ]], + ) status = self.service._app_override_status("com.example.Game") self.assertTrue(status["prepared"]) content = self.service._override_path("com.example.Game").read_text(encoding="utf-8") @@ -180,6 +196,25 @@ class FlatpakServiceTests(unittest.TestCase): install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"] self.assertEqual(len(install_calls), 1) + def test_replaces_extension_from_another_remote(self): + self.user_branches = {"24.08"} + self.user_extension_origin = "lsfgvk-origin" + + response = self.service.install_extension("24.08") + + self.assertTrue(response["success"]) + commands = [call.args[0] for call in self.service._run_flatpak_command.call_args_list] + self.assertIn( + [ + "uninstall", + "--user", + "--noninteractive", + "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08", + ], + commands, + ) + self.assertEqual(self.user_branches, {"24.08"}) + def test_preinstalled_runtime_is_not_owned(self): self.system_branches = {"24.08"} response = self.service.prepare_app("com.example.Game") -- 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 --- justfile | 2 +- package.json | 2 +- py_modules/lsfg_vk/flatpak_profile_service.py | 12 +++- py_modules/lsfg_vk/flatpak_service.py | 30 ++++++++++ src/api/lsfgApi.ts | 1 + src/components/CollapsibleItemGroup.tsx | 79 +++++++++++++++++++++++++ src/components/Content.tsx | 56 +++++++++++++----- src/components/FlatpakNowPlayingTab.tsx | 26 ++++---- src/components/FlatpakTab.tsx | 79 ++++++++++++++++++------- src/components/GameConfigurationSelector.tsx | 85 ++++----------------------- src/hooks/useFlatpakConfiguration.ts | 59 ++++++++++++++----- src/utils/nowPlaying.ts | 54 +++++++++++++++++ tests/nowPlaying.test.ts | 76 ++++++++++++++++++++++++ tests/test_flatpak_profile_service.py | 25 +++++++- tests/test_flatpak_service.py | 6 ++ 15 files changed, 452 insertions(+), 140 deletions(-) create mode 100644 src/components/CollapsibleItemGroup.tsx create mode 100644 src/utils/nowPlaying.ts create mode 100644 tests/nowPlaying.test.ts diff --git a/justfile b/justfile index b608987..c935b76 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ deploy: ./scripts/deploy-to-deck.sh test: - node --experimental-strip-types --test tests/steamLaunchOptions.test.ts + node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts python3.12 -m unittest discover -s tests -p 'test_*.py' watch: diff --git a/package.json b/package.json index 4a1780a..f1bcda0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'" + "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'" }, "repository": { "type": "git", 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(): diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 3d4890e..f3b7fa1 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -118,6 +118,7 @@ export interface RunningFlatpakApp { app_id: string; active: boolean; pid?: string; + start_time?: number | null; } export interface FlatpakAppsResult extends ApiResult { diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx new file mode 100644 index 0000000..a66a8ba --- /dev/null +++ b/src/components/CollapsibleItemGroup.tsx @@ -0,0 +1,79 @@ +import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; +import { type RefObject } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +export interface CollapsibleItem { + id: string; + label: string; + description: string; +} + +export const collapsibleItemGroupStyles = ` + .LSFG_GameGroupCollapseButton_Container > div > div > div > button, + .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_GameGroupCollapseButton_Container svg { + display: block; + margin: 0; + } +`; + +interface Props { + title: string; + items: CollapsibleItem[]; + collapsed: boolean; + onToggle: () => void; + onSelect: (id: string) => void; + toggleRef?: RefObject; +} + +export function CollapsibleItemGroup({ + title, + items, + collapsed, + onToggle, + onSelect, + toggleRef, +}: Props) { + if (items.length === 0) return null; + + return ( + <> + + + + +
+ + {collapsed ? : } + +
+
+ {!collapsed && items.map((item) => ( + + onSelect(item.id)} + highlightOnFocus + /> + + ))} + + ); +} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index ce3c018..0d49f89 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,4 +1,4 @@ -import { Tabs } from "@decky/ui"; +import { Field, PanelSection, PanelSectionRow, Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; @@ -6,6 +6,7 @@ import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallation } from "../hooks/useLsfgHooks"; import { tabStyles } from "../styles"; +import { resolveNowPlayingTarget } from "../utils/nowPlaying"; import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; @@ -80,10 +81,13 @@ export function Content() { const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); const previousRunningWorkload = useRef(null); const runningFlatpak = flatpak.runningApp; - const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak); - const runningWorkload = runningGame?.configured - ? `steam:${runningGame.appid}` - : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null; + const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak); + const hasNowPlaying = Boolean(nowPlayingTarget); + const runningWorkload = nowPlayingTarget + ? nowPlayingTarget.kind === "flatpak" + ? `flatpak:${nowPlayingTarget.app.app_id}` + : `steam:${nowPlayingTarget.game.appid}` + : null; useEffect(() => { if (!setupComplete) { @@ -136,26 +140,27 @@ export function Content() { /> ); - const nowPlaying = runningGame?.configured ? ( + const nowPlaying = nowPlayingTarget?.kind === "steam" ? ( { - await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true); + await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true); }} /> - ) : runningFlatpak ? ( + ) : nowPlayingTarget?.kind === "flatpak" ? ( - ) : null; + ) : ( + + ); const tabs = setupComplete ? [ - ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []), + { id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }, { id: "Games", title: tabIcons.games, @@ -198,13 +203,34 @@ export function Content() { ] : [{ id: "Setup", title: tabIcons.setup, content: setup }]; + const availableTabIds = new Set(tabs.map(({ id }) => id)); + const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup"; + return (
- + { + if (availableTabIds.has(nextTab)) setTab(nextTab); + }} + tabs={tabs} + /> +
+ ); +} + +function NowPlayingTabPlaceholder() { + return ( +
+ + + + +
); } diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx index 9e5a0db..9623b11 100644 --- a/src/components/FlatpakNowPlayingTab.tsx +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -1,17 +1,16 @@ import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi"; +import type { GameTarget } from "../hooks/useGameConfiguration"; import { ConfigurationSection } from "./ConfigurationSection"; -import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; interface Props { app: FlatpakApp; - busy: boolean; + launcher: GameTarget | null; onConfigChange: (appId: string, config: LsfgConfig) => Promise; - onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise; } -export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) { +export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) { if (!app.config) return null; const changeConfig = async ( field: keyof LsfgConfig, @@ -24,16 +23,21 @@ export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundCh - + + {launcher && ( + + + + )} - onWorkaroundChange(app.app_id, state)} - /> ); } diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx index c5e27f5..31bfc2d 100644 --- a/src/components/FlatpakTab.tsx +++ b/src/components/FlatpakTab.tsx @@ -1,7 +1,8 @@ import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup"; import { ConfigurationSection } from "./ConfigurationSection"; import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; @@ -19,6 +20,24 @@ interface Props { onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise; } +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch {} + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + export function FlatpakTab({ apps, runningApp, @@ -36,31 +55,47 @@ export function FlatpakTab({ [apps, selectedAppId], ); const close = useCallback(() => setSelectedAppId(null), []); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed("lsfg-flatpak-enabled-collapsed-v1"); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed("lsfg-flatpak-available-collapsed-v1"); + const enabledToggleRef = useRef(null); + + const enabledApps = useMemo( + () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const availableApps = useMemo( + () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const itemFor = (app: FlatpakApp) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`, + }); if (!selectedAppId) { return ( - {/* - - */} - {apps.map((app) => { - const status = app.enabled - ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled" - : app.prepared && !app.owned ? "Prepared externally" : "Available"; - return ( - - setSelectedAppId(app.app_id)} - highlightOnFocus - /> - - ); - })} + + ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`, + }))} + collapsed={enabledCollapsed} + onToggle={toggleEnabled} + onSelect={setSelectedAppId} + toggleRef={enabledToggleRef} + /> + {apps.length === 0 && !loading && ( diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 1f0bc73..ee87423 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,7 +1,7 @@ import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; -import { useEffect, useRef, useState, type RefObject } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { useEffect, useRef, useState } from "react"; import { GameTarget } from "../hooks/useGameConfiguration"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup"; interface Props { targets: GameTarget[]; @@ -38,57 +38,6 @@ function targetDescription(game: GameTarget): string { return game.nonSteam ? "Non-Steam" : "Steam"; } -function GameGroup({ - title, - games, - collapsed, - onToggle, - onSelect, - toggleRef, -}: { - title: string; - games: GameTarget[]; - collapsed: boolean; - onToggle: () => void; - onSelect: (appid: string) => void; - toggleRef?: RefObject; -}) { - if (games.length === 0) return null; - - return ( - <> - - - - -
- - {collapsed ? : } - -
-
- {!collapsed && games.map((game) => ( - - onSelect(game.appid)} - highlightOnFocus - /> - - ))} - - ); -} - export function GameConfigurationSelector({ targets, runningGame, @@ -105,6 +54,11 @@ export function GameConfigurationSelector({ }); const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); + const toItem = (game: GameTarget) => ({ + id: game.appid, + label: game.name, + description: targetDescription(game), + }); const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); const enabledToggleRef = useRef(null); @@ -146,39 +100,24 @@ export function GameConfigurationSelector({ return ( <> {targets.length === 0 && ( )} - - ([]); const [runningApps, setRunningApps] = useState([]); @@ -58,39 +66,62 @@ export function useFlatpakConfiguration(enabled: boolean) { return () => window.clearInterval(interval); }, [enabled, pollRunning]); - const operate = useCallback(async (appId: string, operation: () => Promise<{ success: boolean; error?: string | null }>) => { - if (busyAppId) return false; + const operate = useCallback(async ( + appId: string, + operation: () => Promise, + refresh = true, + ): Promise => { + if (busyAppId) return { success: false }; setBusyAppId(appId); try { const result = await operation(); if (!result.success) throw new Error(result.error || "Flatpak operation failed"); - await reload(); - await pollRunning(); - return true; + if (refresh) { + await reload(); + await pollRunning(); + } + return result; } catch (error) { showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error)); - return false; + return { success: false, error: error instanceof Error ? error.message : String(error) }; } finally { setBusyAppId(""); } }, [busyAppId, pollRunning, reload]); - const enableApp = useCallback((appId: string) => operate(appId, () => enableFlatpakApp(appId)), [operate]); - const removeApp = useCallback((appId: string) => operate(appId, () => removeFlatpakApp(appId)), [operate]); + const enableApp = useCallback(async (appId: string) => ( + await operate(appId, () => enableFlatpakApp(appId)) + ).success, [operate]); + const removeApp = useCallback(async (appId: string) => ( + await operate(appId, () => removeFlatpakApp(appId)) + ).success, [operate]); const updateConfig = useCallback( - (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)), + async (appId: string, config: LsfgConfig) => { + const result = await operate(appId, () => updateFlatpakConfig(appId, config), false); + if (result.success) { + setApps((current) => current.map((app) => ( + app.app_id === appId ? { ...app, config: result.config || config } : app + ))); + } + return result.success; + }, [operate], ); const updateWorkarounds = useCallback( - (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)), + async (appId: string, state: WorkaroundState) => { + const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false); + if (result.success) { + setApps((current) => current.map((app) => ( + app.app_id === appId ? { ...app, workarounds: result.state || state } : app + ))); + } + return result.success; + }, [operate], ); const runningApp = useMemo(() => { - if (runningApps.length === 0) return null; - const running = runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null); - if (!running) return null; - return apps.find((app) => app.app_id === running.app_id) || null; + return selectMostRecentRunningFlatpak(apps, runningApps); }, [apps, runningApps]); return { diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts new file mode 100644 index 0000000..a207321 --- /dev/null +++ b/src/utils/nowPlaying.ts @@ -0,0 +1,54 @@ +import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi"; +import type { GameTarget } from "../hooks/useGameConfiguration"; + +export type NowPlayingTarget = + | { + kind: "flatpak"; + app: FlatpakApp; + launcher: GameTarget | null; + } + | { + kind: "steam"; + game: GameTarget; + }; + +function numericValue(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : -1; +} + +function numericPid(value: string | undefined): number { + return value && /^\d+$/.test(value) ? Number(value) : -1; +} + +export function selectMostRecentRunningFlatpak( + apps: FlatpakApp[], + runningApps: RunningFlatpakApp[], +): FlatpakApp | null { + const candidates = runningApps + .map((running) => ({ + running, + app: apps.find((app) => app.app_id === running.app_id) || null, + })) + .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null) + .sort((a, b) => { + if (a.running.active !== b.running.active) return a.running.active ? -1 : 1; + const startDifference = numericValue(b.running.start_time) - numericValue(a.running.start_time); + if (startDifference !== 0) return startDifference; + const pidDifference = numericPid(b.running.pid) - numericPid(a.running.pid); + if (pidDifference !== 0) return pidDifference; + return a.running.app_id.localeCompare(b.running.app_id); + }); + + return candidates[0]?.app || null; +} + +export function resolveNowPlayingTarget( + runningGame: GameTarget | null, + runningFlatpak: FlatpakApp | null, +): NowPlayingTarget | null { + if (runningFlatpak) { + return { kind: "flatpak", app: runningFlatpak, launcher: runningGame }; + } + if (runningGame?.configured) return { kind: "steam", game: runningGame }; + return null; +} diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts new file mode 100644 index 0000000..3f0d891 --- /dev/null +++ b/tests/nowPlaying.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveNowPlayingTarget, selectMostRecentRunningFlatpak } from "../src/utils/nowPlaying.ts"; + +const flatpak = (app_id: string, app_name = app_id) => ({ + app_id, + app_name, + runtime_ready: true, + prepared: true, + owned: true, + enabled: true, + profile: `flatpak:${app_id}`, + workarounds: { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, + }, +}); + +const game = (nonSteam = true, configured = true) => ({ + appid: "123456", + name: nonSteam ? "1080 Snowboarding" : "Native Game", + nonSteam, + configured, +}); + +test("selects the newest active managed Flatpak", () => { + const apps = [flatpak("org.example.old"), flatpak("org.example.new")]; + const running = [ + { app_id: "org.example.old", active: true, pid: "100", start_time: 500 }, + { app_id: "org.example.new", active: true, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.new"); +}); + +test("prefers active Flatpak status before process age", () => { + const apps = [flatpak("org.example.running"), flatpak("org.example.active")]; + const running = [ + { app_id: "org.example.running", active: false, pid: "900", start_time: 900 }, + { app_id: "org.example.active", active: true, pid: "100", start_time: 100 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.active"); +}); + +test("Flatpak runtime wins while a Steam shortcut is running", () => { + const target = resolveNowPlayingTarget(game(true), flatpak("org.libretro.RetroArch", "RetroArch")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher?.name : null, "1080 Snowboarding"); +}); + +test("Flatpak runtime wins over a native Steam game", () => { + assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "flatpak"); +}); + +test("direct Flatpak launch creates a Flatpak Now Playing target", () => { + const target = resolveNowPlayingTarget(null, flatpak("org.example.Game")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher : null, null); +}); + +test("configured Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(false), null); + + assert.equal(target?.kind, "steam"); +}); + +test("unconfigured Steam target has no Now Playing controls", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), null), null); +}); diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py index 8d58413..2b9933b 100644 --- a/tests/test_flatpak_profile_service.py +++ b/tests/test_flatpak_profile_service.py @@ -14,6 +14,7 @@ sys.modules.setdefault( sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) from lsfg_vk.configuration import ConfigurationService +from lsfg_vk.flatpak_service import FlatpakService from lsfg_vk.flatpak_profile_service import FlatpakProfileService @@ -26,6 +27,7 @@ class FakeFlatpakService: self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}} self.commands = [] self.running = "" + self.start_times = {} def _read_state(self): return self.state @@ -49,6 +51,9 @@ class FakeFlatpakService: return False, b"" return True, path.read_bytes() + def _process_start_time(self, pid): + return self.start_times.get(pid) + @staticmethod def _write_file(path, content, mode=0o644): path.parent.mkdir(parents=True, exist_ok=True) @@ -175,6 +180,17 @@ class FlatpakProfileServiceTests(unittest.TestCase): self.assertNotIn("--env=DXVK_HDR=0", command) self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command) + def test_config_update_returns_without_relisting_flatpaks(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.service.get_app = Mock(side_effect=AssertionError("config updates must not relist Flatpaks")) + + result = self.service.update_config(self.app_id, {"multiplier": 4}) + + self.assertTrue(result["success"]) + self.assertEqual(result["app_id"], self.app_id) + self.assertEqual(result["config"]["multiplier"], 4) + self.service.get_app.assert_not_called() + def test_remove_restores_exact_original_override_and_profile(self): baseline = "[Environment]\nKEEP=yes\n" self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) @@ -199,11 +215,18 @@ class FlatpakProfileServiceTests(unittest.TestCase): def test_running_detection_uses_owned_selector_state(self): self.assertTrue(self.service.enable_app(self.app_id)["success"]) self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n" + self.flatpak.start_times["1234"] = 200 result = self.service.get_running_apps() self.assertTrue(result["success"]) - self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234"}]) + self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234", "start_time": 200}]) + + def test_process_start_time_parser_handles_parentheses_in_command_name(self): + fields = ["S"] + ["0"] * 18 + ["4242"] + stat = "1234 (retro)arch) " + " ".join(fields) + + self.assertEqual(FlatpakService._parse_process_start_time(stat), 4242) if __name__ == "__main__": diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 4d804c7..17d0016 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -149,6 +149,12 @@ class FlatpakServiceTests(unittest.TestCase): self.assertEqual(runtime, self.runtime_ref) self.assertEqual(branch, "25.08") + def test_clean_env_targets_deck_user_session_bus(self): + env = self.service._clean_env() + user_id = self.home.stat().st_uid + self.assertEqual(env["XDG_RUNTIME_DIR"], f"/run/user/{user_id}") + self.assertEqual(env["DBUS_SESSION_BUS_ADDRESS"], f"unix:path=/run/user/{user_id}/bus") + def test_prepare_app_installs_runtime_and_persists_narrow_override(self): response = self.service.prepare_app("com.example.Game") -- cgit v1.2.3 From 18af8d1de5c7d12af674018ce819cc91480cf51a Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 09:09:29 -0400 Subject: fix tab change jutter --- src/components/ConfigurationTab.tsx | 1 - src/components/Content.tsx | 30 +++++++++++------------------- src/styles.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index a6d4c2a..12f36a5 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -88,7 +88,6 @@ export function ConfigurationTab({ diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 0d49f89..dc8c54f 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,5 +1,5 @@ -import { Field, PanelSection, PanelSectionRow, Tabs } from "@decky/ui"; -import { useEffect, useRef, useState } from "react"; +import { Tabs } from "@decky/ui"; +import { useEffect, useRef, useState, type FocusEvent } from "react"; import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; @@ -79,6 +79,7 @@ export function Content() { const flatpak = useFlatpakConfiguration(setupComplete); const [tab, setTab] = useState("Setup"); const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); + const [contentFocused, setContentFocused] = useState(false); const previousRunningWorkload = useRef(null); const runningFlatpak = flatpak.runningApp; const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak); @@ -154,13 +155,11 @@ export function Content() { launcher={nowPlayingTarget.launcher} onConfigChange={flatpak.updateConfig} /> - ) : ( - - ); + ) : null; const tabs = setupComplete ? [ - { id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }, + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []), { id: "Games", title: tabIcons.games, @@ -205,11 +204,16 @@ export function Content() { const availableTabIds = new Set(tabs.map(({ id }) => id)); const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup"; + const handleFocusCapture = (event: FocusEvent) => { + const focusedElement = event.target as HTMLElement | null; + setContentFocused(!focusedElement?.closest?.('[role="tab"]')); + }; return (
); } - -function NowPlayingTabPlaceholder() { - return ( -
- - - - - -
- ); -} diff --git a/src/styles.ts b/src/styles.ts index 1bc089b..d197451 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -31,4 +31,17 @@ export const tabStyles = ` display: block; margin: 0; } + + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"], + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div, + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div, + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] [role="tab"] { + animation: none !important; + transition: none !important; + } + + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div { + scroll-behavior: auto !important; + scroll-snap-type: none !important; + } `; -- cgit v1.2.3 From 9138886fd5315eb0e8c6a4a549a9d44f93ba3cde Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 09:30:30 -0400 Subject: consistent tab collapsed toggles on flatpak --- scripts/deploy-to-deck.sh | 30 ++++++++++++++++++++++++++++++ src/components/FlatpakTab.tsx | 7 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/scripts/deploy-to-deck.sh b/scripts/deploy-to-deck.sh index b5cb4f7..f27fe4e 100755 --- a/scripts/deploy-to-deck.sh +++ b/scripts/deploy-to-deck.sh @@ -5,6 +5,36 @@ deck_host="deck@192.168.0.241" plugin_root="Decky LSFG-VK" install_path="/home/deck/homebrew/plugins/Decky LSFG-VK" +if [[ -z ${DEPLOY_EXPECT:-} && -f .env ]]; then + set -a + source .env + set +a +fi + +if [[ -n ${DECK_PASSWORD:-} && -z ${DEPLOY_EXPECT:-} ]]; then + export DEPLOY_EXPECT=1 + export DEPLOY_SCRIPT="$0" + exec expect <<'EXPECT' +set timeout -1 +spawn bash $env(DEPLOY_SCRIPT) + +expect { + -re {(?i)yes/no} { + send -- "yes\r" + exp_continue + } + -re {(?i)(password|passphrase).*:} { + send -- "$env(DECK_PASSWORD)\r" + exp_continue + } + eof +} + +catch wait result +exit [lindex $result 3] +EXPECT +fi + package_dir="$(mktemp -d "${TMPDIR:-/tmp}/decky-plugin-package.XXXXXX")" run_id="$(basename "$package_dir")" remote_archive="/tmp/decky-lsfg-vk-${run_id}.zip" diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx index 31bfc2d..b46eb28 100644 --- a/src/components/FlatpakTab.tsx +++ b/src/components/FlatpakTab.tsx @@ -20,6 +20,9 @@ interface Props { onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise; } +const ENABLED_COLLAPSED_KEY = "lsfg-flatpak-enabled-collapsed-v2"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-flatpak-available-collapsed-v2"; + function usePersistentCollapsed(key: string) { const [collapsed, setCollapsed] = useState(() => { try { @@ -55,8 +58,8 @@ export function FlatpakTab({ [apps, selectedAppId], ); const close = useCallback(() => setSelectedAppId(null), []); - const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed("lsfg-flatpak-enabled-collapsed-v1"); - const [availableCollapsed, toggleAvailable] = usePersistentCollapsed("lsfg-flatpak-available-collapsed-v1"); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); const enabledToggleRef = useRef(null); const enabledApps = useMemo( -- 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 ------ src/components/CollapsibleItemGroup.tsx | 20 ++++++++++++++++- src/components/Content.tsx | 2 +- src/components/FlatpakTab.tsx | 24 ++------------------- src/components/GameConfigurationSelector.tsx | 22 ++----------------- src/hooks/useFlatpakConfiguration.ts | 4 +--- src/utils/nowPlaying.ts | 31 +++++++++++++++++---------- tests/nowPlaying.test.ts | 25 +++++++++++++++++++-- 8 files changed, 68 insertions(+), 66 deletions(-) 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": []} diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx index a66a8ba..037b34f 100644 --- a/src/components/CollapsibleItemGroup.tsx +++ b/src/components/CollapsibleItemGroup.tsx @@ -1,5 +1,5 @@ import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; -import { type RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; export interface CollapsibleItem { @@ -25,6 +25,24 @@ export const collapsibleItemGroupStyles = ` } `; +export function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch {} + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + interface Props { title: string; items: CollapsibleItem[]; diff --git a/src/components/Content.tsx b/src/components/Content.tsx index dc8c54f..422eec8 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -86,7 +86,7 @@ export function Content() { const hasNowPlaying = Boolean(nowPlayingTarget); const runningWorkload = nowPlayingTarget ? nowPlayingTarget.kind === "flatpak" - ? `flatpak:${nowPlayingTarget.app.app_id}` + ? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}` : `steam:${nowPlayingTarget.game.appid}` : null; diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx index b46eb28..0b20da0 100644 --- a/src/components/FlatpakTab.tsx +++ b/src/components/FlatpakTab.tsx @@ -1,8 +1,8 @@ import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; -import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; import { ConfigurationSection } from "./ConfigurationSection"; import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; @@ -23,24 +23,6 @@ interface Props { const ENABLED_COLLAPSED_KEY = "lsfg-flatpak-enabled-collapsed-v2"; const AVAILABLE_COLLAPSED_KEY = "lsfg-flatpak-available-collapsed-v2"; -function usePersistentCollapsed(key: string) { - const [collapsed, setCollapsed] = useState(() => { - try { - return localStorage.getItem(key) !== "false"; - } catch { - return true; - } - }); - - useEffect(() => { - try { - localStorage.setItem(key, String(collapsed)); - } catch {} - }, [collapsed, key]); - - return [collapsed, () => setCollapsed((value) => !value)] as const; -} - export function FlatpakTab({ apps, runningApp, @@ -60,7 +42,6 @@ export function FlatpakTab({ const close = useCallback(() => setSelectedAppId(null), []); const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); - const enabledToggleRef = useRef(null); const enabledApps = useMemo( () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), @@ -90,7 +71,6 @@ export function FlatpakTab({ collapsed={enabledCollapsed} onToggle={toggleEnabled} onSelect={setSelectedAppId} - toggleRef={enabledToggleRef} /> { - try { - return localStorage.getItem(key) !== "false"; - } catch { - return true; - } - }); - - useEffect(() => { - try { - localStorage.setItem(key, String(collapsed)); - } catch {} - }, [collapsed, key]); - - return [collapsed, () => setCollapsed((value) => !value)] as const; -} - function targetDescription(game: GameTarget): string { return game.nonSteam ? "Non-Steam" : "Steam"; } diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts index ce881f0..6ff7a79 100644 --- a/src/hooks/useFlatpakConfiguration.ts +++ b/src/hooks/useFlatpakConfiguration.ts @@ -120,9 +120,7 @@ export function useFlatpakConfiguration(enabled: boolean) { [operate], ); - const runningApp = useMemo(() => { - return selectMostRecentRunningFlatpak(apps, runningApps); - }, [apps, runningApps]); + const runningApp = useMemo(() => selectMostRecentRunningFlatpak(apps, runningApps), [apps, runningApps]); return { apps, diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts index a207321..2e0a876 100644 --- a/src/utils/nowPlaying.ts +++ b/src/utils/nowPlaying.ts @@ -29,25 +29,34 @@ export function selectMostRecentRunningFlatpak( running, app: apps.find((app) => app.app_id === running.app_id) || null, })) - .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null) - .sort((a, b) => { - if (a.running.active !== b.running.active) return a.running.active ? -1 : 1; - const startDifference = numericValue(b.running.start_time) - numericValue(a.running.start_time); - if (startDifference !== 0) return startDifference; - const pidDifference = numericPid(b.running.pid) - numericPid(a.running.pid); - if (pidDifference !== 0) return pidDifference; - return a.running.app_id.localeCompare(b.running.app_id); - }); + .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null); + const activeCandidates = candidates.filter(({ running }) => running.active); + const eligibleCandidates = activeCandidates.length > 0 + ? activeCandidates + : candidates.length === 1 + ? candidates + : []; - return candidates[0]?.app || null; + eligibleCandidates.sort((a, b) => { + const startDifference = numericValue(b.running.start_time) - numericValue(a.running.start_time); + if (startDifference !== 0) return startDifference; + const pidDifference = numericPid(b.running.pid) - numericPid(a.running.pid); + if (pidDifference !== 0) return pidDifference; + return a.running.app_id.localeCompare(b.running.app_id); + }); + + return eligibleCandidates[0]?.app || null; } export function resolveNowPlayingTarget( runningGame: GameTarget | null, runningFlatpak: FlatpakApp | null, ): NowPlayingTarget | null { + if (runningGame && !runningGame.nonSteam) { + return runningGame.configured ? { kind: "steam", game: runningGame } : null; + } if (runningFlatpak) { - return { kind: "flatpak", app: runningFlatpak, launcher: runningGame }; + return { kind: "flatpak", app: runningFlatpak, launcher: runningGame?.nonSteam ? runningGame : null }; } if (runningGame?.configured) return { kind: "steam", game: runningGame }; return null; diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts index 3f0d891..8a52b67 100644 --- a/tests/nowPlaying.test.ts +++ b/tests/nowPlaying.test.ts @@ -54,8 +54,12 @@ test("Flatpak runtime wins while a Steam shortcut is running", () => { assert.equal(target?.kind === "flatpak" ? target.launcher?.name : null, "1080 Snowboarding"); }); -test("Flatpak runtime wins over a native Steam game", () => { - assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "flatpak"); +test("native Steam game wins over an unrelated Flatpak", () => { + assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "steam"); +}); + +test("unconfigured native Steam game blocks unrelated Flatpak Now Playing", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), flatpak("org.example.Game")), null); }); test("direct Flatpak launch creates a Flatpak Now Playing target", () => { @@ -65,6 +69,23 @@ test("direct Flatpak launch creates a Flatpak Now Playing target", () => { assert.equal(target?.kind === "flatpak" ? target.launcher : null, null); }); +test("multiple inactive Flatpaks do not create an arbitrary Now Playing target", () => { + const apps = [flatpak("org.example.one"), flatpak("org.example.two")]; + const running = [ + { app_id: "org.example.one", active: false, pid: "100", start_time: 500 }, + { app_id: "org.example.two", active: false, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running), null); +}); + +test("one inactive Flatpak remains a usable fallback", () => { + const apps = [flatpak("org.example.one")]; + const running = [{ app_id: "org.example.one", active: false, pid: "100", start_time: 500 }]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.one"); +}); + test("configured Steam target remains the fallback", () => { const target = resolveNowPlayingTarget(game(false), null); -- cgit v1.2.3 From bc2669b6050da7acdf9e1dda2b77444734852559 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 10:00:19 -0400 Subject: workaround for new client update, ui cuts off in game --- src/components/Content.tsx | 22 +++++++++++++--------- src/styles.ts | 4 ++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 422eec8..32e9bf8 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,5 +1,5 @@ import { Tabs } from "@decky/ui"; -import { useEffect, useRef, useState, type FocusEvent } from "react"; +import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; @@ -141,6 +141,10 @@ export function Content() { /> ); + const tabContent = (content: ReactNode) => ( +
{content}
+ ); + const nowPlaying = nowPlayingTarget?.kind === "steam" ? ( + />, ), }, { id: "Flatpak", title: tabIcons.flatpak, - content: ( + content: tabContent( + />, ), }, - ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: }] : []), - { id: "Setup", title: tabIcons.setup, content: setup }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent() }] : []), + { id: "Setup", title: tabIcons.setup, content: tabContent(setup) }, ] - : [{ id: "Setup", title: tabIcons.setup, content: setup }]; + : [{ id: "Setup", title: tabIcons.setup, content: tabContent(setup) }]; const availableTabIds = new Set(tabs.map(({ id }) => id)); const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup"; diff --git a/src/styles.ts b/src/styles.ts index d197451..58c6b01 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -10,6 +10,10 @@ export const tabStyles = ` padding-right: 8px !important; } + .lsfg-vk-tabs .lsfg-vk-tab-content { + padding-bottom: 96px; // workaround for in-game bottom bar padding behaving differently than in launcher, remove later? + } + .lsfg-vk-tabs [role="tablist"] { display: flex; flex-wrap: nowrap; -- cgit v1.2.3 From 0be6ccdd1a8ecfe386281451cf4c49d052f6dda4 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 10:13:22 -0400 Subject: move inline styping --- src/components/CollapsibleItemGroup.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx index 037b34f..29fe945 100644 --- a/src/components/CollapsibleItemGroup.tsx +++ b/src/components/CollapsibleItemGroup.tsx @@ -9,6 +9,11 @@ export interface CollapsibleItem { } export const collapsibleItemGroupStyles = ` + .LSFG_GameGroupCollapseButton_Container { + margin-top: -2px; + margin-bottom: 4px; + } + .LSFG_GameGroupCollapseButton_Container > div > div > div > button, .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { height: 24px !important; @@ -71,7 +76,6 @@ export function CollapsibleItemGroup({
Date: Fri, 11 Sep 2026 10:23:23 -0400 Subject: compact now playing info --- src/components/FlatpakNowPlayingTab.tsx | 27 +++++++++++---------------- src/components/NowPlayingSummary.tsx | 16 ++++++++++++++++ src/components/NowPlayingTab.tsx | 12 ++++++------ 3 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 src/components/NowPlayingSummary.tsx diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx index 9623b11..89f7f49 100644 --- a/src/components/FlatpakNowPlayingTab.tsx +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -1,8 +1,9 @@ -import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import { Focusable } from "@decky/ui"; import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi"; import type { GameTarget } from "../hooks/useGameConfiguration"; import { ConfigurationSection } from "./ConfigurationSection"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { NowPlayingSummary } from "./NowPlayingSummary"; interface Props { app: FlatpakApp; @@ -21,21 +22,15 @@ export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) { return ( - - - - - {launcher && ( - - - - )} - + detail !== null)} + /> diff --git a/src/components/NowPlayingSummary.tsx b/src/components/NowPlayingSummary.tsx new file mode 100644 index 0000000..adc5d10 --- /dev/null +++ b/src/components/NowPlayingSummary.tsx @@ -0,0 +1,16 @@ +import { Field, PanelSection, PanelSectionRow } from "@decky/ui"; + +interface Props { + title: string; + details: string[]; +} + +export function NowPlayingSummary({ title, details }: Props) { + return ( + + + + + + ); +} diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index c067dc0..4c22fb7 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,7 +1,8 @@ -import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import { Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; +import { NowPlayingSummary } from "./NowPlayingSummary"; interface Props { game: GameTarget; @@ -23,11 +24,10 @@ export function NowPlayingTab({ }: Props) { return ( - - - - - + 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 +++++++++++++++++++++++++++++ src/api/lsfgApi.ts | 19 ++++++++ src/components/ConfigurationSection.tsx | 5 +- src/components/ConfigurationTab.tsx | 15 +----- src/components/Content.tsx | 13 ++++-- src/components/SetupTab.tsx | 82 +++++++++++++++++++++++---------- src/hooks/useGameConfiguration.ts | 46 +++++++++++++++++- src/hooks/useLsfgHooks.ts | 9 +++- src/hooks/usePerAppWorkarounds.ts | 2 + tests/test_configuration_profiles.py | 19 ++++++++ tests/test_installation_cleanup.py | 63 +++++++++++++++++++++++++ tests/test_plugin_migration.py | 8 +++- tests/test_wrapper_service.py | 29 ++++++++++++ 16 files changed, 406 insertions(+), 73 deletions(-) create mode 100644 tests/test_installation_cleanup.py 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, + } diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index f3b7fa1..e050f62 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -64,6 +64,18 @@ export interface WorkaroundStateResult extends ApiResult { wrapper_path?: string; wrapper_owned?: boolean; command_token_added?: boolean; + non_steam?: boolean; +} + +export interface WorkaroundApp { + appid: string; + non_steam: boolean; + command_token_added: boolean; +} + +export interface WorkaroundAppsResult extends ApiResult { + apps?: WorkaroundApp[]; + wrapper_path?: string; } export interface GameConfigsResult extends ApiResult { @@ -71,6 +83,10 @@ export interface GameConfigsResult extends ApiResult { games?: GameConfigEntry[]; } +export interface GlobalConfigResult extends ApiResult { + global_config?: GlobalConfig; +} + export interface GameConfigResult extends ApiResult { appid?: string; exists?: boolean; @@ -149,11 +165,14 @@ export const getInstalledGames = callable<[], InstalledGamesResult>("get_install export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); +export const updateGlobalConfig = callable<[GlobalConfig], GlobalConfigResult>("update_global_config"); export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state"); export const setWorkaroundState = callable<[ string, WorkaroundState, boolean, + boolean, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); +export const getWorkaroundApps = callable<[], WorkaroundAppsResult>("get_workaround_apps"); export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents"); diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index 1f17264..6996bcd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,6 +1,6 @@ import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema"; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; @@ -12,9 +12,6 @@ export function ConfigurationSection({ config, onConfigChange }: ConfigurationSe onConfigChange(FLOW_SCALE, value)} /> - - onConfigChange(NO_FP16, !value)} /> - onConfigChange(PERFORMANCE_MODE, value)} /> diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 12f36a5..37a7867 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, ToggleField, gamepadDialogClasses, showModal } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; @@ -11,8 +11,6 @@ interface ConfigurationTabProps { config: ConfigurationData; targets: GameTarget[]; runningGame: GameTarget | null; - showDebugTab: boolean; - onShowDebugTabChange: (value: boolean) => void; onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; onEnable: (appid: string) => Promise; @@ -26,8 +24,6 @@ export function ConfigurationTab({ config, targets, runningGame, - showDebugTab, - onShowDebugTabChange, onSelect, onConfigChange, onEnable, @@ -84,15 +80,6 @@ export function ConfigurationTab({ onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} /> - - - - - ); } diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 32e9bf8..91775df 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -47,16 +47,19 @@ export function Content() { const { config, runningConfig, + globalConfig, targets, runningGame, setSelectedAppId, save, saveFor, + updateGlobal, enable, enableAll, repair, resetSelected, resetAll, + cleanupAllWorkarounds, reload, } = useGameConfiguration(); const { @@ -69,7 +72,7 @@ export function Content() { isUninstalling, install, uninstall, - } = useInstallation(reload); + } = useInstallation(reload, cleanupAllWorkarounds); const setupComplete = isInstalled && losslessScalingInstalled && @@ -78,7 +81,7 @@ export function Content() { !steamBranchStatus.needs_switch; const flatpak = useFlatpakConfiguration(setupComplete); const [tab, setTab] = useState("Setup"); - const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false); const [contentFocused, setContentFocused] = useState(false); const previousRunningWorkload = useRef(null); const runningFlatpak = flatpak.runningApp; @@ -136,6 +139,10 @@ export function Content() { steamBranchStatus={steamBranchStatus} isInstalling={isInstalling} isUninstalling={isUninstalling} + globalConfig={globalConfig} + showDebugTab={showDebugTab} + onGlobalConfigChange={updateGlobal} + onShowDebugTabChange={setShowDebugTab} onInstall={() => void install()} onUninstall={() => void uninstall()} /> @@ -172,8 +179,6 @@ export function Content() { config={config} targets={targets} runningGame={runningGame} - showDebugTab={showDebugTab} - onShowDebugTabChange={setShowDebugTab} onSelect={setSelectedAppId} onConfigChange={(field, value) => handleConfigChange(field, value, true)} onEnable={enable} diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index d769200..ff072cb 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,5 +1,5 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; -import { type SteamBranchStatus } from "../api/lsfgApi"; +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; import t from "../i18n/i18n"; interface SetupTabProps { @@ -10,6 +10,10 @@ interface SetupTabProps { steamBranchStatus: SteamBranchStatus | null; isInstalling: boolean; isUninstalling: boolean; + globalConfig: GlobalConfig; + showDebugTab: boolean; + onGlobalConfigChange: (config: GlobalConfig) => Promise; + onShowDebugTabChange: (value: boolean) => void; onInstall: () => void; onUninstall: () => void; } @@ -23,6 +27,10 @@ export function SetupTab(props: SetupTabProps) { steamBranchStatus, isInstalling, isUninstalling, + globalConfig, + showDebugTab, + onGlobalConfigChange, + onShowDebugTabChange, onInstall, onUninstall, } = props; @@ -36,33 +44,57 @@ export function SetupTab(props: SetupTabProps) { : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); return ( - - - - - - - - {steamBranchStatus?.installed && ( + <> + + + + + {steamBranchStatus?.installed && ( + + + + )} + + + {buttonLabel} + + + + {isInstalled && ( + <> + + + void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} + /> + + + + + + + + )} - - - {buttonLabel} - - - + ); } diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 2e39f5e..fd8dfb1 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; @@ -153,6 +153,7 @@ export function useGameConfiguration() { target.appid, state, integration.commandTokenAdded, + target.nonSteam, ); if (!saved.success) throw new Error(saved.error || "Could not save workaround state"); return true; @@ -205,6 +206,40 @@ export function useGameConfiguration() { } }, [installedGames]); + const cleanupAllWorkarounds = useCallback(async (): Promise => { + try { + const result = await getWorkaroundApps(); + if (!result.success) throw new Error(result.error || "Could not read workaround state"); + const targetsByAppId = new Map(targets.map((target) => [target.appid, target])); + const cleaned = new Set(); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + + for (const entry of result.apps || []) { + const target = targetsByAppId.get(entry.appid); + const nonSteam = target?.nonSteam ?? entry.non_steam; + await removeWrapperIntegration( + Number(entry.appid), + nonSteam, + wrapperPath, + entry.command_token_added, + ); + const removed = await removeWorkaroundState(entry.appid); + if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); + cleaned.add(entry.appid); + } + + // Also clean configured targets whose sidecar entry was lost. This + // removes an old wrapper and only the plugin-managed launch pieces. + for (const target of targets.filter((item) => item.configured && installedGames.some((game) => game.appid === item.appid))) { + if (!cleaned.has(target.appid) && !(await removeTargetWorkarounds(target))) return false; + } + return true; + } catch (error) { + showErrorToast("Could not clean up game launch options", asError(error).message); + return false; + } + }, [installedGames, removeTargetWorkarounds, targets]); + const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; @@ -222,6 +257,13 @@ export function useGameConfiguration() { [saveFor, selectedAppId], ); + const updateGlobal = useCallback(async (next: GlobalConfig): Promise => { + const result = await saveGlobalConfig(next); + if (!result.success) return false; + setGlobalConfig(result.global_config || next); + return true; + }, []); + const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; @@ -283,5 +325,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, enable, enableAll, repair, resetSelected, resetAll, reload: load }; + return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, cleanupAllWorkarounds, reload: load }; } diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index 9beb749..0f51e90 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -13,7 +13,10 @@ import { showUninstallSuccessToast, } from "../utils/toastUtils"; -export function useInstallation(reloadConfig?: () => Promise) { +export function useInstallation( + reloadConfig?: () => Promise, + beforeUninstall?: () => Promise, +) { const [isInstalled, setIsInstalled] = useState(false); const [installationStatus, setInstallationStatus] = useState(""); const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); @@ -77,6 +80,10 @@ export function useInstallation(reloadConfig?: () => Promise) { setIsUninstalling(true); setInstallationStatus("Uninstalling lsfg-vk..."); try { + if (beforeUninstall && !(await beforeUninstall())) { + setInstallationStatus("Uninstallation cancelled: could not clean up launch options"); + return; + } const result = await uninstallLsfgVk(); if (!result.success) { setInstallationStatus(`Uninstallation failed: ${result.error}`); diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index c2b8904..ab33bb2 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -86,6 +86,7 @@ async function adoptWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, integration.commandTokenAdded, + nonSteam, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); return makeSnapshot(integration.snapshot, finalized, nonSteam); @@ -206,6 +207,7 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo appId, nextState, current.commandTokenAdded, + nonSteam, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); applySnapshot({ diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py index 789842e..64db0f1 100644 --- a/tests/test_configuration_profiles.py +++ b/tests/test_configuration_profiles.py @@ -75,6 +75,25 @@ preserve_swapchain_image_count = false self.assertIn("Steam Game", data["profiles"]) self.assertNotIn("flatpak:org.example.Game", data["profiles"]) + def test_global_config_update_does_not_change_profile_values(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + + result = self.service.update_global_config({"no_fp16": True}) + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertTrue(result["global_config"]["no_fp16"]) + self.assertTrue(data["global_config"]["no_fp16"]) + self.assertEqual(data["profiles"]["Steam Game"]["multiplier"], 2) + + def test_profile_update_cannot_overwrite_global_fp16_setting(self): + self.service.update_global_config({"no_fp16": True}) + + self.service.update_game_config("123", "Steam Game", {"multiplier": 3, "no_fp16": False}) + + data = self.service._get_profile_data() + self.assertTrue(data["global_config"]["no_fp16"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_installation_cleanup.py b/tests/test_installation_cleanup.py new file mode 100644 index 0000000..3336505 --- /dev/null +++ b/tests/test_installation_cleanup.py @@ -0,0 +1,63 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.base_service import BaseService +from lsfg_vk.installation import InstallationService + + +class InstallationCleanupTests(unittest.TestCase): + def test_uninstall_removes_legacy_files_and_prunes_only_empty_directories(self): + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" / "deck" + service = InstallationService.__new__(InstallationService) + BaseService.__init__(service) + service.log = Mock() + service.user_home = home + service.local_bin_dir = home / ".local/bin" + service.local_lib_dir = home / ".local/lib" + service.local_share_dir = home / ".local/share/vulkan/implicit_layer.d" + service.config_dir = home / ".config/lsfg-vk" + service.config_file_path = service.config_dir / "conf.toml" + service.legacy_script_path = home / "lsfg" + service.lib_file = service.local_lib_dir / "liblsfg-vk-layer.so" + service.lib_x86_file = service.local_lib_dir / "liblsfg-vk-layer.x86.so" + service.json_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.json" + service.json_x86_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.x86.json" + service.cli_file = service.local_bin_dir / "lsfg-vk-cli" + service.legacy_lib_file = service.local_lib_dir / "liblsfg-vk.so" + service.legacy_json_file = service.local_share_dir / "VkLayer_LS_frame_generation.json" + + for path in ( + service.lib_file, + service.config_file_path, + service.legacy_script_path, + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("owned", encoding="utf-8") + unrelated = home / ".local/bin/keep-me" + unrelated.parent.mkdir(parents=True, exist_ok=True) + unrelated.write_text("user file", encoding="utf-8") + + result = service.uninstall() + + self.assertTrue(result["success"]) + self.assertFalse(service.lib_file.exists()) + self.assertFalse(service.config_file_path.exists()) + self.assertFalse(service.legacy_script_path.exists()) + self.assertTrue(unrelated.exists()) + self.assertTrue(service.local_bin_dir.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py index 7ab4621..1a7cb29 100644 --- a/tests/test_plugin_migration.py +++ b/tests/test_plugin_migration.py @@ -63,12 +63,16 @@ class PluginMigrationTests(unittest.TestCase): plugin.installation_service = Mock() plugin.flatpak_service = Mock() plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} + plugin.configuration_service.reset_all_flatpak_configs.return_value = {"success": True} + plugin.wrapper_service.purge.return_value = {"success": True} asyncio.run(plugin._uninstall()) plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() + plugin.wrapper_service.purge.assert_called_once_with() plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() finally: self._restore(previous_decky, previous_tomllib, previous_plugin) @@ -80,12 +84,14 @@ class PluginMigrationTests(unittest.TestCase): plugin.installation_service = Mock() plugin.flatpak_service = Mock() plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"} asyncio.run(plugin._uninstall()) plugin.configuration_service.reset_all_flatpak_configs.assert_not_called() - plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_not_called() finally: self._restore(previous_decky, previous_tomllib, previous_plugin) diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py index 5c291c7..20a08ff 100644 --- a/tests/test_wrapper_service.py +++ b/tests/test_wrapper_service.py @@ -158,6 +158,35 @@ class WrapperServiceTests(unittest.TestCase): ) self.assertEqual(result.stdout, "ok") + def test_purge_removes_owned_wrapper_and_state(self): + self.service.set("123", self._state(), non_steam=True) + self.assertTrue(self.service.get("123")["non_steam"]) + self.assertEqual(self.service.list_apps()["apps"][0]["non_steam"], True) + response = self.service.purge() + self.assertTrue(response["success"]) + self.assertEqual(response["removed_files"], [str(self.service.wrapper_path), str(self.service.sidecar_path)]) + self.assertFalse(self.service.wrapper_path.exists()) + self.assertFalse(self.service.sidecar_path.exists()) + + def test_purge_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertTrue(self.service.wrapper_path.exists()) + + def test_purge_refuses_invalid_state(self): + self.service.config_dir.mkdir(parents=True, exist_ok=True) + self.service.sidecar_path.write_text("not json", encoding="utf-8") + self.service.wrapper_path.write_text( + f"#!/bin/sh\n{self.service.MARKER}\nexec \"$@\"\n", + encoding="utf-8", + ) + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertTrue(self.service.wrapper_path.exists()) + self.assertTrue(self.service.sidecar_path.exists()) + if __name__ == "__main__": unittest.main() -- cgit v1.2.3 From 4b7852b153106a487ad8fac654ea59f00aa8a8e4 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 11:40:53 -0400 Subject: handle multiple flatpak actives --- src/utils/nowPlaying.ts | 23 ++++++++++++++++++----- tests/nowPlaying.test.ts | 10 ++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts index 2e0a876..380c9f5 100644 --- a/src/utils/nowPlaying.ts +++ b/src/utils/nowPlaying.ts @@ -20,11 +20,26 @@ function numericPid(value: string | undefined): number { return value && /^\d+$/.test(value) ? Number(value) : -1; } +function compareRunningProcesses(a: RunningFlatpakApp, b: RunningFlatpakApp): number { + if (a.active !== b.active) return a.active ? -1 : 1; + const startDifference = numericValue(b.start_time) - numericValue(a.start_time); + if (startDifference !== 0) return startDifference; + return numericPid(b.pid) - numericPid(a.pid); +} + export function selectMostRecentRunningFlatpak( apps: FlatpakApp[], runningApps: RunningFlatpakApp[], ): FlatpakApp | null { - const candidates = runningApps + const newestProcessByApp = new Map(); + for (const running of runningApps) { + const current = newestProcessByApp.get(running.app_id); + if (!current || compareRunningProcesses(running, current) < 0) { + newestProcessByApp.set(running.app_id, running); + } + } + + const candidates = Array.from(newestProcessByApp.values()) .map((running) => ({ running, app: apps.find((app) => app.app_id === running.app_id) || null, @@ -38,10 +53,8 @@ export function selectMostRecentRunningFlatpak( : []; eligibleCandidates.sort((a, b) => { - const startDifference = numericValue(b.running.start_time) - numericValue(a.running.start_time); - if (startDifference !== 0) return startDifference; - const pidDifference = numericPid(b.running.pid) - numericPid(a.running.pid); - if (pidDifference !== 0) return pidDifference; + const processDifference = compareRunningProcesses(a.running, b.running); + if (processDifference !== 0) return processDifference; return a.running.app_id.localeCompare(b.running.app_id); }); diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts index 8a52b67..c99ffc4 100644 --- a/tests/nowPlaying.test.ts +++ b/tests/nowPlaying.test.ts @@ -47,6 +47,16 @@ test("prefers active Flatpak status before process age", () => { assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.active"); }); +test("deduplicates multiple process rows for one managed Flatpak", () => { + const apps = [flatpak("com.heroicgameslauncher.hgl")]; + const running = [ + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228081", start_time: null }, + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228116", start_time: null }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "com.heroicgameslauncher.hgl"); +}); + test("Flatpak runtime wins while a Steam shortcut is running", () => { const target = resolveNowPlayingTarget(game(true), flatpak("org.libretro.RetroArch", "RetroArch")); -- 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 +++++++++++++++++++++++++++++++++++ tests/test_plugin_migration.py | 5 +++-- tests/test_wrapper_service.py | 18 ++++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) 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, + } diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py index 1a7cb29..fc51470 100644 --- a/tests/test_plugin_migration.py +++ b/tests/test_plugin_migration.py @@ -66,13 +66,14 @@ class PluginMigrationTests(unittest.TestCase): plugin.wrapper_service = Mock() plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} plugin.configuration_service.reset_all_flatpak_configs.return_value = {"success": True} - plugin.wrapper_service.purge.return_value = {"success": True} + plugin.wrapper_service.neutralize.return_value = {"success": True} asyncio.run(plugin._uninstall()) plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() - plugin.wrapper_service.purge.assert_called_once_with() + plugin.wrapper_service.neutralize.assert_called_once_with() + plugin.wrapper_service.purge.assert_not_called() plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() finally: self._restore(previous_decky, previous_tomllib, previous_plugin) diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py index 20a08ff..46e029b 100644 --- a/tests/test_wrapper_service.py +++ b/tests/test_wrapper_service.py @@ -187,6 +187,24 @@ class WrapperServiceTests(unittest.TestCase): self.assertTrue(self.service.wrapper_path.exists()) self.assertTrue(self.service.sidecar_path.exists()) + def test_neutralize_leaves_dependency_free_passthrough_wrapper(self): + self.service.set("123", self._state()) + response = self.service.neutralize() + self.assertTrue(response["success"]) + self.assertFalse(self.service.sidecar_path.exists()) + self.assertTrue(self.service.wrapper_path.exists()) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertIn(self.service.MARKER, content) + self.assertNotIn("LSFGVK_CONFIG", content) + self.assertEqual(self._run(123, "/usr/bin/printf", "ok").stdout, "ok") + + def test_neutralize_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.neutralize() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + if __name__ == "__main__": unittest.main() -- cgit v1.2.3 From 4d14bc1086a1ba05acd01d7466749ff3cffba75f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 12:58:16 -0400 Subject: enable all and disable all for flatpak --- src/components/Content.tsx | 2 ++ src/components/FlatpakTab.tsx | 53 +++++++++++++++++++++++++++++++++++- src/hooks/useFlatpakConfiguration.ts | 23 ++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 91775df..3f25320 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -200,7 +200,9 @@ export function Content() { busyAppId={flatpak.busyAppId} onRefresh={flatpak.reload} onEnable={flatpak.enableApp} + onEnableAll={flatpak.enableAll} onRemove={flatpak.removeApp} + onRemoveAll={flatpak.removeAll} onConfigChange={flatpak.updateConfig} onWorkaroundChange={flatpak.updateWorkarounds} />, diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx index 0b20da0..35721f6 100644 --- a/src/components/FlatpakTab.tsx +++ b/src/components/FlatpakTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useMemo, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; @@ -15,7 +15,9 @@ interface Props { busyAppId: string; onRefresh: () => Promise; onEnable: (appId: string) => Promise; + onEnableAll: () => Promise; onRemove: (appId: string) => Promise; + onRemoveAll: () => Promise; onConfigChange: (appId: string, config: LsfgConfig) => Promise; onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise; } @@ -30,7 +32,9 @@ export function FlatpakTab({ busyAppId, onRefresh, onEnable, + onEnableAll, onRemove, + onRemoveAll, onConfigChange, onWorkaroundChange, }: Props) { @@ -51,6 +55,33 @@ export function FlatpakTab({ () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), [apps], ); + const enableableApps = useMemo( + () => availableApps.filter((app) => !(app.prepared && !app.owned) && !app.error), + [availableApps], + ); + const confirmEnableAll = () => { + showModal( + void onEnableAll()} + onCancel={() => {}} + />, + ); + }; + const confirmRemoveAll = () => { + showModal( + void onRemoveAll()} + onCancel={() => {}} + />, + ); + }; const itemFor = (app: FlatpakApp) => ({ id: app.app_id, label: app.app_name, @@ -79,6 +110,26 @@ export function FlatpakTab({ onToggle={toggleAvailable} onSelect={setSelectedAppId} /> + {enableableApps.length > 0 && ( + + + Enable all available Flatpaks + + + )} + + + Remove all profiles + + {apps.length === 0 && !loading && ( diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts index 6ff7a79..f936271 100644 --- a/src/hooks/useFlatpakConfiguration.ts +++ b/src/hooks/useFlatpakConfiguration.ts @@ -95,6 +95,27 @@ export function useFlatpakConfiguration(enabled: boolean) { const removeApp = useCallback(async (appId: string) => ( await operate(appId, () => removeFlatpakApp(appId)) ).success, [operate]); + const enableAll = useCallback(async (): Promise => { + if (busyAppId) return; + const available = apps.filter((app) => ( + !app.enabled && !(app.prepared && !app.owned) && !app.error + )); + for (const app of available) { + const result = await operate(app.app_id, () => enableFlatpakApp(app.app_id), false); + if (!result.success) break; + } + await reload(); + await pollRunning(); + }, [apps, busyAppId, operate, pollRunning, reload]); + const removeAll = useCallback(async (): Promise => { + if (busyAppId) return; + for (const app of apps.filter((item) => item.enabled)) { + const result = await operate(app.app_id, () => removeFlatpakApp(app.app_id), false); + if (!result.success) break; + } + await reload(); + await pollRunning(); + }, [apps, busyAppId, operate, pollRunning, reload]); const updateConfig = useCallback( async (appId: string, config: LsfgConfig) => { const result = await operate(appId, () => updateFlatpakConfig(appId, config), false); @@ -130,7 +151,9 @@ export function useFlatpakConfiguration(enabled: boolean) { busyAppId, reload, enableApp, + enableAll, removeApp, + removeAll, updateConfig, updateWorkarounds, }; -- cgit v1.2.3 From d1990a2b630f7161342a9c854bce0fcf7a967a8d Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 13:11:42 -0400 Subject: readme updates and version bump --- README.md | 11 +++++------ package.json | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 13918fd..31e2ad8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Decky LSFG-VK -> **Note:** > This is an **unofficial community plugin**. It is independently developed and **not officially supported** by the creators of Lossless Scaling or lsfg-vk. For support, please use the [decky-lsfg-vk Discord Channel](https://discord.gg/TwvHdVucC3). @@ -14,26 +13,26 @@

-## What is this? - -A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://lsfg-vk.dev/)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. +Decky LSFG-VK is a Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://lsfg-vk.dev/)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. ## Installation 1. **Download the plugin** from the [releases tab](https://github.com/xXJSONDeruloXx/decky-lsfg-vk/releases) - - Download the "decky-lsfg-vk.zip" file to your Steam Deck + - Download the "Decky LSFG-VK.zip" file to your Steam Deck 2. **Install manually through Decky**: - In Game Mode, go to the settings cog in the top right of the Decky Loader tab - Enable "Developer Mode" - Go to "Developer" tab and select "Install Plugin from Zip" - - Select the downloaded "decky-lsfg-vk.zip" file + - Select the downloaded "Decky LSFG-VK.zip" file ## How to Use 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam +2. **Switch Branch** In the Lossless Scaling Steam app, go to Properties > Game Versions & Betas > select "lsfg-vk" branch, and let Steam download the new version 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer 4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings +5. **Configure Flatpak apps** in the Flatpak tab individually or with **Enable all** and **Remove all profiles** 6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile ### Core Settings diff --git a/package.json b/package.json index f1bcda0..59a8aa5 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "decky-lsfg-vk", - "version": "0.12.8", + "version": "0.14.0", "description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer", "type": "module", "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'" + "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts && python3.12 -m unittest discover -s tests -p 'test_*.py'" }, "repository": { "type": "git", -- 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 --- justfile | 3 +- package.json | 2 +- py_modules/lsfg_vk/configuration.py | 27 ++++++ py_modules/lsfg_vk/plugin.py | 3 + src/api/lsfgApi.ts | 1 + src/components/ConfigFileTab.tsx | 4 +- src/components/ConfigurationTab.tsx | 56 +++++++---- src/components/Content.tsx | 98 +++++++++++++------ src/components/FlatpakNowPlayingTab.tsx | 4 +- src/components/GameConfigurationSelector.tsx | 47 +++++---- src/components/NowPlayingTab.tsx | 5 +- src/components/SettingsTab.tsx | 100 +++++++++++++++++++ src/components/SetupTab.tsx | 100 ------------------- src/components/index.ts | 2 +- src/hooks/useGameConfiguration.ts | 139 ++++++++++++++++++--------- src/utils/gameTargets.ts | 75 +++++++++++++++ src/utils/nowPlaying.ts | 18 +++- tests/gameTargets.test.ts | 41 ++++++++ tests/nowPlaying.test.ts | 7 ++ tests/test_configuration_profiles.py | 17 +++- 20 files changed, 525 insertions(+), 224 deletions(-) create mode 100644 src/components/SettingsTab.tsx delete mode 100644 src/components/SetupTab.tsx create mode 100644 src/utils/gameTargets.ts create mode 100644 tests/gameTargets.test.ts diff --git a/justfile b/justfile index c935b76..57636a8 100644 --- a/justfile +++ b/justfile @@ -8,8 +8,7 @@ deploy: ./scripts/deploy-to-deck.sh test: - node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts - python3.12 -m unittest discover -s tests -p 'test_*.py' + pnpm test watch: ssh deck@192.168.0.6 "journalctl -f" diff --git a/package.json b/package.json index 59a8aa5..735f9c4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts && python3.12 -m unittest discover -s tests -p 'test_*.py'" + "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts tests/gameTargets.test.ts && python3.12 -m unittest discover -s tests -p 'test_*.py'" }, "repository": { "type": "git", 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() diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index e050f62..883f56e 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -164,6 +164,7 @@ export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs" export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); +export const resetGameConfigs = callable<[string[]], GameConfigsResult>("reset_game_configs"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); export const updateGlobalConfig = callable<[GlobalConfig], GlobalConfigResult>("update_global_config"); export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state"); diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx index 07408d7..fd6d716 100644 --- a/src/components/ConfigFileTab.tsx +++ b/src/components/ConfigFileTab.tsx @@ -80,7 +80,7 @@ export function ConfigFileTab() { if (!result) { return ( - + @@ -108,7 +108,7 @@ export function ConfigFileTab() { } `} - + {result.error && ( diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 37a7867..37555e0 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,26 +1,36 @@ -import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; import { GameConfigurationControls } from "./GameConfigurationControls"; import { GameConfigurationSelector } from "./GameConfigurationSelector"; import { ProfileDetails } from "./ProfileDetails"; interface ConfigurationTabProps { + title: string; + source: KnownGameSource; config: ConfigurationData; targets: GameTarget[]; runningGame: GameTarget | null; onSelect: (appid: string) => void; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions?: boolean, + ) => Promise; onEnable: (appid: string) => Promise; - onEnableAll: () => Promise; + onEnableAll: (source: KnownGameSource) => Promise; + bulkOperationBusy: boolean; onRepair: (appid: string) => Promise; onReset: () => Promise; - onResetAll: () => Promise; + onResetAll: (source: KnownGameSource) => Promise; } export function ConfigurationTab({ + title, + source, config, targets, runningGame, @@ -28,6 +38,7 @@ export function ConfigurationTab({ onConfigChange, onEnable, onEnableAll, + bulkOperationBusy, onRepair, onReset, onResetAll, @@ -64,10 +75,12 @@ export function ConfigurationTab({ if (detailAppId === null) { return ( <> - + { setFocusConfiguredToggle(false); setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); @@ -85,9 +98,9 @@ export function ConfigurationTab({ } const profileLabel = selectedTarget?.name || "Game profile"; - const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam"; + const profileTransport = selectedTarget ? sourceLabel(selectedTarget.source) : sourceLabel(source); const profileDescription = selectedTarget - ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` + ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}${selectedTarget.source === "unknown" ? " · Bulk actions exclude this profile" : ""}` : "Game is no longer available"; const enableProfile = async (appid: string, quitRunningGame = false) => { if (!(await onEnable(appid))) return; @@ -101,8 +114,8 @@ export function ConfigurationTab({ closeDetails(); } else if (detailAppId) { const isRunningUnconfigured = runningGame?.appid === detailAppId - && runningGame.nonSteam === false - && selectedTarget?.nonSteam === false + && runningGame.source === "steam" + && selectedTarget?.source === "steam" && !runningGame.configured; if (isRunningUnconfigured) { showModal( @@ -128,7 +141,7 @@ export function ConfigurationTab({
{!selectedTarget?.configured && selectedTarget && ( - - Enable for next launch - + {selectedTarget.source === "unknown" ? ( + + ) : ( + + Enable for next launch + + )} )} {selectedTarget?.configured && ( onConfigChange(field, value, selectedTarget?.source !== "unknown")} autoFocusFpsMultiplier={focusFpsMultiplier} onFpsMultiplierFocused={clearFpsFocusRequest} - showWorkarounds - workaroundTarget={selectedTarget || undefined} - onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined} + showWorkarounds={selectedTarget.source !== "unknown"} + workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined} + onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined} /> )} {selectedTarget?.configured && ( diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 3f25320..470db95 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,28 +1,37 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; -import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { FaCube, FaExternalLinkAlt, FaFileAlt, FaGamepad, FaSteam, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallation } from "../hooks/useLsfgHooks"; import { tabStyles } from "../styles"; -import { resolveNowPlayingTarget } from "../utils/nowPlaying"; +import { targetsForSource } from "../utils/gameTargets"; +import { resolveNowPlayingTarget, type NowPlayingTarget } from "../utils/nowPlaying"; import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; import { FlatpakTab } from "./FlatpakTab"; import { NowPlayingTab } from "./NowPlayingTab"; -import { SetupTab } from "./SetupTab"; +import { SettingsTab } from "./SettingsTab"; const tabIcons = { nowPlaying: , - games: , + steam: , + nonSteam: , flatpak: , configFile: , - setup: , + settings: , }; const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1"; +type GameTabId = "Steam" | "NonSteam" | "Flatpak"; + +function tabForNowPlaying(target: NowPlayingTarget | null): GameTabId { + if (!target) return "Steam"; + if (target.kind === "flatpak") return target.launcher?.source === "nonSteam" ? "NonSteam" : "Flatpak"; + return target.game.source === "nonSteam" ? "NonSteam" : "Steam"; +} function usePersistentBoolean(key: string, defaultValue: boolean) { const [value, setValue] = useState(() => { @@ -56,6 +65,7 @@ export function Content() { updateGlobal, enable, enableAll, + bulkOperationBusy, repair, resetSelected, resetAll, @@ -80,34 +90,40 @@ export function Content() { steamBranchStatus.installed && !steamBranchStatus.needs_switch; const flatpak = useFlatpakConfiguration(setupComplete); - const [tab, setTab] = useState("Setup"); + const [tab, setTab] = useState("Settings"); const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false); const [contentFocused, setContentFocused] = useState(false); const previousRunningWorkload = useRef(null); + const previousNowPlayingTab = useRef("Steam"); const runningFlatpak = flatpak.runningApp; const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak); const hasNowPlaying = Boolean(nowPlayingTarget); const runningWorkload = nowPlayingTarget ? nowPlayingTarget.kind === "flatpak" ? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}` - : `steam:${nowPlayingTarget.game.appid}` + : `${nowPlayingTarget.game.source}:${nowPlayingTarget.game.appid}` : null; + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); useEffect(() => { if (!setupComplete) { - setTab("Setup"); + setTab("Settings"); return; } - setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current); + setTab((current) => current === "Settings" ? (hasNowPlaying ? "NowPlaying" : "Steam") : current); }, [hasNowPlaying, setupComplete]); useEffect(() => { if (!setupComplete) return; const previous = previousRunningWorkload.current; previousRunningWorkload.current = runningWorkload; - if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying"); + if (runningWorkload && runningWorkload !== previous) { + previousNowPlayingTab.current = tabForNowPlaying(nowPlayingTarget); + setTab("NowPlaying"); + } else if (!runningWorkload && previous) { - setTab((current) => current === "NowPlaying" ? "Games" : current); + setTab((current) => current === "NowPlaying" ? previousNowPlayingTab.current : current); } }, [runningWorkload, setupComplete]); @@ -119,8 +135,8 @@ export function Content() { }, [isInstalled, reload, flatpak.reload]); useEffect(() => { - if (!showDebugTab && tab === "ConfigFile") setTab("Games"); - }, [showDebugTab, tab]); + if (!showDebugTab && tab === "ConfigFile") setTab(setupComplete ? "Steam" : "Settings"); + }, [setupComplete, showDebugTab, tab]); const handleConfigChange = async ( fieldName: keyof ConfigurationData, @@ -130,8 +146,8 @@ export function Content() { await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); }; - const setup = ( - {content}
); - const nowPlaying = nowPlayingTarget?.kind === "steam" ? ( + const nowPlaying = nowPlayingTarget?.kind === "flatpak" ? ( + + ) : nowPlayingTarget ? ( - ) : nowPlayingTarget?.kind === "flatpak" ? ( - ) : null; const tabs = setupComplete ? [ ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []), { - id: "Games", - title: tabIcons.games, + id: "Steam", + title: tabIcons.steam, content: tabContent( handleConfigChange(field, value, true)} + onConfigChange={handleConfigChange} onEnable={enable} onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} />, ), }, + { + id: "NonSteam", + title: tabIcons.nonSteam, + content: tabContent( + + ), + }, { id: "Flatpak", title: tabIcons.flatpak, @@ -209,12 +249,12 @@ export function Content() { ), }, ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent() }] : []), - { id: "Setup", title: tabIcons.setup, content: tabContent(setup) }, + { id: "Settings", title: tabIcons.settings, content: tabContent(settings) }, ] - : [{ id: "Setup", title: tabIcons.setup, content: tabContent(setup) }]; + : [{ id: "Settings", title: tabIcons.settings, content: tabContent(settings) }]; const availableTabIds = new Set(tabs.map(({ id }) => id)); - const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup"; + const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Steam" : "Settings"; const handleFocusCapture = (event: FocusEvent) => { const focusedElement = event.target as HTMLElement | null; setContentFocused(!focusedElement?.closest?.('[role="tab"]')); diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx index 89f7f49..449e4b5 100644 --- a/src/components/FlatpakNowPlayingTab.tsx +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -1,6 +1,6 @@ import { Focusable } from "@decky/ui"; import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi"; -import type { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget } from "../utils/gameTargets"; import { ConfigurationSection } from "./ConfigurationSection"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; import { NowPlayingSummary } from "./NowPlayingSummary"; @@ -25,7 +25,7 @@ export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) { void; - onEnableAll: () => Promise; - onResetAll: () => Promise; + onEnableAll: (source: KnownGameSource) => Promise; + onResetAll: (source: KnownGameSource) => Promise; focusConfiguredToggle?: boolean; onConfiguredToggleFocused?: () => void; } @@ -17,12 +20,16 @@ const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; function targetDescription(game: GameTarget): string { - return game.nonSteam ? "Non-Steam" : "Steam"; + return game.source === "unknown" + ? "Unknown source · excluded from bulk actions" + : sourceLabel(game.source); } export function GameConfigurationSelector({ targets, runningGame, + source, + bulkOperationBusy, onSelect, onEnableAll, onResetAll, @@ -36,13 +43,19 @@ export function GameConfigurationSelector({ }); const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); + const enableableGames = availableGames.filter((game) => game.source === source); + const removableGames = enabledGames.filter((game) => game.source === source); + const sourceName = source === "nonSteam" ? "non-Steam shortcuts" : "Steam games"; + const emptyDescription = source === "nonSteam" + ? "Steam has not reported any eligible non-Steam shortcuts" + : "Steam has not reported any eligible installed games"; const toItem = (game: GameTarget) => ({ id: game.appid, label: game.name, description: targetDescription(game), }); - const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); - const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(`${ENABLED_COLLAPSED_KEY}-${source}`); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-${source}`); const enabledToggleRef = useRef(null); useEffect(() => { @@ -57,10 +70,10 @@ export function GameConfigurationSelector({ const confirmResetAll = () => { showModal( void onResetAll()} + onOK={() => void onResetAll(source)} onCancel={() => {}} />, ); @@ -69,11 +82,11 @@ export function GameConfigurationSelector({ const confirmEnableAll = () => { showModal( void onEnableAll()} + onOK={() => void onEnableAll(source)} onCancel={() => {}} />, ); @@ -86,7 +99,7 @@ export function GameConfigurationSelector({ {targets.length === 0 && ( - + )} - {availableGames.length > 0 && ( + {enableableGames.length > 0 && ( - - Enable all available games + + {`Enable all ${sourceName}`} )} @@ -115,9 +128,9 @@ export function GameConfigurationSelector({ target.configured)} + disabled={bulkOperationBusy || removableGames.length === 0} > - Remove all profiles + {`Remove all ${sourceLabel(source)} profiles`}
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 4c22fb7..69e72d7 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,6 +1,7 @@ import { Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; import { GameConfigurationControls } from "./GameConfigurationControls"; import { NowPlayingSummary } from "./NowPlayingSummary"; @@ -14,7 +15,7 @@ interface Props { } function targetDescription(game: GameTarget): string { - return game.nonSteam ? "Non-Steam" : "Steam"; + return sourceLabel(game.source); } export function NowPlayingTab({ diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx new file mode 100644 index 0000000..35a74cc --- /dev/null +++ b/src/components/SettingsTab.tsx @@ -0,0 +1,100 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface SettingsTabProps { + isInstalled: boolean; + installationStatus: string; + losslessScalingInstalled: boolean; + losslessScalingStatus: string; + steamBranchStatus: SteamBranchStatus | null; + isInstalling: boolean; + isUninstalling: boolean; + globalConfig: GlobalConfig; + showDebugTab: boolean; + onGlobalConfigChange: (config: GlobalConfig) => Promise; + onShowDebugTabChange: (value: boolean) => void; + onInstall: () => void; + onUninstall: () => void; +} + +export function SettingsTab(props: SettingsTabProps) { + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + globalConfig, + showDebugTab, + onGlobalConfigChange, + onShowDebugTabChange, + onInstall, + onUninstall, + } = props; + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + const buttonLabel = isInstalling + ? t("INSTALL_INSTALLING", "Installing...") + : isUninstalling + ? t("INSTALL_UNINSTALLING", "Uninstalling...") + : isInstalled + ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") + : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); + + return ( + <> + + + + + + + + {steamBranchStatus?.installed && ( + + + + )} + + + {buttonLabel} + + + + {isInstalled && ( + <> + + + void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} + /> + + + + + + + + + )} + + ); +} diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx deleted file mode 100644 index ff072cb..0000000 --- a/src/components/SetupTab.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; -import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; -import t from "../i18n/i18n"; - -interface SetupTabProps { - isInstalled: boolean; - installationStatus: string; - losslessScalingInstalled: boolean; - losslessScalingStatus: string; - steamBranchStatus: SteamBranchStatus | null; - isInstalling: boolean; - isUninstalling: boolean; - globalConfig: GlobalConfig; - showDebugTab: boolean; - onGlobalConfigChange: (config: GlobalConfig) => Promise; - onShowDebugTabChange: (value: boolean) => void; - onInstall: () => void; - onUninstall: () => void; -} - -export function SetupTab(props: SetupTabProps) { - const { - isInstalled, - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - isInstalling, - isUninstalling, - globalConfig, - showDebugTab, - onGlobalConfigChange, - onShowDebugTabChange, - onInstall, - onUninstall, - } = props; - const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; - const buttonLabel = isInstalling - ? t("INSTALL_INSTALLING", "Installing...") - : isUninstalling - ? t("INSTALL_UNINSTALLING", "Uninstalling...") - : isInstalled - ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") - : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); - - return ( - <> - - - - - - - - {steamBranchStatus?.installed && ( - - - - )} - - - {buttonLabel} - - - - {isInstalled && ( - <> - - - void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} - /> - - - - - - - - - )} - - ); -} diff --git a/src/components/index.ts b/src/components/index.ts index bca6f6f..5459974 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,7 +3,7 @@ export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; export { ConfigFileTab } from "./ConfigFileTab"; -export { SetupTab } from "./SetupTab"; +export { SettingsTab } from "./SettingsTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; export { NowPlayingTab } from "./NowPlayingTab"; diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index fd8dfb1..deb68ff 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundApp, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; +import { getTargetSource, mergeGameTargets, type GameTarget, type KnownGameSource } from "../utils/gameTargets"; import { showErrorToast } from "../utils/toastUtils"; -export interface GameTarget extends InstalledGame { configured: boolean; } +export type { GameSource, GameTarget, KnownGameSource } from "../utils/gameTargets"; async function getSteamShortcuts(): Promise { const apps = (globalThis as any).SteamClient?.Apps; @@ -56,20 +57,29 @@ export function useGameConfiguration() { const [games, setGames] = useState([]); const [globalConfig, setGlobalConfig] = useState({ dll: "", no_fp16: false }); const [installedGames, setInstalledGames] = useState([]); + const [workaroundApps, setWorkaroundApps] = useState([]); const [configsLoaded, setConfigsLoaded] = useState(false); const [selectedAppId, setSelectedAppId] = useState(""); const [runningGame, setRunningGame] = useState(null); + const [bulkOperationBusy, setBulkOperationBusy] = useState(false); + const bulkOperationLock = useRef(false); const previousRunningAppId = useRef(null); const previousQuickAccessVisible = useRef(null); const quickAccessVisible = useQuickAccessVisible(); const load = useCallback(async () => { - const [result, installed, shortcuts] = await Promise.all([getGameConfigs(), getInstalledGames(), getSteamShortcuts()]); + const [result, installed, shortcuts, workaroundResult] = await Promise.all([ + getGameConfigs(), + getInstalledGames(), + getSteamShortcuts(), + getWorkaroundApps(), + ]); if (result.success) { setGlobalConfig(result.global_config || { dll: "", no_fp16: false }); setGames(result.games || []); } setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts)); + setWorkaroundApps(workaroundResult.success ? workaroundResult.apps || [] : []); setConfigsLoaded(true); }, []); @@ -89,15 +99,19 @@ export function useGameConfiguration() { const installed = installedGames.find((game) => game.appid === appid); const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); + const source = getTargetSource(appid, installedGames, workaroundApps); const next: GameTarget = { - ...(installed || { appid, name, nonSteam: false }), + ...(installed || { appid, name, nonSteam: source === "nonSteam" }), name, + nonSteam: source === "nonSteam", + source, configured: games.some((game) => game.appid === appid), }; setRunningGame((current) => ( current?.appid === next.appid && current.name === next.name && current.nonSteam === next.nonSteam + && current.source === next.source && current.configured === next.configured ? current : next @@ -106,7 +120,7 @@ export function useGameConfiguration() { poll(); const interval = window.setInterval(poll, 2000); return () => window.clearInterval(interval); - }, [configsLoaded, games, installedGames]); + }, [configsLoaded, games, installedGames, workaroundApps]); useEffect(() => { const appid = runningGame?.appid || null; @@ -117,11 +131,8 @@ export function useGameConfiguration() { }, [runningGame?.appid]); const targets = useMemo(() => { - const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true }); - if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); - return configured; - }, [games, installedGames, runningGame]); + return mergeGameTargets(games, installedGames, workaroundApps, runningGame); + }, [games, installedGames, runningGame, workaroundApps]); const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; const runningConfig = runningGame @@ -129,6 +140,10 @@ export function useGameConfiguration() { : template; const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { + if (target.source === "unknown") { + showErrorToast("Could not initialize workarounds", "The target source is unknown; re-discover the game before enabling it"); + return false; + } if (!installedGames.some((game) => game.appid === target.appid)) return true; const appId = Number(target.appid); let integration: Awaited> | null = null; @@ -185,18 +200,21 @@ export function useGameConfiguration() { }, [installedGames]); const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise => { - if (!installedGames.some((game) => game.appid === target.appid)) return true; + const installed = installedGames.some((game) => game.appid === target.appid); + if (target.source === "unknown" && installed) return true; const appId = Number(target.appid); try { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); - await removeWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - existing.command_token_added === true, - ); + if (installed) { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + existing.command_token_added === true, + ); + } const removed = await removeWorkaroundState(target.appid); if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); return true; @@ -206,20 +224,29 @@ export function useGameConfiguration() { } }, [installedGames]); + const acquireBulkOperation = useCallback(() => { + if (bulkOperationLock.current) return false; + bulkOperationLock.current = true; + setBulkOperationBusy(true); + return true; + }, []); + + const releaseBulkOperation = useCallback(() => { + bulkOperationLock.current = false; + setBulkOperationBusy(false); + }, []); + const cleanupAllWorkarounds = useCallback(async (): Promise => { try { const result = await getWorkaroundApps(); if (!result.success) throw new Error(result.error || "Could not read workaround state"); - const targetsByAppId = new Map(targets.map((target) => [target.appid, target])); const cleaned = new Set(); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); for (const entry of result.apps || []) { - const target = targetsByAppId.get(entry.appid); - const nonSteam = target?.nonSteam ?? entry.non_steam; await removeWrapperIntegration( Number(entry.appid), - nonSteam, + entry.non_steam, wrapperPath, entry.command_token_added, ); @@ -274,23 +301,32 @@ export function useGameConfiguration() { return result.success; }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); - const enableAll = useCallback(async (): Promise => { - const available = targets.filter((target) => !target.configured && target.name); - if (available.length === 0) return; - for (const target of available) { - if (!(await ensureTargetWorkarounds(target))) return; - const result = await updateGameConfig(target.appid, target.name, template); - if (!result.success) { - await removeTargetWorkarounds(target); - showErrorToast( - "Could not enable all games", - result.error || `Could not create a profile for ${target.name}`, - ); - return; + const enableAll = useCallback(async (source: KnownGameSource): Promise => { + if (!acquireBulkOperation()) return; + try { + const available = targets.filter((target) => target.source === source && !target.configured && target.name); + if (available.length === 0) return; + for (const target of available) { + if (!(await ensureTargetWorkarounds(target))) { + await load(); + return; + } + const result = await updateGameConfig(target.appid, target.name, template); + if (!result.success) { + await removeTargetWorkarounds(target); + showErrorToast( + "Could not enable all games", + result.error || `Could not create a profile for ${target.name}`, + ); + await load(); + return; + } } + await load(); + } finally { + releaseBulkOperation(); } - await load(); - }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [acquireBulkOperation, ensureTargetWorkarounds, load, removeTargetWorkarounds, releaseBulkOperation, targets, template]); const repair = useCallback(async (appid: string): Promise => { const target = targets.find((item) => item.appid === appid); @@ -313,17 +349,30 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, selectedAppId, targets]); - const resetAll = useCallback(async () => { - for (const target of targets.filter((item) => item.configured)) { - if (!(await removeTargetWorkarounds(target))) return; - } - const result = await resetAllGameConfigs(); - if (result.success) { - setRunningGame((current) => current ? { ...current, configured: false } : current); + const resetAll = useCallback(async (source: KnownGameSource) => { + if (!acquireBulkOperation()) return; + try { + const selectedTargets = targets.filter((item) => item.configured && item.source === source); + if (selectedTargets.length === 0) return; + for (const target of selectedTargets) { + if (!(await removeTargetWorkarounds(target))) { + await load(); + return; + } + } + const result = await resetGameConfigs(selectedTargets.map((target) => target.appid)); + if (!result.success) { + showErrorToast("Could not remove all profiles", result.error || "Could not remove the selected profiles"); + await load(); + return; + } + setRunningGame((current) => current?.source === source ? { ...current, configured: false } : current); setSelectedAppId(""); await load(); + } finally { + releaseBulkOperation(); } - }, [load, removeTargetWorkarounds, targets]); + }, [acquireBulkOperation, load, removeTargetWorkarounds, releaseBulkOperation, targets]); - return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, cleanupAllWorkarounds, reload: load }; + return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, bulkOperationBusy, cleanupAllWorkarounds, reload: load }; } diff --git a/src/utils/gameTargets.ts b/src/utils/gameTargets.ts new file mode 100644 index 0000000..8922e98 --- /dev/null +++ b/src/utils/gameTargets.ts @@ -0,0 +1,75 @@ +import type { GameConfigEntry, InstalledGame, WorkaroundApp } from "../api/lsfgApi"; + +export type GameSource = "steam" | "nonSteam" | "unknown"; +export type KnownGameSource = Exclude; + +export interface GameTarget extends InstalledGame { + configured: boolean; + source: GameSource; +} + +export function sourceFromNonSteam(nonSteam: boolean): KnownGameSource { + return nonSteam ? "nonSteam" : "steam"; +} + +export function getTargetSource( + appid: string, + installedGames: InstalledGame[], + workaroundApps: WorkaroundApp[], +): GameSource { + const workaround = workaroundApps.find((item) => item.appid === appid); + if (workaround) return sourceFromNonSteam(workaround.non_steam); + + const installed = installedGames.find((game) => game.appid === appid); + return installed ? sourceFromNonSteam(installed.nonSteam) : "unknown"; +} + +export function mergeGameTargets( + configs: GameConfigEntry[], + installedGames: InstalledGame[], + workaroundApps: WorkaroundApp[], + runningGame: GameTarget | null = null, +): GameTarget[] { + const configuredIds = new Set(configs.map((game) => game.appid)); + const targets = installedGames.map((game) => { + const source = getTargetSource(game.appid, installedGames, workaroundApps); + return { + ...game, + nonSteam: source === "nonSteam", + source, + configured: configuredIds.has(game.appid), + }; + }); + + for (const game of configs) { + if (targets.some((target) => target.appid === game.appid)) continue; + const source = getTargetSource(game.appid, installedGames, workaroundApps); + targets.push({ + appid: game.appid, + name: game.profile || `App ${game.appid}`, + nonSteam: source === "nonSteam", + source, + configured: true, + }); + } + + if ( + runningGame + && !targets.some((target) => target.appid === runningGame.appid) + && (runningGame.configured || runningGame.source !== "unknown") + ) { + targets.unshift(runningGame); + } + + return targets; +} + +export function targetsForSource(targets: GameTarget[], source: KnownGameSource): GameTarget[] { + return targets.filter((target) => target.source === source || (target.source === "unknown" && target.configured)); +} + +export function sourceLabel(source: GameSource): string { + if (source === "nonSteam") return "Non-Steam"; + if (source === "steam") return "Steam"; + return "Unknown source"; +} diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts index 380c9f5..a307f5c 100644 --- a/src/utils/nowPlaying.ts +++ b/src/utils/nowPlaying.ts @@ -1,5 +1,5 @@ import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi"; -import type { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget } from "./gameTargets"; export type NowPlayingTarget = | { @@ -10,6 +10,10 @@ export type NowPlayingTarget = | { kind: "steam"; game: GameTarget; + } + | { + kind: "nonSteam"; + game: GameTarget; }; function numericValue(value: number | null | undefined): number { @@ -65,12 +69,18 @@ export function resolveNowPlayingTarget( runningGame: GameTarget | null, runningFlatpak: FlatpakApp | null, ): NowPlayingTarget | null { - if (runningGame && !runningGame.nonSteam) { + if (runningGame?.source === "steam") { return runningGame.configured ? { kind: "steam", game: runningGame } : null; } if (runningFlatpak) { - return { kind: "flatpak", app: runningFlatpak, launcher: runningGame?.nonSteam ? runningGame : null }; + return { + kind: "flatpak", + app: runningFlatpak, + launcher: runningGame?.source === "nonSteam" ? runningGame : null, + }; + } + if (runningGame?.source === "nonSteam" && runningGame.configured) { + return { kind: "nonSteam", game: runningGame }; } - if (runningGame?.configured) return { kind: "steam", game: runningGame }; return null; } diff --git a/tests/gameTargets.test.ts b/tests/gameTargets.test.ts new file mode 100644 index 0000000..9896d76 --- /dev/null +++ b/tests/gameTargets.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getTargetSource, mergeGameTargets, targetsForSource } from "../src/utils/gameTargets.ts"; + +const config = (appid: string, profile: string) => ({ appid, profile, config: {} }); + +test("workaround sidecar source wins over current discovery metadata", () => { + const installed = [{ appid: "123", name: "Shortcut", nonSteam: false }]; + const workarounds = [{ appid: "123", non_steam: true, command_token_added: true }]; + + assert.equal(getTargetSource("123", installed, workarounds), "nonSteam"); + assert.equal(mergeGameTargets([config("123", "Shortcut")], installed, workarounds)[0].source, "nonSteam"); +}); + +test("configured profiles without reliable source are unknown", () => { + const targets = mergeGameTargets([config("456", "Missing Game")], [], []); + + assert.deepEqual(targets[0], { + appid: "456", + name: "Missing Game", + nonSteam: false, + source: "unknown", + configured: true, + }); +}); + +test("unknown configured profiles are visible in both source tabs", () => { + const targets = mergeGameTargets([ + config("123", "Steam Game"), + config("456", "Missing Game"), + ], [ + { appid: "123", name: "Steam Game", nonSteam: false }, + { appid: "789", name: "Shortcut", nonSteam: true }, + ], []); + + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); + + assert.deepEqual(steamTargets.map((target) => target.appid).sort(), ["123", "456"]); + assert.deepEqual(nonSteamTargets.map((target) => target.appid).sort(), ["456", "789"]); +}); diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts index c99ffc4..a6b116a 100644 --- a/tests/nowPlaying.test.ts +++ b/tests/nowPlaying.test.ts @@ -24,6 +24,7 @@ const game = (nonSteam = true, configured = true) => ({ appid: "123456", name: nonSteam ? "1080 Snowboarding" : "Native Game", nonSteam, + source: nonSteam ? "nonSteam" : "steam", configured, }); @@ -102,6 +103,12 @@ test("configured Steam target remains the fallback", () => { assert.equal(target?.kind, "steam"); }); +test("configured non-Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(true), null); + + assert.equal(target?.kind, "nonSteam"); +}); + test("unconfigured Steam target has no Now Playing controls", () => { assert.equal(resolveNowPlayingTarget(game(false, false), null), null); }); diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py index 64db0f1..898a9e7 100644 --- a/tests/test_configuration_profiles.py +++ b/tests/test_configuration_profiles.py @@ -3,7 +3,7 @@ import tempfile import types import unittest from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch sys.modules.setdefault( @@ -64,6 +64,21 @@ preserve_swapchain_image_count = false self.assertIn("flatpak:org.example.Game", data["profiles"]) self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + def test_scoped_game_reset_is_one_write_and_preserves_other_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_game_config("456", "Non-Steam Game", {"multiplier": 3}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 4}) + + with patch.object(self.service, "_save_profile_data", wraps=self.service._save_profile_data) as save: + result = self.service.reset_game_configs(["123"]) + + data = self.service._get_profile_data() + self.assertTrue(result["success"]) + self.assertEqual(save.call_count, 1) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("Non-Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + def test_flatpak_reset_all_preserves_steam_profiles(self): self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) -- cgit v1.2.3 From f185d26f4d37894183cdfa8c3e6ffc718cb3b103 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 14:16:06 -0400 Subject: labeled slider for multiplier --- src/components/FpsMultiplierControl.tsx | 81 ++++++++++----------------------- 1 file changed, 25 insertions(+), 56 deletions(-) diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 2c50be1..523e8b3 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,4 +1,4 @@ -import { DialogButton, Focusable, PanelSectionRow } from "@decky/ui"; +import { Focusable, PanelSectionRow, SliderField } from "@decky/ui"; import { useEffect, useRef } from "react"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; @@ -31,63 +31,32 @@ export function FpsMultiplierControl({ return () => cancelAnimationFrame(frame); }, [autoFocus, onAutoFocus]); + const multiplierLabel = config.multiplier === 1 + ? t("MULTIPLIER_OFF", "Off") + : `${config.multiplier}x`; + return ( - - void onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} - disabled={config.multiplier <= 1} - > - − - -
4 ? "red" : "white", - minWidth: "60px", - textAlign: "center", - }} - > - {config.multiplier < 2 ? t("MULTIPLIER_OFF", "OFF") : `${config.multiplier}X`} -
- void onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))} - disabled={config.multiplier >= 4} - > - + - + + void onConfigChange(MULTIPLIER, value)} + />
); -- 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 ++++++++++++++++++++++---- src/api/lsfgApi.ts | 1 + src/components/CollapsibleItemGroup.tsx | 4 ++- src/components/GameConfigurationSelector.tsx | 6 ++-- src/utils/steamLaunchOptions.ts | 41 +++++++++++++++++++++++----- tests/gameTargets.test.ts | 9 ++++++ tests/steamLaunchOptions.test.ts | 20 ++++++++++++-- tests/test_steam_service.py | 20 +++++++++++++- 8 files changed, 119 insertions(+), 20 deletions(-) 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 = {} diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 883f56e..44b72b5 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -41,6 +41,7 @@ export interface InstalledGame { appid: string; name: string; nonSteam: boolean; + isFlatpakShortcut?: boolean; } export interface GlobalConfig { diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx index 29fe945..a4e4092 100644 --- a/src/components/CollapsibleItemGroup.tsx +++ b/src/components/CollapsibleItemGroup.tsx @@ -6,6 +6,7 @@ export interface CollapsibleItem { id: string; label: string; description: string; + disabled?: boolean; } export const collapsibleItemGroupStyles = ` @@ -91,7 +92,8 @@ export function CollapsibleItemGroup({ onSelect(item.id)} + disabled={item.disabled} + onActivate={item.disabled ? undefined : () => onSelect(item.id)} highlightOnFocus />
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index ad3537d..91dc3df 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -20,6 +20,7 @@ const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; function targetDescription(game: GameTarget): string { + if (game.isFlatpakShortcut) return "Non-Steam | Use Flatpak Tab"; return game.source === "unknown" ? "Unknown source · excluded from bulk actions" : sourceLabel(game.source); @@ -43,8 +44,8 @@ export function GameConfigurationSelector({ }); const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); - const enableableGames = availableGames.filter((game) => game.source === source); - const removableGames = enabledGames.filter((game) => game.source === source); + const enableableGames = availableGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const removableGames = enabledGames.filter((game) => game.source === source && !game.isFlatpakShortcut); const sourceName = source === "nonSteam" ? "non-Steam shortcuts" : "Steam games"; const emptyDescription = source === "nonSteam" ? "Steam has not reported any eligible non-Steam shortcuts" @@ -53,6 +54,7 @@ export function GameConfigurationSelector({ id: game.appid, label: game.name, description: targetDescription(game), + disabled: game.isFlatpakShortcut, }); const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(`${ENABLED_COLLAPSED_KEY}-${source}`); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-${source}`); diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index f03eeab..83c46f2 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -153,7 +153,25 @@ function tokenize(options: string): LaunchToken[] { } const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); -const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +function isCommandToken(token: LaunchToken): boolean { + return token.value.toLowerCase() === COMMAND_TOKEN; +} + +function isMalformedCommandToken(token: LaunchToken): boolean { + const value = token.value.toLowerCase(); + return value === "%command" || value === "command%"; +} + +function normalizeCommandTokens(tokens: LaunchToken[]): void { + for (const token of tokens) { + if (isCommandToken(token) || isMalformedCommandToken(token)) { + token.raw = COMMAND_TOKEN; + token.value = COMMAND_TOKEN; + } + } +} + +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex(isCommandToken); const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); @@ -173,9 +191,9 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, - shortcutLaunchOptions = false, ) { const tokens = tokenize(options); + normalizeCommandTokens(tokens); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); if (command >= 0) { @@ -185,11 +203,15 @@ function installLaunchOption( tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath }); return { options: serialize(tokens), commandTokenAdded: false }; } + + const existingWrapper = tokens.findIndex((token) => decodeToken(token.value) === wrapperPath); + if (existingWrapper >= 0) { + tokens.splice(existingWrapper + 1, 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + return { options: serialize(tokens), commandTokenAdded: true }; + } + let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { - throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); - } tokens.splice(insertion, 0, { raw: wrapperPath, value: wrapperPath }, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, @@ -207,6 +229,7 @@ export function removeWrapperLaunchOption( commandTokenAdded = false, ): string { const tokens = tokenize(options); + normalizeCommandTokens(tokens); if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) { const command = commandIndex(tokens); if (command >= 0) tokens.splice(command, 1); @@ -345,14 +368,18 @@ export function installWrapperIntegration( const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); + const rewrite = installLaunchOption(cleaned, wrapperPath); if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false }; const value = await writeVerified( appId, nonSteam, current.options, rewrite.options, (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); - return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true }; + return { + snapshot: value, + commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded, + changed: true, + }; }); } diff --git a/tests/gameTargets.test.ts b/tests/gameTargets.test.ts index 9896d76..2eb8b7e 100644 --- a/tests/gameTargets.test.ts +++ b/tests/gameTargets.test.ts @@ -39,3 +39,12 @@ test("unknown configured profiles are visible in both source tabs", () => { assert.deepEqual(steamTargets.map((target) => target.appid).sort(), ["123", "456"]); assert.deepEqual(nonSteamTargets.map((target) => target.appid).sort(), ["456", "789"]); }); + +test("direct Flatpak shortcuts remain non-Steam targets", () => { + const targets = mergeGameTargets([], [ + { appid: "123", name: "Flatpak shortcut", nonSteam: true, isFlatpakShortcut: true }, + ], []); + + assert.equal(targets[0].source, "nonSteam"); + assert.equal(targets[0].isFlatpakShortcut, true); +}); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 40aeb3c..1d2f762 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -28,7 +28,7 @@ test("inserts one wrapper immediately before an existing command macro", () => { }); }); -test("normalizes blank and argument-only shortcut fields", () => { +test("normalizes blank, malformed, and argument-only launch fields", () => { assert.deepEqual(installWrapperLaunchOption("", wrapper), { options: `${wrapper} %command%`, commandTokenAdded: true, @@ -37,8 +37,22 @@ test("normalizes blank and argument-only shortcut fields", () => { options: `FOO=bar ${wrapper} %command% --windowed`, commandTokenAdded: true, }); - assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/); - assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); + assert.deepEqual(installWrapperLaunchOption("gamemoderun --windowed", wrapper), { + options: `${wrapper} %command% gamemoderun --windowed`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption('"%command%"', wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} %command`, wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} --windowed`, wrapper), { + options: `${wrapper} %command% --windowed`, + commandTokenAdded: true, + }); }); test("preserves assignments quoting suffixes and released wrapper cleanup", () => { diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 004ffb6..75cdabf 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -15,7 +15,7 @@ from lsfg_vk.steam_service import SteamService class SteamShortcutTests(unittest.TestCase): - def test_direct_flatpak_shortcut_is_ordinary_non_steam_metadata(self): + def test_direct_flatpak_shortcut_is_marked(self): game = SteamService._shortcut_game( { "appid": 123456, @@ -30,6 +30,24 @@ class SteamShortcutTests(unittest.TestCase): "appid": "123456", "name": "PCSX2 shortcut", "nonSteam": True, + "isFlatpakShortcut": True, + }) + + def test_bare_flatpak_shortcut_is_marked(self): + game = SteamService._shortcut_game( + { + "appid": 654321, + "AppName": "Faugus shortcut", + "Exe": '"flatpak"', + "LaunchOptions": "run io.github.Faugus.faugus-launcher --game elliot", + } + ) + + self.assertEqual(game, { + "appid": "654321", + "name": "Faugus shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, }) def test_emudeck_launcher_is_ordinary_non_steam_metadata(self): -- cgit v1.2.3