summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--py_modules/lsfg_vk/config_schema.py339
-rw-r--r--py_modules/lsfg_vk/configuration.py399
-rw-r--r--py_modules/lsfg_vk/dll_detection.py120
-rw-r--r--py_modules/lsfg_vk/installation.py149
-rw-r--r--py_modules/lsfg_vk/plugin.py106
-rw-r--r--py_modules/lsfg_vk/types.py23
-rw-r--r--src/api/lsfgApi.ts26
-rw-r--r--src/components/ConfigurationSection.tsx6
-rw-r--r--src/components/Content.tsx32
-rw-r--r--src/components/ProfileManagement.tsx340
-rw-r--r--src/components/index.ts1
-rw-r--r--src/hooks/useProfileManagement.ts190
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
+ };
+}