diff options
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema.py | 339 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 399 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/dll_detection.py | 120 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 149 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 106 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/types.py | 23 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 26 | ||||
| -rw-r--r-- | src/components/ConfigurationSection.tsx | 6 | ||||
| -rw-r--r-- | src/components/Content.tsx | 32 | ||||
| -rw-r--r-- | src/components/ProfileManagement.tsx | 340 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/hooks/useProfileManagement.ts | 190 |
13 files changed, 1584 insertions, 149 deletions
diff --git a/package.json b/package.json index b16186a..9751add 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "decky-lossless-scaling-vk", - "version": "0.9.1", + "version": "0.10.1", "description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer", "type": "module", "scripts": { diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 33f7b3e..66aeb69 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -10,7 +10,7 @@ This module defines the complete configuration structure for lsfg-vk, managing T import re import sys -from typing import TypedDict, Dict, Any, Union, cast +from typing import TypedDict, Dict, Any, Union, cast, List from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -74,10 +74,21 @@ COMPLETE_CONFIG_SCHEMA = {**CONFIG_SCHEMA, **SCRIPT_ONLY_FIELDS} # Import auto-generated configuration components from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic +# 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) + + class ConfigurationManager: """Centralized configuration management""" @@ -163,58 +174,104 @@ class ConfigurationManager: @staticmethod def generate_toml_content(config: ConfigurationData) -> str: - """Generate TOML configuration file content using the new game-specific format""" + """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}, + "global_config": { + "dll": config.get("dll", ""), + "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 - if config.get("dll"): + dll_path = profile_data["global_config"].get("dll", "") + if dll_path: lines.append(f"# specify where Lossless.dll is stored") - from .config_schema_generated import DLL - lines.append(f'dll = "{config[DLL]}"') - + lines.append(f'dll = "{dll_path}"') + lines.append("") + # Add no_fp16 field - from .config_schema_generated import NO_FP16 + no_fp16 = profile_data["global_config"].get("no_fp16", False) lines.append(f"# force-disable fp16 (use on older nvidia cards)") - lines.append(f"no_fp16 = {str(config[NO_FP16]).lower()}") + lines.append(f"no_fp16 = {str(no_fp16).lower()}") lines.append("") - # Add game section with process name for LSFG_PROCESS approach - lines.append("[[game]]") - lines.append("# Plugin-managed game entry (uses LSFG_PROCESS=decky-lsfg-vk)") - lines.append('exe = "decky-lsfg-vk"') - 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])) - # Add all configuration fields to the game section - for field_name, field_def in CONFIG_SCHEMA.items(): - # Skip global fields - they go in global section - if field_name in ("dll", "no_fp16"): - continue - - value = config[field_name] - - # 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}") + 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("") - lines.append("") # Empty line for readability + # 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) @staticmethod def parse_toml_content(content: str) -> ConfigurationData: - """Parse TOML content into configuration data using simple regex parsing""" - config = ConfigurationManager.get_defaults() + """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 + + @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 try: # Look for both [global] and [[game]] sections @@ -222,6 +279,7 @@ class ConfigurationManager: 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() @@ -232,6 +290,29 @@ class ConfigurationManager: # 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 @@ -256,42 +337,79 @@ class ConfigurationManager: elif value.startswith("'") and value.endswith("'"): value = value[1:-1] - # Handle global section (dll and no_fp16) - USE GENERATED CONSTANTS + # Handle global section if in_global_section: - if key == "dll": - from .config_schema_generated import DLL - config[DLL] = value + if key == "current_profile": + current_profile = value + elif key == "dll": + global_config["dll"] = value elif key == "no_fp16": - from .config_schema_generated import NO_FP16 - config[NO_FP16] = value.lower() in ('true', '1', 'yes', 'on') + 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 - # Only parse config for our plugin-managed game entry - elif current_game_exe == "decky-lsfg-vk" and key in CONFIG_SCHEMA: + # Store config fields for current game + elif key in CONFIG_SCHEMA: field_def = CONFIG_SCHEMA[key] try: if field_def.field_type == ConfigFieldType.BOOLEAN: - config[key] = value.lower() in ('true', '1', 'yes', 'on') + current_game_config[key] = value.lower() in ('true', '1', 'yes', 'on') elif field_def.field_type == ConfigFieldType.INTEGER: - parsed_value = int(value) - config[key] = parsed_value + current_game_config[key] = int(value) elif field_def.field_type == ConfigFieldType.FLOAT: - config[key] = float(value) + current_game_config[key] = float(value) elif field_def.field_type == ConfigFieldType.STRING: - config[key] = value + current_game_config[key] = value except (ValueError, TypeError): # If conversion fails, keep default value pass - return config + # 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 Exception: - # If parsing fails completely, return defaults - return ConfigurationManager.get_defaults() + # If parsing fails completely, return default profile structure + return ProfileData( + current_profile=DEFAULT_PROFILE_NAME, + profiles={DEFAULT_PROFILE_NAME: ConfigurationManager.get_defaults()}, + global_config={} + ) @staticmethod def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]: @@ -333,3 +451,124 @@ class ConfigurationManager: """Create configuration from keyword arguments - USES GENERATED CODE""" from .config_schema_generated import create_config_dict return create_config_dict(**kwargs) + + @staticmethod + def validate_profile_name(profile_name: str) -> bool: + """Validate profile name for safety""" + if not profile_name: + return False + + # Check for invalid characters that could cause issues in shell scripts or TOML + invalid_chars = set(' \t\n\r\'"\\/$|&;()<>{}[]`*?') + if any(char in invalid_chars for char in profile_name): + return False + + # Check for reserved names + reserved_names = {'global', 'game', 'current_profile'} + if profile_name.lower() in reserved_names: + return False + + return True + + @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}") + + 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( + current_profile=profile_data["current_profile"], + profiles=dict(profile_data["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}'") + + 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"]) + ) + + # 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}") + + 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"]) + ) + + # 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( + current_profile=profile_name, + profiles=dict(profile_data["profiles"]), + global_config=dict(profile_data["global_config"]) + ) + + return new_profile_data diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index b9ee174..d4d60d4 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -6,10 +6,10 @@ from pathlib import Path from typing import Dict, Any from .base_service import BaseService -from .config_schema import ConfigurationManager, CONFIG_SCHEMA +from .config_schema import ConfigurationManager, CONFIG_SCHEMA, ProfileData, DEFAULT_PROFILE_NAME from .config_schema_generated import ConfigurationData, get_script_generation_logic from .configuration_helpers_generated import log_configuration_update -from .types import ConfigurationResponse +from .types import ConfigurationResponse, ProfilesResponse, ProfileResponse class ConfigurationService(BaseService): @@ -72,27 +72,11 @@ class ConfigurationService(BaseService): ConfigurationResponse with success status """ try: - # Generate TOML content using centralized manager - toml_content = ConfigurationManager.generate_toml_content(config) + profile_data = self._get_profile_data() + current_profile = profile_data["current_profile"] - # Ensure config directory exists - self.config_dir.mkdir(parents=True, exist_ok=True) - - # Write the updated config directly to preserve inode for file watchers - self._write_file(self.config_file_path, toml_content, 0o644) - - # Update the launch script with the new configuration - script_result = self.update_lsfg_script(config) - if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - # Log with dynamic field listing - field_values = ", ".join(f"{k}={repr(v)}" for k, v in config.items()) - self.log.info(f"Updated lsfg configuration: {field_values}") - - return self._success_response(ConfigurationResponse, - "lsfg configuration updated successfully", - config=config) + # Update the current profile's config + return self.update_profile_config(current_profile, config) except (OSError, IOError) as e: error_msg = f"Error updating lsfg config: {str(e)}" @@ -116,26 +100,8 @@ class ConfigurationService(BaseService): # Create configuration from keyword arguments using generated function config = ConfigurationManager.create_config_from_args(**kwargs) - # Generate TOML content using centralized manager - toml_content = ConfigurationManager.generate_toml_content(config) - - # Ensure config directory exists - self.config_dir.mkdir(parents=True, exist_ok=True) - - # Write the updated config directly to preserve inode for file watchers - self._write_file(self.config_file_path, toml_content, 0o644) - - # Update the launch script with the new configuration - script_result = self.update_lsfg_script(config) - if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - # Use auto-generated logging - log_configuration_update(self.log, config) - - return self._success_response(ConfigurationResponse, - "lsfg configuration updated successfully", - config=config) + # Update using the new profile-aware method + return self.update_config_from_dict(config) except (OSError, IOError) as e: error_msg = f"Error updating lsfg config: {str(e)}" @@ -156,34 +122,29 @@ class ConfigurationService(BaseService): ConfigurationResponse with success status """ try: - # Get current merged config (TOML + script) - current_response = self.get_config() - if not current_response["success"] or current_response["config"] is None: - # If we can't read current config, use defaults with DLL detection - from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - else: - config = current_response["config"] + profile_data = self._get_profile_data() - # Update just the DLL path - USE GENERATED CONSTANTS - from .config_schema_generated import DLL - config[DLL] = dll_path + # Update global config (DLL path is global) + profile_data["global_config"]["dll"] = dll_path - # Generate TOML content and write it - toml_content = ConfigurationManager.generate_toml_content(config) + # Also update current profile's config for backward compatibility + current_profile = profile_data["current_profile"] + from .config_schema_generated import DLL + profile_data["profiles"][current_profile][DLL] = dll_path - # Ensure config directory exists - self.config_dir.mkdir(parents=True, exist_ok=True) + # Save to file + self._save_profile_data(profile_data) - # Write the updated config directly to preserve inode for file watchers - self._write_file(self.config_file_path, toml_content, 0o644) + # Update launch script + 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"Updated DLL path in lsfg configuration: '{dll_path}'") return self._success_response(ConfigurationResponse, f"DLL path updated to: {dll_path}", - config=config) + config=profile_data["profiles"][current_profile]) except Exception as e: error_msg = f"Error updating DLL path: {str(e)}" @@ -242,3 +203,319 @@ class ConfigurationService(BaseService): ]) return "\n".join(lines) + "\n" + + 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()) + + # Merge global config with profile config + merged_config = dict(config) + for field_name, value in profile_data["global_config"].items(): + merged_config[field_name] = value + + lines = [ + "#!/bin/bash", + "# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin", + f"# Current profile: {current_profile}", + "# This script sets up the environment for lsfg-vk to work with the plugin configuration" + ] + + # Use auto-generated script generation logic + generate_script_lines = get_script_generation_logic() + lines.extend(generate_script_lines(merged_config)) + + # Export LSFG_PROCESS with current profile name + lines.extend([ + f"export LSFG_PROCESS={current_profile}", + 'exec "$@"' + ]) + + return "\n".join(lines) + "\n" + + def _get_profile_data(self) -> ProfileData: + """Get current profile data from config file""" + if not self.config_file_path.exists(): + # Return default profile structure if file doesn't exist + from .dll_detection import DllDetectionService + dll_service = DllDetectionService(self.log) + default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + return ProfileData( + current_profile=DEFAULT_PROFILE_NAME, + profiles={DEFAULT_PROFILE_NAME: default_config}, + global_config={ + "dll": default_config.get("dll", ""), + "no_fp16": default_config.get("no_fp16", False) + } + ) + + content = self.config_file_path.read_text(encoding='utf-8') + return ConfigurationManager.parse_toml_content_multi_profile(content) + + 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) + + # Ensure config directory exists + self.config_dir.mkdir(parents=True, exist_ok=True) + + # Write the updated config directly to preserve inode for file watchers + self._write_file(self.config_file_path, toml_content, 0o644) + + # Profile management methods + 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) + + def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse: + """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 with success status + """ + try: + profile_data = self._get_profile_data() + + # Use current profile as source if not specified + if not source_profile: + source_profile = profile_data["current_profile"] + + # Create the new profile + new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile) + + # Save to file + self._save_profile_data(new_profile_data) + + self.log.info(f"Created profile '{profile_name}' from '{source_profile}'") + + return self._success_response(ProfileResponse, + f"Profile '{profile_name}' created 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 creating profile: {str(e)}" + self.log.error(error_msg) + return self._error_response(ProfileResponse, str(e), 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() + + # Delete the profile + new_profile_data = ConfigurationManager.delete_profile(profile_data, profile_name) + + # Save to file + self._save_profile_data(new_profile_data) + + # Update launch script if current profile changed + script_result = self.update_lsfg_script_from_profile_data(new_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) + + 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 + + Returns: + ProfileResponse with success status + """ + try: + profile_data = self._get_profile_data() + + # Rename the profile + new_profile_data = ConfigurationManager.rename_profile(profile_data, old_name, new_name) + + # Save to file + self._save_profile_data(new_profile_data) + + # Update launch script if current profile changed + script_result = self.update_lsfg_script_from_profile_data(new_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 '{new_name}'") + + return self._success_response(ProfileResponse, + f"Profile renamed from '{old_name}' to '{new_name}' successfully", + profile_name=new_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) + + 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() + + # Set current profile + new_profile_data = ConfigurationManager.set_current_profile(profile_data, profile_name) + + # Save to file + self._save_profile_data(new_profile_data) + + # Update launch script with new current profile + script_result = self.update_lsfg_script_from_profile_data(new_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) + + 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] + + # Save to file + self._save_profile_data(profile_data) + + # Update launch script if this is the current profile + 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']}") + + # Log with dynamic field listing + 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) + + 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) diff --git a/py_modules/lsfg_vk/dll_detection.py b/py_modules/lsfg_vk/dll_detection.py index f1dace9..e547405 100644 --- a/py_modules/lsfg_vk/dll_detection.py +++ b/py_modules/lsfg_vk/dll_detection.py @@ -3,8 +3,9 @@ DLL detection service for Lossless Scaling. """ import os +import re from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, List from .base_service import BaseService from .constants import ( @@ -20,6 +21,12 @@ class DllDetectionService(BaseService): 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 """ @@ -39,6 +46,11 @@ class DllDetectionService(BaseService): if home_path: return home_path + # Check all Steam library folders (including SD cards) + steam_libraries_path = self._check_steam_library_folders() + if steam_libraries_path: + return steam_libraries_path + # DLL not found in any expected location return { "detected": False, @@ -118,3 +130,109 @@ class DllDetectionService(BaseService): "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 = [] + + # Try different possible Steam installation locations + steam_paths = [] + + # XDG_DATA_HOME path + data_dir = os.getenv(ENV_XDG_DATA_HOME) + if data_dir and data_dir.strip(): + steam_paths.append(Path(data_dir.strip()) / "Steam") + + # HOME/.local/share path (most common on Steam Deck) + 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(): + # Add the main Steam directory as a library + library_paths.append(str(steam_path)) + + # Parse libraryfolders.vdf for additional libraries + 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)}") + + # Remove duplicates while preserving order + 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() + + # Look for "path" entries in the VDF file + # The format is typically: "path" "/path/to/library" + path_pattern = r'"path"\s*"([^"]+)"' + matches = re.findall(path_pattern, content, re.IGNORECASE) + + for path_match in matches: + # Convert Windows paths to Unix paths if needed + path = path_match.replace('\\\\', '/').replace('\\', '/') + library_path = Path(path) + + # Verify the library folder exists and has a steamapps directory + 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/installation.py b/py_modules/lsfg_vk/installation.py index b0e05ad..04ffb33 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -141,25 +141,55 @@ class InstallationService(BaseService): shutil.copy2(src_file, dst_file) def _create_config_file(self) -> None: - """Create the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path""" + """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) - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - # Generate TOML content using centralized manager - toml_content = ConfigurationManager.generate_toml_content(config) + # Check if config file already exists + 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) + 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 initial 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 - if config[DLL]: - self.log.info(f"Configured DLL path: {config[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 Exception: + pass # Don't fail installation if we can't log the DLL path def _create_lsfg_launch_script(self) -> None: """Create the ~/lsfg launch script for easier game setup""" @@ -221,12 +251,15 @@ class InstallationService(BaseService): 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 = [] - files_to_remove = [self.lib_file, self.json_file, self.config_file_path, self.lsfg_launch_script_path] + # 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): @@ -236,13 +269,7 @@ class InstallationService(BaseService): if self._remove_if_exists(self.lsfg_script_path): removed_files.append(str(self.lsfg_script_path)) - # Remove config directory if it's empty - try: - if self.config_dir.exists() and not any(self.config_dir.iterdir()): - self.config_dir.rmdir() - removed_files.append(str(self.config_dir)) - except OSError: - pass # Directory not empty or other error, ignore + # Don't remove config directory since we're preserving the config file if not removed_files: return self._success_response(UninstallationResponse, @@ -261,17 +288,21 @@ class InstallationService(BaseService): message="", removed_files=None) def cleanup_on_uninstall(self) -> None: - """Clean up lsfg-vk files when the plugin is uninstalled""" + """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}") + 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 = [] - files_to_remove = [self.lib_file, self.json_file, self.config_file_path, self.lsfg_launch_script_path, self.lsfg_script_path] + # 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: @@ -280,13 +311,7 @@ class InstallationService(BaseService): except OSError as e: self.log.error(f"Failed to remove {file_path}: {e}") - # Try to remove config directory if empty - try: - if self.config_dir.exists() and not any(self.config_dir.iterdir()): - self.config_dir.rmdir() - removed_files.append(str(self.config_dir)) - except OSError: - pass # Directory not empty or other error, ignore + # 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}") @@ -297,3 +322,77 @@ class InstallationService(BaseService): self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {str(e)}") import traceback 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": default_config.get("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 diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 8fa2435..cee6607 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -176,13 +176,37 @@ class Plugin: """Get configuration schema information for frontend Returns: - Dict with field names, types, and defaults + Dict with field names, types, defaults, and profile information """ - 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() - } + try: + # Get profile information + 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() + } + + # Add profile information if available + 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 Exception: + # Fallback to basic schema without profile info + 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" + } 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) @@ -210,6 +234,76 @@ class Plugin: """ return self.configuration_service.update_dll_path(dll_path) + # Profile management methods + async def 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() + + async def 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 self.configuration_service.create_profile(profile_name, source_profile) + + async def 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 self.configuration_service.delete_profile(profile_name) + + async def 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 self.configuration_service.rename_profile(old_name, new_name) + + async def 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 self.configuration_service.set_current_profile(profile_name) + + async def 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 + """ + # Validate and extract configuration from the config dict + validated_config = ConfigurationManager.validate_config(config) + + return self.configuration_service.update_profile_config(profile_name, validated_config) + # Self-updater methods async def check_for_plugin_update(self) -> Dict[str, Any]: """Check for plugin updates by comparing current version with latest GitHub release diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 3d555e1..7b7ca2b 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -2,7 +2,7 @@ Type definitions for the lsfg-vk plugin responses. """ -from typing import TypedDict, Optional, List +from typing import TypedDict, Optional, List, Dict, Any from .config_schema import ConfigurationData @@ -60,3 +60,24 @@ class ConfigurationResponse(BaseResponse): 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 profile operations""" + profiles: Optional[List[str]] + current_profile: Optional[str] + message: Optional[str] + error: Optional[str] + + +class ProfileResponse(BaseResponse): + """Response for single profile operations""" + profile_name: Optional[str] + message: Optional[str] + error: Optional[str] diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 8d14da6..6c535af 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -55,6 +55,8 @@ export interface ConfigSchemaResult { field_names: string[]; field_types: Record<string, string>; defaults: ConfigurationData; + profiles?: string[]; + current_profile?: string; } export interface UpdateCheckResult { @@ -87,6 +89,22 @@ export interface FileContentResult { error?: 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"); @@ -113,3 +131,11 @@ export const updateLsfgConfigFromObject = async (config: ConfigurationData): Pro // Self-updater API functions export const checkForPluginUpdate = callable<[], UpdateCheckResult>("check_for_plugin_update"); export const downloadPluginUpdate = callable<[string], UpdateDownloadResult>("download_plugin_update"); + +// 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 31ce278..92d1867 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -4,7 +4,7 @@ import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { FpsMultiplierControl } from "./FpsMultiplierControl"; import { - NO_FP16, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, + FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI } from "../config/generatedConfigSchema"; @@ -113,14 +113,14 @@ export function ConfigurationSection({ /> </PanelSectionRow> - <PanelSectionRow> + {/* <PanelSectionRow> <ToggleField label="Force Disable FP16" description="Force-disable FP16 acceleration" checked={config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, value)} /> - </PanelSectionRow> + </PanelSectionRow> */} <PanelSectionRow> <ToggleField diff --git a/src/components/Content.tsx b/src/components/Content.tsx index a075574..7815951 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,10 +1,12 @@ import { useEffect } from "react"; import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; import { useInstallationStatus, useDllDetection, useLsfgConfig } from "../hooks/useLsfgHooks"; +import { useProfileManagement } from "../hooks/useProfileManagement"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { StatusDisplay } from "./StatusDisplay"; import { InstallationButton } from "./InstallationButton"; import { ConfigurationSection } from "./ConfigurationSection"; +import { ProfileManagement } from "./ProfileManagement"; import { UsageInstructions } from "./UsageInstructions"; import { WikiButton } from "./WikiButton"; import { ClipboardButton } from "./ClipboardButton"; @@ -29,6 +31,12 @@ export function Content() { updateField } = useLsfgConfig(); + const { + currentProfile, + updateProfileConfig, + loadProfiles + } = useProfileManagement(); + const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); // Reload config when installation status changes @@ -40,7 +48,18 @@ export function Content() { // Generic configuration change handler const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string) => { - await updateField(fieldName, value); + // If we have a current profile, update that profile specifically + if (currentProfile) { + const newConfig = { ...config, [fieldName]: value }; + const result = await updateProfileConfig(currentProfile, newConfig); + if (result.success) { + // Reload config to reflect the changes from the backend + await loadLsfgConfig(); + } + } else { + // Fallback to the original method for backward compatibility + await updateField(fieldName, value); + } }; const onInstall = () => { @@ -74,6 +93,17 @@ export function Content() { <SmartClipboardButton /> + {/* Profile Management - only show if installed */} + {isInstalled && ( + <ProfileManagement + currentProfile={currentProfile} + onProfileChange={async () => { + await loadProfiles(); + await loadLsfgConfig(); + }} + /> + )} + {/* Configuration Section - only show if installed */} {isInstalled && ( <ConfigurationSection diff --git a/src/components/ProfileManagement.tsx b/src/components/ProfileManagement.tsx new file mode 100644 index 0000000..67f0645 --- /dev/null +++ b/src/components/ProfileManagement.tsx @@ -0,0 +1,340 @@ +import { useState, useEffect } from "react"; +import { + PanelSection, + PanelSectionRow, + Dropdown, + DropdownOption, + showModal, + ConfirmModal, + Field, + DialogButton, + ButtonItem, + ModalRoot, + TextField, + Focusable +} from "@decky/ui"; +import { + getProfiles, + createProfile, + deleteProfile, + renameProfile, + setCurrentProfile, + ProfilesResult, + ProfileResult +} from "../api/lsfgApi"; +import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; + +interface ProfileManagementProps { + currentProfile?: string; + onProfileChange?: (profileName: string) => void; +} + +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 ( + <ModalRoot> + <div style={{ padding: "16px", minWidth: "400px" }}> + <h2 style={{ marginBottom: "16px" }}>{title}</h2> + <p style={{ marginBottom: "24px" }}>{description}</p> + + <div style={{ marginBottom: "24px" }}> + <Field + label="Name" + childrenLayout="below" + childrenContainerWidth="max" + > + <TextField + value={value} + onChange={(e) => setValue(e?.target?.value || "")} + style={{ width: "100%" }} + /> + </Field> + </div> + + <Focusable + style={{ + display: "flex", + justifyContent: "flex-end", + gap: "8px", + marginTop: "16px" + }} + flow-children="horizontal" + > + <DialogButton onClick={closeModal}> + {cancelText} + </DialogButton> + <DialogButton + onClick={handleOK} + disabled={!value.trim()} + > + {okText} + </DialogButton> + </Focusable> + </div> + </ModalRoot> + ); +} + +interface ProfileManagementProps { + currentProfile?: string; + onProfileChange?: (profileName: string) => void; +} + +export function ProfileManagement({ currentProfile, onProfileChange }: ProfileManagementProps) { + const [profiles, setProfiles] = useState<string[]>([]); + const [selectedProfile, setSelectedProfile] = useState<string>(currentProfile || "decky-lsfg-vk"); + const [isLoading, setIsLoading] = useState(false); + + // Load profiles on component mount + useEffect(() => { + loadProfiles(); + }, []); + + // Update selected profile when prop changes + useEffect(() => { + if (currentProfile) { + setSelectedProfile(currentProfile); + } + }, [currentProfile]); + + 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( + <TextInputModal + title="Create New Profile" + description="Enter a name for the new profile. The current profile's settings will be copied." + okText="Create" + cancelText="Cancel" + onOK={(name: string) => { + if (name.trim()) { + createNewProfile(name.trim()); + } + }} + /> + ); + }; + + const createNewProfile = async (profileName: string) => { + setIsLoading(true); + try { + const result: ProfileResult = await createProfile(profileName, selectedProfile); + if (result.success) { + showSuccessToast("Profile created", `Created profile: ${profileName}`); + await loadProfiles(); + // Automatically switch to the newly created profile + await handleProfileChange(profileName); + } 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("Cannot delete default profile", "The default profile cannot be deleted"); + return; + } + + showModal( + <ConfirmModal + strTitle="Delete Profile" + strDescription={`Are you sure you want to delete the profile "${selectedProfile}"? This action cannot be undone.`} + strOKButtonText="Delete" + strCancelButtonText="Cancel" + onOK={() => 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("Cannot rename default profile", "The default profile cannot be renamed"); + return; + } + + showModal( + <TextInputModal + title="Rename Profile" + description={`Enter a new name for the profile "${selectedProfile}".`} + defaultValue={selectedProfile} + okText="Rename" + cancelText="Cancel" + onOK={(newName: string) => { + 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) { + showSuccessToast("Profile renamed", `Renamed profile to: ${newName}`); + await loadProfiles(); + setSelectedProfile(newName); + onProfileChange?.(newName); + } 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" ? "Default" : profile + })), + { + data: "__NEW_PROFILE__", + label: "New Profile" + } + ]; + + return ( + <PanelSection title="Select Profile"> + <PanelSectionRow> + <Field + label="" + childrenLayout="below" + childrenContainerWidth="max" + > + <Dropdown + rgOptions={profileOptions} + selectedOption={selectedProfile} + onChange={handleDropdownChange} + disabled={isLoading} + /> + </Field> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={handleRenameProfile} + disabled={isLoading || selectedProfile === "decky-lsfg-vk"} + > + Rename + </ButtonItem> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={handleDeleteProfile} + disabled={isLoading || selectedProfile === "decky-lsfg-vk"} + > + Delete + </ButtonItem> + </PanelSectionRow> + </PanelSection> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 305911d..bf60423 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,3 +8,4 @@ export { WikiButton } from "./WikiButton"; export { SmartClipboardButton } from "./SmartClipboardButton"; export { PluginUpdateChecker } from "./PluginUpdateChecker"; export { NerdStuffModal } from "./NerdStuffModal"; +export { ProfileManagement } from "./ProfileManagement"; diff --git a/src/hooks/useProfileManagement.ts b/src/hooks/useProfileManagement.ts new file mode 100644 index 0000000..4b10f0e --- /dev/null +++ b/src/hooks/useProfileManagement.ts @@ -0,0 +1,190 @@ +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<string[]>([]); + const [currentProfile, setCurrentProfileState] = useState<string>("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) { + showSuccessToast("Profile created", `Created profile: ${profileName}`); + 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) { + showSuccessToast("Profile renamed", `Renamed profile to: ${newName}`); + await loadProfiles(); + // Update current profile if it was renamed + if (currentProfile === oldName) { + setCurrentProfileState(newName); + } + 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 + }; +} |
