summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2025-08-16 12:05:10 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2025-08-16 12:05:10 -0400
commit4104e28053fc03b3875958c7bf56ec6fbc5aab84 (patch)
tree1095b374ab453ce98ab35dfbfa79d810ad99fdab
parent6489f2273fc246fcca25e95d913e60ea214e0d31 (diff)
downloaddecky-lsfg-vk-4104e28053fc03b3875958c7bf56ec6fbc5aab84.tar.gz
decky-lsfg-vk-4104e28053fc03b3875958c7bf56ec6fbc5aab84.zip
initial prof selector and creator ui
-rw-r--r--py_modules/lsfg_vk/config_schema.py334
-rw-r--r--py_modules/lsfg_vk/configuration.py399
-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/Content.tsx26
-rw-r--r--src/components/ProfileManagement.tsx307
-rw-r--r--src/components/index.ts1
-rw-r--r--src/hooks/useProfileManagement.ts194
9 files changed, 1297 insertions, 119 deletions
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index 33f7b3e..60e54e0 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,99 @@ 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}"')
# 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 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}")
+ # Add game sections for each profile
+ for profile_name, config in profile_data["profiles"].items():
+ 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[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}")
+
+ 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 +274,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 +285,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 +332,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 +446,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/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/Content.tsx b/src/components/Content.tsx
index a075574..c7c757b 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,11 @@ export function Content() {
updateField
} = useLsfgConfig();
+ const {
+ currentProfile,
+ updateProfileConfig
+ } = useProfileManagement();
+
const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions();
// Reload config when installation status changes
@@ -40,7 +47,16 @@ 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 };
+ await updateProfileConfig(currentProfile, newConfig);
+ // Also update local config state
+ await updateField(fieldName, value);
+ } else {
+ // Fallback to the original method
+ await updateField(fieldName, value);
+ }
};
const onInstall = () => {
@@ -74,6 +90,14 @@ export function Content() {
<SmartClipboardButton />
+ {/* Profile Management - only show if installed */}
+ {isInstalled && (
+ <ProfileManagement
+ currentProfile={currentProfile}
+ onProfileChange={() => 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..a499836
--- /dev/null
+++ b/src/components/ProfileManagement.tsx
@@ -0,0 +1,307 @@
+import { useState, useEffect } from "react";
+import {
+ PanelSection,
+ PanelSectionRow,
+ Dropdown,
+ DropdownOption,
+ showModal,
+ ConfirmModal,
+ Field,
+ DialogButton,
+ Focusable,
+ ModalRoot,
+ TextField
+} 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 = () => {
+ onOK(value);
+ closeModal?.();
+ };
+
+ return (
+ <ModalRoot>
+ <div style={{ padding: "16px", minWidth: "300px" }}>
+ <h2 style={{ marginBottom: "16px" }}>{title}</h2>
+ <p style={{ marginBottom: "16px" }}>{description}</p>
+
+ <Field label="Name">
+ <TextField
+ value={value}
+ onChange={(e) => setValue(e?.target?.value || "")}
+ />
+ </Field>
+
+ <div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
+ <DialogButton onClick={handleOK} disabled={!value.trim()}>
+ {okText}
+ </DialogButton>
+ <DialogButton onClick={closeModal}>
+ {cancelText}
+ </DialogButton>
+ </div>
+ </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();
+ } 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 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 => ({
+ data: profile,
+ label: profile === "decky-lsfg-vk" ? `${profile} (default)` : profile
+ }));
+
+ return (
+ <PanelSection title="Profile Management">
+ <PanelSectionRow>
+ <Field label="Current Profile">
+ <Dropdown
+ rgOptions={profileOptions}
+ selectedOption={selectedProfile}
+ onChange={(option) => handleProfileChange(option.data)}
+ disabled={isLoading}
+ />
+ </Field>
+ </PanelSectionRow>
+
+ <PanelSectionRow>
+ <Focusable style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
+ <DialogButton
+ onClick={handleCreateProfile}
+ disabled={isLoading}
+ style={{ flex: "1", minWidth: "120px" }}
+ >
+ New Profile
+ </DialogButton>
+
+ <DialogButton
+ onClick={handleRenameProfile}
+ disabled={isLoading || selectedProfile === "decky-lsfg-vk"}
+ style={{ flex: "1", minWidth: "120px" }}
+ >
+ Rename
+ </DialogButton>
+
+ <DialogButton
+ onClick={handleDeleteProfile}
+ disabled={isLoading || selectedProfile === "decky-lsfg-vk"}
+ style={{ flex: "1", minWidth: "120px" }}
+ >
+ Delete
+ </DialogButton>
+ </Focusable>
+ </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..bb6cd6f
--- /dev/null
+++ b/src/hooks/useProfileManagement.ts
@@ -0,0 +1,194 @@
+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) {
+ // Only show success toast if this is the current profile
+ if (profileName === currentProfile) {
+ showSuccessToast("Configuration updated", `Updated configuration for profile: ${profileName}`);
+ }
+ 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
+ };
+}