summaryrefslogtreecommitdiff
path: root/py_modules
diff options
context:
space:
mode:
Diffstat (limited to 'py_modules')
-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
4 files changed, 744 insertions, 118 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]