summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2025-07-22 13:33:13 -0400
committerGitHub <noreply@github.com>2025-07-22 13:33:13 -0400
commit42668d412105b972e0cc7440b64e8e24aeef4587 (patch)
treec8f20ccbe508ac6684a38c115d63602476575493
parent97bb41947bd44a712ad26905771a9d2cc4692878 (diff)
parent43def41747d3b75bb547b649a00f12653c3ae537 (diff)
downloaddecky-lsfg-vk-42668d412105b972e0cc7440b64e8e24aeef4587.tar.gz
decky-lsfg-vk-42668d412105b972e0cc7440b64e8e24aeef4587.zip
Merge pull request #67 from xXJSONDeruloXx/workaround-envs
add workaround env vars, rm old tests
-rw-r--r--.gitignore5
-rw-r--r--justfile7
-rw-r--r--package.json2
-rw-r--r--py_modules/lsfg_vk/config_schema.py118
-rw-r--r--py_modules/lsfg_vk/config_schema_generated.py131
-rw-r--r--py_modules/lsfg_vk/configuration.py108
-rw-r--r--py_modules/lsfg_vk/configuration_helpers_generated.py22
-rw-r--r--py_modules/lsfg_vk/installation.py7
-rw-r--r--py_modules/lsfg_vk/plugin.py28
-rw-r--r--scripts/generate_python_boilerplate.py329
-rw-r--r--scripts/generate_ts_schema.py158
-rw-r--r--shared_config.py43
-rw-r--r--src/api/lsfgApi.ts11
-rw-r--r--src/components/ConfigurationSection.tsx63
-rw-r--r--src/config/configSchema.ts27
-rw-r--r--src/config/generatedConfigSchema.ts31
-rw-r--r--tests/conftest.py31
-rw-r--r--tests/test_configuration.py190
-rw-r--r--tests/test_dll_detection.py129
-rw-r--r--tests/test_installation.py150
20 files changed, 880 insertions, 710 deletions
diff --git a/.gitignore b/.gitignore
index d384256..0297e2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,8 @@ out/*
cli/
cli/*
cli/decky
+
+# generated files
+py_modules/lsfg_vk/config_schema_generated.py
+py_modules/lsfg_vk/configuration_helpers_generated.py
+src/config/generatedConfigSchema.ts
diff --git a/justfile b/justfile
index 9923416..054c113 100644
--- a/justfile
+++ b/justfile
@@ -1,8 +1,11 @@
default:
- echo "Available recipes: build, test, clean"
+ echo "Available recipes: build, test, clean, generate-schema"
+
+generate-schema:
+ python3 scripts/generate_ts_schema.py
build:
- sudo rm -rf node_modules && .vscode/build.sh
+ python3 scripts/generate_ts_schema.py && sudo rm -rf node_modules && .vscode/build.sh
test:
scp "out/Lossless Scaling.zip" deck@192.168.0.6:~/Desktop
diff --git a/package.json b/package.json
index 9fcf10d..ee18530 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "decky-lossless-scaling-vk",
- "version": "0.6.9",
+ "version": "0.6.10",
"description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer",
"type": "module",
"scripts": {
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index c82d1d3..bbace42 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -19,6 +19,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_field_names, get_defaults, get_field_types
+# Import auto-generated configuration components
+from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic
+
@dataclass
class ConfigField:
@@ -52,45 +55,27 @@ CONFIG_SCHEMA["dll"] = ConfigField(
description="specify where Lossless.dll is stored"
)
-# Fields that should ONLY be in the lsfg script, not in TOML config
+# Get script-only fields dynamically from shared config
SCRIPT_ONLY_FIELDS = {
- "dxvk_frame_rate": ConfigField(
- name="dxvk_frame_rate",
- field_type=ConfigFieldType.INTEGER,
- default=0,
- description="base framerate cap for DirectX games, before frame multiplier (0 = disabled, requires game re-launch)"
- ),
-
- "enable_wow64": ConfigField(
- name="enable_wow64",
- field_type=ConfigFieldType.BOOLEAN,
- default=False,
- description="enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)"
- ),
-
- "disable_steamdeck_mode": ConfigField(
- name="disable_steamdeck_mode",
- field_type=ConfigFieldType.BOOLEAN,
- default=False,
- description="disable Steam Deck mode (unlocks hidden settings in some games)"
+ field_name: ConfigField(
+ name=field_def["name"],
+ field_type=ConfigFieldType(field_def["fieldType"]),
+ default=field_def["default"],
+ description=field_def["description"]
)
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items()
+ if field_def.get("location") == "script"
}
# Complete configuration schema (TOML + script-only fields)
COMPLETE_CONFIG_SCHEMA = {**CONFIG_SCHEMA, **SCRIPT_ONLY_FIELDS}
-class ConfigurationData(TypedDict):
- """Type-safe configuration data structure"""
- dll: str
- multiplier: int
- flow_scale: float
- performance_mode: bool
- hdr_mode: bool
- experimental_present_mode: str
- dxvk_frame_rate: int
- enable_wow64: bool
- disable_steamdeck_mode: bool
+# Import auto-generated configuration components
+from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic
+
+# Note: ConfigurationData is now imported from generated file
+# No need to manually maintain the TypedDict anymore!
class ConfigurationManager:
@@ -186,7 +171,9 @@ class ConfigurationManager:
if config.get("dll"):
lines.append("[global]")
lines.append(f"# specify where Lossless.dll is stored")
- lines.append(f'dll = "{config["dll"]}"')
+ # Generate TOML lines for TOML fields only - USE GENERATED CONSTANTS
+ from .config_schema_generated import DLL
+ lines.append(f'dll = "{config[DLL]}"')
lines.append("")
# Add game section with process name for LSFG_PROCESS approach
@@ -263,9 +250,10 @@ class ConfigurationManager:
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
- # Handle global section (dll only)
+ # Handle global section (dll only) - USE GENERATED CONSTANTS
if in_global_section and key == "dll":
- config["dll"] = value
+ from .config_schema_generated import DLL
+ config[DLL] = value
# Handle game section
elif in_game_section:
@@ -305,42 +293,9 @@ class ConfigurationManager:
Returns:
Dict containing parsed script-only field values
"""
- script_values = {}
-
- try:
- lines = script_content.split('\n')
-
- for line in lines:
- line = line.strip()
-
- # Skip comments, empty lines, and non-export lines
- if not line or line.startswith('#') or not line.startswith('export '):
- continue
-
- # Parse export statements: export VAR=value
- if '=' in line:
- # Remove 'export ' prefix
- export_line = line[len('export '):]
- key, value = export_line.split('=', 1)
- key = key.strip()
- value = value.strip()
-
- # Map environment variables to config field names
- if key == "DXVK_FRAME_RATE":
- try:
- script_values["dxvk_frame_rate"] = int(value)
- except ValueError:
- pass
- elif key == "PROTON_USE_WOW64":
- script_values["enable_wow64"] = value == "1"
- elif key == "SteamDeck":
- script_values["disable_steamdeck_mode"] = value == "0"
-
- except (ValueError, KeyError, IndexError) as e:
- # If parsing fails, log the error and return empty dict (will use defaults)
- print(f"Error parsing script content: {e}")
-
- return script_values
+ # Use auto-generated parsing logic
+ parse_script_values = get_script_parsing_logic()
+ return parse_script_values(script_content.split('\n'))
@staticmethod
def merge_config_with_script(toml_config: ConfigurationData, script_values: Dict[str, Union[bool, int, str]]) -> ConfigurationData:
@@ -363,21 +318,8 @@ class ConfigurationManager:
return cast(ConfigurationData, merged_config)
@staticmethod
- def create_config_from_args(dll: str, multiplier: int, flow_scale: float,
- performance_mode: bool, hdr_mode: bool,
- experimental_present_mode: str = "fifo",
- dxvk_frame_rate: int = 0,
- enable_wow64: bool = False,
- disable_steamdeck_mode: bool = False) -> ConfigurationData:
- """Create configuration from individual arguments"""
- return cast(ConfigurationData, {
- "dll": dll,
- "multiplier": multiplier,
- "flow_scale": flow_scale,
- "performance_mode": performance_mode,
- "hdr_mode": hdr_mode,
- "experimental_present_mode": experimental_present_mode,
- "dxvk_frame_rate": dxvk_frame_rate,
- "enable_wow64": enable_wow64,
- "disable_steamdeck_mode": disable_steamdeck_mode
- })
+ @staticmethod
+ def create_config_from_args(**kwargs) -> ConfigurationData:
+ """Create configuration from keyword arguments - USES GENERATED CODE"""
+ from .config_schema_generated import create_config_dict
+ return create_config_dict(**kwargs)
diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py
new file mode 100644
index 0000000..cc90207
--- /dev/null
+++ b/py_modules/lsfg_vk/config_schema_generated.py
@@ -0,0 +1,131 @@
+"""
+Auto-generated configuration schema components from shared_config.py
+DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build
+"""
+
+from typing import TypedDict, Dict, Any, Union, cast
+from enum import Enum
+import sys
+from pathlib import Path
+
+# Import shared configuration constants
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType
+
+# Field name constants for type-safe access
+DLL = "dll"
+MULTIPLIER = "multiplier"
+FLOW_SCALE = "flow_scale"
+PERFORMANCE_MODE = "performance_mode"
+HDR_MODE = "hdr_mode"
+EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode"
+DXVK_FRAME_RATE = "dxvk_frame_rate"
+ENABLE_WOW64 = "enable_wow64"
+DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode"
+MANGOHUD_WORKAROUND = "mangohud_workaround"
+DISABLE_VKBASALT = "disable_vkbasalt"
+
+
+class ConfigurationData(TypedDict):
+ """Type-safe configuration data structure - AUTO-GENERATED"""
+ dll: str
+ multiplier: int
+ flow_scale: float
+ performance_mode: bool
+ hdr_mode: bool
+ experimental_present_mode: str
+ dxvk_frame_rate: int
+ enable_wow64: bool
+ disable_steamdeck_mode: bool
+ mangohud_workaround: bool
+ disable_vkbasalt: bool
+
+
+def get_script_parsing_logic():
+ """Return the script parsing logic as a callable"""
+ def parse_script_values(lines):
+ script_values = {}
+ for line in lines:
+ line = line.strip()
+ if not line or line.startswith("#") or not line.startswith("export "):
+ continue
+ if "=" in line:
+ export_line = line[len("export "):]
+ key, value = export_line.split("=", 1)
+ key = key.strip()
+ value = value.strip()
+
+ # Auto-generated parsing logic:
+ if key == "DXVK_FRAME_RATE":
+ try:
+ script_values["dxvk_frame_rate"] = int(value)
+ except ValueError:
+ pass
+ if key == "PROTON_USE_WOW64":
+ script_values["enable_wow64"] = value == "1"
+ if key == "SteamDeck":
+ script_values["disable_steamdeck_mode"] = value == "0"
+ if key == "MANGOHUD":
+ script_values["mangohud_workaround"] = value == "1"
+ if key == "DISABLE_VKBASALT":
+ script_values["disable_vkbasalt"] = value == "1"
+
+ return script_values
+ return parse_script_values
+
+
+def get_script_generation_logic():
+ """Return the script generation logic as a callable"""
+ def generate_script_lines(config):
+ lines = []
+ dxvk_frame_rate = config.get("dxvk_frame_rate", 0)
+ if dxvk_frame_rate > 0:
+ lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}")
+ if config.get("enable_wow64", False):
+ lines.append("export PROTON_USE_WOW64=1")
+ if config.get("disable_steamdeck_mode", False):
+ lines.append("export SteamDeck=0")
+ if config.get("mangohud_workaround", False):
+ lines.append("export MANGOHUD=1")
+ if config.get("disable_vkbasalt", False):
+ lines.append("export DISABLE_VKBASALT=1")
+ return lines
+ return generate_script_lines
+
+
+def get_function_parameters() -> str:
+ """Return function signature parameters"""
+ return """dll: str = "/games/Lossless Scaling/Lossless.dll",
+ multiplier: int = 1,
+ flow_scale: float = 0.8,
+ performance_mode: bool = True,
+ hdr_mode: bool = False,
+ experimental_present_mode: str = "fifo",
+ dxvk_frame_rate: int = 0,
+ enable_wow64: bool = False,
+ disable_steamdeck_mode: bool = False,
+ mangohud_workaround: bool = False,
+ disable_vkbasalt: bool = False"""
+
+
+def create_config_dict(**kwargs) -> ConfigurationData:
+ """Create configuration dictionary from keyword arguments"""
+ return cast(ConfigurationData, {
+ "dll": kwargs.get("dll"),
+ "multiplier": kwargs.get("multiplier"),
+ "flow_scale": kwargs.get("flow_scale"),
+ "performance_mode": kwargs.get("performance_mode"),
+ "hdr_mode": kwargs.get("hdr_mode"),
+ "experimental_present_mode": kwargs.get("experimental_present_mode"),
+ "dxvk_frame_rate": kwargs.get("dxvk_frame_rate"),
+ "enable_wow64": kwargs.get("enable_wow64"),
+ "disable_steamdeck_mode": kwargs.get("disable_steamdeck_mode"),
+ "mangohud_workaround": kwargs.get("mangohud_workaround"),
+ "disable_vkbasalt": kwargs.get("disable_vkbasalt"),
+ })
+
+
+# Field lists for dynamic operations
+TOML_FIELDS = ['dll', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode']
+SCRIPT_FIELDS = ['dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt']
+ALL_FIELDS = ['dll', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt']
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index 47d0ebc..b9ee174 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -6,7 +6,9 @@ from pathlib import Path
from typing import Dict, Any
from .base_service import BaseService
-from .config_schema import ConfigurationManager, ConfigurationData, CONFIG_SCHEMA
+from .config_schema import ConfigurationManager, CONFIG_SCHEMA
+from .config_schema_generated import ConfigurationData, get_script_generation_logic
+from .configuration_helpers_generated import log_configuration_update
from .types import ConfigurationResponse
@@ -60,34 +62,59 @@ class ConfigurationService(BaseService):
f"Using default configuration due to parse error: {str(e)}",
config=config)
- def update_config(self, dll: str, multiplier: int, flow_scale: float,
- performance_mode: bool, hdr_mode: bool,
- experimental_present_mode: str = "fifo",
- dxvk_frame_rate: int = 0,
- enable_wow64: bool = False,
- disable_steamdeck_mode: bool = False) -> ConfigurationResponse:
- """Update TOML configuration
+ def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse:
+ """Update TOML configuration from configuration dictionary (eliminates parameter duplication)
Args:
- dll: Path to Lossless.dll
- multiplier: LSFG multiplier value
- flow_scale: LSFG flow scale value
- performance_mode: Whether to enable performance mode
- hdr_mode: Whether to enable HDR mode
- experimental_present_mode: Experimental Vulkan present mode override
- dxvk_frame_rate: Frame rate cap for DirectX games, before frame multiplier (0 = disabled)
- enable_wow64: Whether to enable PROTON_USE_WOW64=1 for 32-bit games
- disable_steamdeck_mode: Whether to disable Steam Deck mode
+ config: Complete configuration data dictionary
Returns:
ConfigurationResponse with success status
"""
try:
- # Create configuration from individual arguments
- config = ConfigurationManager.create_config_from_args(
- dll, multiplier, flow_scale, performance_mode, hdr_mode,
- experimental_present_mode, dxvk_frame_rate, enable_wow64, disable_steamdeck_mode
- )
+ # 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']}")
+
+ # 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)
+
+ except (OSError, IOError) as e:
+ error_msg = f"Error updating lsfg config: {str(e)}"
+ self.log.error(error_msg)
+ return self._error_response(ConfigurationResponse, str(e), config=None)
+ except ValueError as e:
+ error_msg = f"Invalid configuration arguments: {str(e)}"
+ self.log.error(error_msg)
+ return self._error_response(ConfigurationResponse, str(e), config=None)
+
+ def update_config(self, **kwargs) -> ConfigurationResponse:
+ """Update TOML configuration using generated schema - SIMPLIFIED WITH GENERATED CODE
+
+ Args:
+ **kwargs: Configuration field values (see shared_config.py for available fields)
+
+ Returns:
+ ConfigurationResponse with success status
+ """
+ try:
+ # 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)
@@ -103,12 +130,8 @@ class ConfigurationService(BaseService):
if not script_result["success"]:
self.log.warning(f"Failed to update launch script: {script_result['error']}")
- self.log.info(f"Updated lsfg TOML configuration: "
- f"dll='{dll}', multiplier={multiplier}, flow_scale={flow_scale}, "
- f"performance_mode={performance_mode}, hdr_mode={hdr_mode}, "
- f"experimental_present_mode='{experimental_present_mode}', "
- f"dxvk_frame_rate={dxvk_frame_rate}, "
- f"enable_wow64={enable_wow64}, disable_steamdeck_mode={disable_steamdeck_mode}")
+ # Use auto-generated logging
+ log_configuration_update(self.log, config)
return self._success_response(ConfigurationResponse,
"lsfg configuration updated successfully",
@@ -143,8 +166,9 @@ class ConfigurationService(BaseService):
else:
config = current_response["config"]
- # Update just the DLL path
- config["dll"] = dll_path
+ # Update just the DLL path - USE GENERATED CONSTANTS
+ from .config_schema_generated import DLL
+ config[DLL] = dll_path
# Generate TOML content and write it
toml_content = ConfigurationManager.generate_toml_content(config)
@@ -207,22 +231,14 @@ class ConfigurationService(BaseService):
"# This script sets up the environment for lsfg-vk to work with the plugin configuration"
]
- # Add optional export statements based on configuration
- if config.get("enable_wow64", False):
- lines.append("export PROTON_USE_WOW64=1")
-
- if config.get("disable_steamdeck_mode", False):
- lines.append("export SteamDeck=0")
-
- # Add DXVK_FRAME_RATE if dxvk_frame_rate is set
- dxvk_frame_rate = config.get("dxvk_frame_rate", 0)
- if dxvk_frame_rate > 0:
- lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}")
-
- # Always add the LSFG_PROCESS export
- lines.append("export LSFG_PROCESS=decky-lsfg-vk")
+ # Use auto-generated script generation logic
+ generate_script_lines = get_script_generation_logic()
+ lines.extend(generate_script_lines(config))
- # Add the execution line
- lines.append('exec "$@"')
+ # Always add the LSFG_PROCESS export and execution line
+ lines.extend([
+ "export LSFG_PROCESS=decky-lsfg-vk",
+ 'exec "$@"'
+ ])
return "\n".join(lines) + "\n"
diff --git a/py_modules/lsfg_vk/configuration_helpers_generated.py b/py_modules/lsfg_vk/configuration_helpers_generated.py
new file mode 100644
index 0000000..f9f4a65
--- /dev/null
+++ b/py_modules/lsfg_vk/configuration_helpers_generated.py
@@ -0,0 +1,22 @@
+"""
+Auto-generated configuration helper functions from shared_config.py
+DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build
+"""
+
+from typing import Dict, Any
+from .config_schema_generated import ConfigurationData, ALL_FIELDS
+
+
+def log_configuration_update(logger, config: ConfigurationData) -> None:
+ """Log configuration update with all field values"""
+ logger.info(f"Updated lsfg TOML configuration: dll={config['dll']}, multiplier={config['multiplier']}, flow_scale={config['flow_scale']}, performance_mode={config['performance_mode']}, hdr_mode={config['hdr_mode']}, experimental_present_mode={config['experimental_present_mode']}, dxvk_frame_rate={config['dxvk_frame_rate']}, enable_wow64={config['enable_wow64']}, disable_steamdeck_mode={config['disable_steamdeck_mode']}, mangohud_workaround={config['mangohud_workaround']}, disable_vkbasalt={config['disable_vkbasalt']}")
+
+
+def get_config_field_names() -> list[str]:
+ """Get all configuration field names"""
+ return ALL_FIELDS.copy()
+
+
+def extract_config_values(config: ConfigurationData) -> Dict[str, Any]:
+ """Extract configuration values as a dictionary"""
+ return {field: config[field] for field in ALL_FIELDS}
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index b340093..996a03f 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -121,9 +121,10 @@ class InstallationService(BaseService):
self._write_file(self.config_file_path, toml_content, 0o644)
self.log.info(f"Created config file at {self.config_file_path}")
- # Log detected DLL path if found
- if config["dll"]:
- self.log.info(f"Configured DLL path: {config['dll']}")
+ # Log detected DLL path if found - USE GENERATED CONSTANTS
+ from .config_schema_generated import DLL
+ if config[DLL]:
+ self.log.info(f"Configured DLL path: {config[DLL]}")
def _create_lsfg_launch_script(self) -> None:
"""Create the ~/lsfg launch script for easier game setup"""
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index 425d7e7..8fa2435 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -184,32 +184,20 @@ class Plugin:
"defaults": ConfigurationManager.get_defaults()
}
- async def update_lsfg_config(self, dll: str, multiplier: int, flow_scale: float,
- performance_mode: bool, hdr_mode: bool,
- experimental_present_mode: str = "fifo",
- dxvk_frame_rate: int = 0,
- enable_wow64: bool = False,
- disable_steamdeck_mode: bool = False) -> Dict[str, Any]:
- """Update lsfg TOML configuration
+ async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
+ """Update lsfg TOML configuration using object-based API (single source of truth)
Args:
- dll: Path to Lossless.dll
- multiplier: LSFG multiplier value
- flow_scale: LSFG flow scale value
- performance_mode: Whether to enable performance mode
- hdr_mode: Whether to enable HDR mode
- experimental_present_mode: Experimental Vulkan present mode override
- dxvk_frame_rate: Frame rate cap for DirectX games, before frame multiplier (0 = disabled)
- enable_wow64: Whether to enable PROTON_USE_WOW64=1 for 32-bit games
- disable_steamdeck_mode: Whether to disable Steam Deck mode
+ config: Configuration data dictionary containing all settings
Returns:
ConfigurationResponse dict with success status
"""
- return self.configuration_service.update_config(
- dll, multiplier, flow_scale, performance_mode, hdr_mode,
- experimental_present_mode, dxvk_frame_rate, enable_wow64, disable_steamdeck_mode
- )
+ # Validate and extract configuration from the config dict
+ validated_config = ConfigurationManager.validate_config(config)
+
+ # Use dynamic parameter passing based on schema
+ return self.configuration_service.update_config_from_dict(validated_config)
async def update_dll_path(self, dll_path: str) -> Dict[str, Any]:
"""Update the DLL path in the configuration when detected
diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py
new file mode 100644
index 0000000..b16aa3f
--- /dev/null
+++ b/scripts/generate_python_boilerplate.py
@@ -0,0 +1,329 @@
+#!/usr/bin/env python3
+"""
+Generate Python boilerplate from shared_config.py
+
+This script generates repetitive Python code patterns from the canonical schema,
+reducing manual maintenance when adding/removing configuration fields.
+"""
+
+import sys
+from pathlib import Path
+
+# Add project root to path to import shared_config
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root))
+
+from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType
+
+
+def get_python_type(field_type: ConfigFieldType) -> str:
+ """Convert ConfigFieldType to Python type annotation"""
+ type_map = {
+ ConfigFieldType.BOOLEAN: "bool",
+ ConfigFieldType.INTEGER: "int",
+ ConfigFieldType.FLOAT: "float",
+ ConfigFieldType.STRING: "str"
+ }
+ return type_map.get(field_type, "Any")
+
+
+def get_env_var_name(field_name: str) -> str:
+ """Convert field name to environment variable name"""
+ env_map = {
+ "dxvk_frame_rate": "DXVK_FRAME_RATE",
+ "enable_wow64": "PROTON_USE_WOW64",
+ "disable_steamdeck_mode": "SteamDeck",
+ "mangohud_workaround": "MANGOHUD",
+ "disable_vkbasalt": "DISABLE_VKBASALT"
+ }
+ return env_map.get(field_name, field_name.upper())
+
+
+def generate_typed_dict() -> str:
+ """Generate ConfigurationData TypedDict"""
+ lines = [
+ "class ConfigurationData(TypedDict):",
+ " \"\"\"Type-safe configuration data structure - AUTO-GENERATED\"\"\""
+ ]
+
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ python_type = get_python_type(ConfigFieldType(field_def["fieldType"]))
+ lines.append(f" {field_name}: {python_type}")
+
+ return "\n".join(lines)
+
+
+def generate_function_signature() -> str:
+ """Generate function signature for update_config and create_config_from_args"""
+ params = []
+
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ python_type = get_python_type(ConfigFieldType(field_def["fieldType"]))
+ default = field_def["default"]
+
+ # Format default value
+ if isinstance(default, str):
+ default_str = f'"{default}"'
+ elif isinstance(default, bool):
+ default_str = str(default)
+ else:
+ default_str = str(default)
+
+ params.append(f"{field_name}: {python_type} = {default_str}")
+
+ return ",\n ".join(params)
+
+
+def generate_config_dict_creation() -> str:
+ """Generate dictionary creation for create_config_from_args"""
+ lines = [" return cast(ConfigurationData, {"]
+
+ for field_name in CONFIG_SCHEMA_DEF.keys():
+ lines.append(f' "{field_name}": kwargs.get("{field_name}"),')
+
+ lines.append(" })")
+ return "\n".join(lines)
+
+
+def generate_script_parsing() -> str:
+ """Generate script content parsing logic"""
+ lines = []
+
+ script_fields = [
+ (field_name, field_def)
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items()
+ if field_def.get("location") == "script"
+ ]
+
+ for field_name, field_def in script_fields:
+ env_var = get_env_var_name(field_name)
+ field_type = ConfigFieldType(field_def["fieldType"])
+
+ if field_type == ConfigFieldType.BOOLEAN:
+ if field_name == "disable_steamdeck_mode":
+ # Special case: SteamDeck=0 means disable_steamdeck_mode=True
+ lines.append(f' elif key == "{env_var}":')
+ lines.append(f' script_values["{field_name}"] = value == "0"')
+ else:
+ lines.append(f' elif key == "{env_var}":')
+ lines.append(f' script_values["{field_name}"] = value == "1"')
+ elif field_type == ConfigFieldType.INTEGER:
+ lines.append(f' elif key == "{env_var}":')
+ lines.append(' try:')
+ lines.append(f' script_values["{field_name}"] = int(value)')
+ lines.append(' except ValueError:')
+ lines.append(' pass')
+ elif field_type == ConfigFieldType.FLOAT:
+ lines.append(f' elif key == "{env_var}":')
+ lines.append(' try:')
+ lines.append(f' script_values["{field_name}"] = float(value)')
+ lines.append(' except ValueError:')
+ lines.append(' pass')
+ elif field_type == ConfigFieldType.STRING:
+ lines.append(f' elif key == "{env_var}":')
+ lines.append(f' script_values["{field_name}"] = value')
+
+ return "\n".join(lines)
+
+
+def generate_script_generation() -> str:
+ """Generate script content generation logic"""
+ lines = []
+
+ script_fields = [
+ (field_name, field_def)
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items()
+ if field_def.get("location") == "script"
+ ]
+
+ for field_name, field_def in script_fields:
+ env_var = get_env_var_name(field_name)
+ field_type = ConfigFieldType(field_def["fieldType"])
+
+ if field_type == ConfigFieldType.BOOLEAN:
+ if field_name == "disable_steamdeck_mode":
+ # Special case: disable_steamdeck_mode=True should export SteamDeck=0
+ lines.append(f' if config.get("{field_name}", False):')
+ lines.append(f' lines.append("export {env_var}=0")')
+ else:
+ lines.append(f' if config.get("{field_name}", False):')
+ lines.append(f' lines.append("export {env_var}=1")')
+ elif field_type in [ConfigFieldType.INTEGER, ConfigFieldType.FLOAT]:
+ default = field_def["default"]
+ if field_name == "dxvk_frame_rate":
+ # Special handling for DXVK_FRAME_RATE (only export if > 0)
+ lines.append(f' {field_name} = config.get("{field_name}", {default})')
+ lines.append(f' if {field_name} > 0:')
+ lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")')
+ else:
+ lines.append(f' {field_name} = config.get("{field_name}", {default})')
+ lines.append(f' if {field_name} != {default}:')
+ lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")')
+ elif field_type == ConfigFieldType.STRING:
+ lines.append(f' {field_name} = config.get("{field_name}", "")')
+ lines.append(f' if {field_name}:')
+ lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")')
+
+ return "\n".join(lines)
+
+
+def generate_log_statement() -> str:
+ """Generate logging statement with all field values"""
+ field_parts = []
+
+ for field_name in CONFIG_SCHEMA_DEF.keys():
+ field_parts.append(f"{field_name}={{{field_name}}}")
+
+ log_format = ", ".join(field_parts)
+ return f' self.log.info(f"Updated lsfg TOML configuration: {log_format}")'
+
+
+def generate_complete_schema_file() -> str:
+ """Generate complete config_schema_generated.py file"""
+
+ # Generate field name constants
+ field_constants = []
+ for field_name in CONFIG_SCHEMA_DEF.keys():
+ const_name = field_name.upper()
+ field_constants.append(f'{const_name} = "{field_name}"')
+
+ lines = [
+ '"""',
+ 'Auto-generated configuration schema components from shared_config.py',
+ 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build',
+ '"""',
+ '',
+ 'from typing import TypedDict, Dict, Any, Union, cast',
+ 'from enum import Enum',
+ 'import sys',
+ 'from pathlib import Path',
+ '',
+ '# Import shared configuration constants',
+ 'sys.path.insert(0, str(Path(__file__).parent.parent.parent))',
+ 'from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType',
+ '',
+ '# Field name constants for type-safe access',
+ ] + field_constants + [
+ '',
+ '',
+ generate_typed_dict(),
+ '',
+ '',
+ 'def get_script_parsing_logic():',
+ ' """Return the script parsing logic as a callable"""',
+ ' def parse_script_values(lines):',
+ ' script_values = {}',
+ ' for line in lines:',
+ ' line = line.strip()',
+ ' if not line or line.startswith("#") or not line.startswith("export "):',
+ ' continue',
+ ' if "=" in line:',
+ ' export_line = line[len("export "):]',
+ ' key, value = export_line.split("=", 1)',
+ ' key = key.strip()',
+ ' value = value.strip()',
+ '',
+ ' # Auto-generated parsing logic:',
+ f'{generate_script_parsing().replace(" elif", " if")}',
+ '',
+ ' return script_values',
+ ' return parse_script_values',
+ '',
+ '',
+ 'def get_script_generation_logic():',
+ ' """Return the script generation logic as a callable"""',
+ ' def generate_script_lines(config):',
+ ' lines = []',
+ f'{generate_script_generation()}',
+ ' return lines',
+ ' return generate_script_lines',
+ '',
+ '',
+ 'def get_function_parameters() -> str:',
+ ' """Return function signature parameters"""',
+ f' return """{generate_function_signature()}"""',
+ '',
+ '',
+ 'def create_config_dict(**kwargs) -> ConfigurationData:',
+ ' """Create configuration dictionary from keyword arguments"""',
+ f'{generate_config_dict_creation().replace(" return cast(ConfigurationData, {", " return cast(ConfigurationData, {").replace(" })", " })")}',
+ '',
+ '',
+ '# Field lists for dynamic operations',
+ f'TOML_FIELDS = {[name for name, field in CONFIG_SCHEMA_DEF.items() if field.get("location") == "toml"]}',
+ f'SCRIPT_FIELDS = {[name for name, field in CONFIG_SCHEMA_DEF.items() if field.get("location") == "script"]}',
+ f'ALL_FIELDS = {list(CONFIG_SCHEMA_DEF.keys())}',
+ ''
+ ]
+
+ return '\n'.join(lines)
+
+
+def generate_complete_configuration_helpers() -> str:
+ """Generate configuration_helpers_generated.py file"""
+
+ # Generate the log format string using config parameter
+ log_parts = []
+ for field_name in CONFIG_SCHEMA_DEF.keys():
+ log_parts.append(f"{field_name}={{config['{field_name}']}}")
+ log_format = ", ".join(log_parts)
+
+ lines = [
+ '"""',
+ 'Auto-generated configuration helper functions from shared_config.py',
+ 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build',
+ '"""',
+ '',
+ 'from typing import Dict, Any',
+ 'from .config_schema_generated import ConfigurationData, ALL_FIELDS',
+ '',
+ '',
+ 'def log_configuration_update(logger, config: ConfigurationData) -> None:',
+ ' """Log configuration update with all field values"""',
+ f' logger.info(f"Updated lsfg TOML configuration: {log_format}")',
+ '',
+ '',
+ 'def get_config_field_names() -> list[str]:',
+ ' """Get all configuration field names"""',
+ ' return ALL_FIELDS.copy()',
+ '',
+ '',
+ 'def extract_config_values(config: ConfigurationData) -> Dict[str, Any]:',
+ ' """Extract configuration values as a dictionary"""',
+ ' return {field: config[field] for field in ALL_FIELDS}',
+ ''
+ ]
+
+ return '\n'.join(lines)
+
+
+def main():
+ """Generate complete Python configuration files"""
+ try:
+ # Create generated files in py_modules/lsfg_vk/
+ target_dir = project_root / "py_modules" / "lsfg_vk"
+
+ # Generate the complete schema file
+ schema_content = generate_complete_schema_file()
+ schema_file = target_dir / "config_schema_generated.py"
+ schema_file.write_text(schema_content)
+ print(f"āœ… Generated {schema_file.relative_to(project_root)}")
+
+ # Generate configuration helpers
+ helpers_content = generate_complete_configuration_helpers()
+ helpers_file = target_dir / "configuration_helpers_generated.py"
+ helpers_file.write_text(helpers_content)
+ print(f"āœ… Generated {helpers_file.relative_to(project_root)}")
+
+ print(f"\nšŸŽÆ Ready-to-use files generated!")
+ print(" Import these in your main files:")
+ print(" - from .config_schema_generated import ConfigurationData, get_script_parsing_logic, etc.")
+ print(" - from .configuration_helpers_generated import log_configuration_update, etc.")
+
+ except Exception as e:
+ print(f"āŒ Error generating Python files: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py
new file mode 100644
index 0000000..c4c0e8a
--- /dev/null
+++ b/scripts/generate_ts_schema.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""
+Generate TypeScript schema from Python shared_config.py
+
+This script reads the canonical schema from shared_config.py and generates
+the corresponding TypeScript files, ensuring single source of truth.
+"""
+
+import sys
+from pathlib import Path
+
+# Add project root to path to import shared_config
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root))
+
+from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType
+
+
+def generate_typescript_schema():
+ """Generate generatedConfigSchema.ts from Python schema"""
+
+ # Generate field name constants
+ field_constants = []
+ for field_name in CONFIG_SCHEMA_DEF.keys():
+ const_name = field_name.upper()
+ field_constants.append(f'export const {const_name} = "{field_name}" as const;')
+
+ # Generate enum
+ enum_lines = [
+ "// src/config/generatedConfigSchema.ts",
+ "// Configuration field type enum - matches Python",
+ "export enum ConfigFieldType {",
+ " BOOLEAN = \"boolean\",",
+ " INTEGER = \"integer\",",
+ " FLOAT = \"float\",",
+ " STRING = \"string\"",
+ "}",
+ "",
+ "// Field name constants for type-safe access",
+ ] + field_constants + [
+ "",
+ "// Configuration field definition",
+ "export interface ConfigField {",
+ " name: string;",
+ " fieldType: ConfigFieldType;",
+ " default: boolean | number | string;",
+ " description: string;",
+ "}",
+ "",
+ "// Configuration schema - auto-generated from Python",
+ "export const CONFIG_SCHEMA: Record<string, ConfigField> = {"
+ ]
+
+ # Generate schema entries
+ schema_entries = []
+ interface_fields = []
+ defaults_fields = []
+ field_types = []
+
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ # Schema entry
+ default_value = field_def["default"]
+ if isinstance(default_value, str):
+ default_str = f'"{default_value}"'
+ elif isinstance(default_value, bool):
+ default_str = "true" if default_value else "false"
+ else:
+ default_str = str(default_value)
+
+ schema_entries.append(f' {field_name}: {{')
+ schema_entries.append(f' name: "{field_def["name"]}",')
+ schema_entries.append(f' fieldType: ConfigFieldType.{field_def["fieldType"].upper()},')
+ schema_entries.append(f' default: {default_str},')
+ schema_entries.append(f' description: "{field_def["description"]}"')
+ schema_entries.append(' },')
+
+ # Interface field
+ if field_def["fieldType"] == ConfigFieldType.BOOLEAN:
+ ts_type = "boolean"
+ elif field_def["fieldType"] == ConfigFieldType.INTEGER:
+ ts_type = "number"
+ elif field_def["fieldType"] == ConfigFieldType.FLOAT:
+ ts_type = "number"
+ elif field_def["fieldType"] == ConfigFieldType.STRING:
+ ts_type = "string"
+ else:
+ ts_type = "any"
+
+ interface_fields.append(f' {field_name}: {ts_type};')
+ defaults_fields.append(f' {field_name}: {default_str},')
+ field_types.append(f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},')
+
+ # Complete the file
+ all_lines = enum_lines + schema_entries + [
+ "};",
+ "",
+ "// Type-safe configuration data structure",
+ "export interface ConfigurationData {",
+ ] + interface_fields + [
+ "}",
+ "",
+ "// Helper functions",
+ "export function getFieldNames(): string[] {",
+ " return Object.keys(CONFIG_SCHEMA);",
+ "}",
+ "",
+ "export function getDefaults(): ConfigurationData {",
+ " return {",
+ ] + defaults_fields + [
+ " };",
+ "}",
+ "",
+ "export function getFieldTypes(): Record<string, ConfigFieldType> {",
+ " return {",
+ ] + field_types + [
+ " };",
+ "}",
+ "",
+ ""
+ ]
+
+ return "\n".join(all_lines)
+
+
+def main():
+ """Main function to generate TypeScript schema and Python boilerplate"""
+ try:
+ # Generate the TypeScript content
+ ts_content = generate_typescript_schema()
+
+ # Write to the target file
+ target_file = project_root / "src" / "config" / "generatedConfigSchema.ts"
+ target_file.write_text(ts_content)
+
+ print(f"āœ… Generated {target_file} from shared_config.py")
+ print(f" Fields: {len(CONFIG_SCHEMA_DEF)}")
+
+ # Also generate Python boilerplate
+ print("\nšŸ”„ Generating Python boilerplate...")
+ from pathlib import Path
+ import subprocess
+
+ boilerplate_script = project_root / "scripts" / "generate_python_boilerplate.py"
+ result = subprocess.run([sys.executable, str(boilerplate_script)],
+ capture_output=True, text=True)
+
+ if result.returncode == 0:
+ print(result.stdout)
+ else:
+ print(f"āš ļø Python boilerplate generation had issues:\n{result.stderr}")
+
+ except Exception as e:
+ print(f"āŒ Error generating schema: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shared_config.py b/shared_config.py
index 46bfc84..68c7b82 100644
--- a/shared_config.py
+++ b/shared_config.py
@@ -24,63 +24,88 @@ CONFIG_SCHEMA_DEF = {
"name": "dll",
"fieldType": ConfigFieldType.STRING,
"default": "/games/Lossless Scaling/Lossless.dll",
- "description": "specify where Lossless.dll is stored"
+ "description": "specify where Lossless.dll is stored",
+ "location": "toml" # where this field is stored/used
},
"multiplier": {
"name": "multiplier",
"fieldType": ConfigFieldType.INTEGER,
"default": 1,
- "description": "change the fps multiplier"
+ "description": "change the fps multiplier",
+ "location": "toml"
},
"flow_scale": {
"name": "flow_scale",
"fieldType": ConfigFieldType.FLOAT,
"default": 0.8,
- "description": "change the flow scale"
+ "description": "change the flow scale",
+ "location": "toml"
},
"performance_mode": {
"name": "performance_mode",
"fieldType": ConfigFieldType.BOOLEAN,
"default": True,
- "description": "use a lighter model for FG (recommended for most games)"
+ "description": "use a lighter model for FG (recommended for most games)",
+ "location": "toml"
},
"hdr_mode": {
"name": "hdr_mode",
"fieldType": ConfigFieldType.BOOLEAN,
"default": False,
- "description": "enable HDR mode (only for games that support HDR)"
+ "description": "enable HDR mode (only for games that support HDR)",
+ "location": "toml"
},
"experimental_present_mode": {
"name": "experimental_present_mode",
"fieldType": ConfigFieldType.STRING,
"default": "fifo",
- "description": "override Vulkan present mode (may cause crashes)"
+ "description": "override Vulkan present mode (may cause crashes)",
+ "location": "toml"
},
"dxvk_frame_rate": {
"name": "dxvk_frame_rate",
"fieldType": ConfigFieldType.INTEGER,
"default": 0,
- "description": "base framerate cap for DirectX games before frame multiplier"
+ "description": "base framerate cap for DirectX games before frame multiplier",
+ "location": "script" # script-only field
},
"enable_wow64": {
"name": "enable_wow64",
"fieldType": ConfigFieldType.BOOLEAN,
"default": False,
- "description": "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)"
+ "description": "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)",
+ "location": "script"
},
"disable_steamdeck_mode": {
"name": "disable_steamdeck_mode",
"fieldType": ConfigFieldType.BOOLEAN,
"default": False,
- "description": "disable Steam Deck mode (unlocks hidden settings in some games)"
+ "description": "disable Steam Deck mode (unlocks hidden settings in some games)",
+ "location": "script"
+ },
+
+ "mangohud_workaround": {
+ "name": "mangohud_workaround",
+ "fieldType": ConfigFieldType.BOOLEAN,
+ "default": False,
+ "description": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode",
+ "location": "script"
+ },
+
+ "disable_vkbasalt": {
+ "name": "disable_vkbasalt",
+ "fieldType": ConfigFieldType.BOOLEAN,
+ "default": False,
+ "description": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)",
+ "location": "script"
}
}
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 74caa57..8d14da6 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -1,5 +1,5 @@
import { callable } from "@decky/api";
-import { ConfigurationData, ConfigurationManager } from "../config/configSchema";
+import { ConfigurationData } from "../config/configSchema";
// Type definitions for API responses
export interface InstallationResult {
@@ -99,16 +99,15 @@ export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_opti
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
export const getLaunchScriptContent = callable<[], FileContentResult>("get_launch_script_content");
-// Updated config function using centralized configuration
+// Updated config function using object-based configuration (single source of truth)
export const updateLsfgConfig = callable<
- [string, number, number, boolean, boolean, string, number, boolean, boolean],
+ [ConfigurationData],
ConfigUpdateResult
>("update_lsfg_config");
-// Helper function to create config update from configuration object
+// Legacy helper function for backward compatibility
export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise<ConfigUpdateResult> => {
- const args = ConfigurationManager.createArgsFromConfig(config);
- return updateLsfgConfig(...args as [string, number, number, boolean, boolean, string, number, boolean, boolean]);
+ return updateLsfgConfig(config);
};
// Self-updater API functions
diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx
index 59ad880..c0b67fd 100644
--- a/src/components/ConfigurationSection.tsx
+++ b/src/components/ConfigurationSection.tsx
@@ -1,5 +1,10 @@
import { PanelSectionRow, ToggleField, SliderField, DropdownItem } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
+import {
+ MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE,
+ EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE,
+ MANGOHUD_WORKAROUND, DISABLE_VKBASALT
+} from "../config/generatedConfigSchema";
interface ConfigurationSectionProps {
config: ConfigurationData;
@@ -45,7 +50,7 @@ export function ConfigurationSection({
]}
showValue={false}
notchTicksVisible={true}
- onChange={(value) => onConfigChange('multiplier', value)}
+ onChange={(value) => onConfigChange(MULTIPLIER, value)}
/>
</PanelSectionRow>
@@ -57,7 +62,7 @@ export function ConfigurationSection({
min={0.25}
max={1.0}
step={0.01}
- onChange={(value) => onConfigChange('flow_scale', value)}
+ onChange={(value) => onConfigChange(FLOW_SCALE, value)}
/>
</PanelSectionRow>
@@ -66,7 +71,7 @@ export function ConfigurationSection({
label="Performance Mode"
description="Uses a lighter model for FG (Recommended for most games)"
checked={config.performance_mode}
- onChange={(value) => onConfigChange('performance_mode', value)}
+ onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)}
/>
</PanelSectionRow>
@@ -75,7 +80,7 @@ export function ConfigurationSection({
label="HDR Mode"
description="Enables HDR mode (only for games that support HDR)"
checked={config.hdr_mode}
- onChange={(value) => onConfigChange('hdr_mode', value)}
+ onChange={(value) => onConfigChange(HDR_MODE, value)}
/>
</PanelSectionRow>
@@ -101,7 +106,7 @@ export function ConfigurationSection({
description="Select a specific Vulkan presentation mode for better performance or compatibility (May cause crashes)"
menuLabel="Select presentation mode"
selectedOption={config.experimental_present_mode || "fifo"}
- onChange={(value) => onConfigChange('experimental_present_mode', value.data)}
+ onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value.data)}
rgOptions={[
{ data: "fifo", label: "FIFO (VSync) - Default" },
{ data: "mailbox", label: "Mailbox" }
@@ -112,16 +117,26 @@ export function ConfigurationSection({
<PanelSectionRow>
<div
style={{
- fontSize: "14px",
- fontWeight: "bold",
- marginTop: "16px",
- marginBottom: "8px",
- borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
- paddingBottom: "4px",
- color: "white"
+ fontSize: "14px",
+ fontWeight: "bold",
+ marginTop: "16px",
+ marginBottom: "2px",
+ borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
+ paddingBottom: "2px",
+ color: "white"
+ }}
+ >
+ Environment Variables
+ </div>
+ <div
+ style={{
+ fontSize: "12px",
+ color: "#cccccc",
+ marginTop: "2px",
+ marginBottom: "8px"
}}
>
- Environment Variables (Requires re-launch)
+ Must be toggled before game start or restart game to take effect
</div>
</PanelSectionRow>
@@ -133,7 +148,7 @@ export function ConfigurationSection({
min={0}
max={60}
step={1}
- onChange={(value) => onConfigChange('dxvk_frame_rate', value)}
+ onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)}
/>
</PanelSectionRow>
@@ -151,7 +166,25 @@ export function ConfigurationSection({
label="Disable Steam Deck Mode"
description="Disables Steam Deck mode (Unlocks hidden settings in some games)"
checked={config.disable_steamdeck_mode}
- onChange={(value) => onConfigChange('disable_steamdeck_mode', value)}
+ onChange={(value) => onConfigChange(DISABLE_STEAMDECK_MODE, value)}
+ />
+ </PanelSectionRow>
+
+ <PanelSectionRow>
+ <ToggleField
+ label="MangoHud Workaround"
+ description="Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode"
+ checked={config.mangohud_workaround}
+ onChange={(value) => onConfigChange(MANGOHUD_WORKAROUND, value)}
+ />
+ </PanelSectionRow>
+
+ <PanelSectionRow>
+ <ToggleField
+ label="Disable vkBasalt"
+ description="Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)"
+ checked={config.disable_vkbasalt}
+ onChange={(value) => onConfigChange(DISABLE_VKBASALT, value)}
/>
</PanelSectionRow>
</>
diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts
index 4ab0d25..9568fd8 100644
--- a/src/config/configSchema.ts
+++ b/src/config/configSchema.ts
@@ -8,6 +8,7 @@
import { callable } from "@decky/api";
import type { ConfigurationData } from './generatedConfigSchema';
import { getDefaults } from './generatedConfigSchema';
+import { updateLsfgConfig } from "../api/lsfgApi";
// Re-export all auto-generated configuration constants
export {
@@ -17,7 +18,11 @@ export {
ConfigurationData,
getFieldNames,
getDefaults,
- getFieldTypes
+ getFieldTypes,
+ // Field name constants for type-safe access
+ DLL, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE,
+ EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, ENABLE_WOW64,
+ DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT
} from './generatedConfigSchema';
/**
@@ -30,7 +35,6 @@ export class ConfigurationManager {
// Callable methods for backend communication
private getConfiguration = callable<[], { success: boolean; data?: ConfigurationData; error?: string }>("get_configuration");
- private setConfiguration = callable<[{ config_data: ConfigurationData }], { success: boolean; error?: string }>("set_configuration");
private resetConfiguration = callable<[], { success: boolean; data?: ConfigurationData; error?: string }>("reset_configuration");
private constructor() {}
@@ -50,23 +54,6 @@ export class ConfigurationManager {
}
/**
- * Create args array from config object for lsfg API calls
- */
- static createArgsFromConfig(config: ConfigurationData): [string, number, number, boolean, boolean, string, number, boolean, boolean] {
- return [
- config.dll,
- config.multiplier,
- config.flow_scale,
- config.performance_mode,
- config.hdr_mode,
- config.experimental_present_mode,
- config.dxvk_frame_rate,
- config.enable_wow64,
- config.disable_steamdeck_mode
- ];
- }
-
- /**
* Load configuration from backend
*/
async loadConfig(): Promise<ConfigurationData> {
@@ -89,7 +76,7 @@ export class ConfigurationManager {
*/
async saveConfig(config: ConfigurationData): Promise<void> {
try {
- const result = await this.setConfiguration({ config_data: config });
+ const result = await updateLsfgConfig(config);
if (result.success) {
this._config = config;
} else {
diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts
index 5f0e368..4a301a1 100644
--- a/src/config/generatedConfigSchema.ts
+++ b/src/config/generatedConfigSchema.ts
@@ -7,6 +7,19 @@ export enum ConfigFieldType {
STRING = "string"
}
+// Field name constants for type-safe access
+export const DLL = "dll" as const;
+export const MULTIPLIER = "multiplier" as const;
+export const FLOW_SCALE = "flow_scale" as const;
+export const PERFORMANCE_MODE = "performance_mode" as const;
+export const HDR_MODE = "hdr_mode" as const;
+export const EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const;
+export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const;
+export const ENABLE_WOW64 = "enable_wow64" as const;
+export const DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" as const;
+export const MANGOHUD_WORKAROUND = "mangohud_workaround" as const;
+export const DISABLE_VKBASALT = "disable_vkbasalt" as const;
+
// Configuration field definition
export interface ConfigField {
name: string;
@@ -71,6 +84,18 @@ export const CONFIG_SCHEMA: Record<string, ConfigField> = {
default: false,
description: "disable Steam Deck mode (unlocks hidden settings in some games)"
},
+ mangohud_workaround: {
+ name: "mangohud_workaround",
+ fieldType: ConfigFieldType.BOOLEAN,
+ default: false,
+ description: "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode"
+ },
+ disable_vkbasalt: {
+ name: "disable_vkbasalt",
+ fieldType: ConfigFieldType.BOOLEAN,
+ default: false,
+ description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)"
+ },
};
// Type-safe configuration data structure
@@ -84,6 +109,8 @@ export interface ConfigurationData {
dxvk_frame_rate: number;
enable_wow64: boolean;
disable_steamdeck_mode: boolean;
+ mangohud_workaround: boolean;
+ disable_vkbasalt: boolean;
}
// Helper functions
@@ -102,6 +129,8 @@ export function getDefaults(): ConfigurationData {
dxvk_frame_rate: 0,
enable_wow64: false,
disable_steamdeck_mode: false,
+ mangohud_workaround: false,
+ disable_vkbasalt: false,
};
}
@@ -116,6 +145,8 @@ export function getFieldTypes(): Record<string, ConfigFieldType> {
dxvk_frame_rate: ConfigFieldType.INTEGER,
enable_wow64: ConfigFieldType.BOOLEAN,
disable_steamdeck_mode: ConfigFieldType.BOOLEAN,
+ mangohud_workaround: ConfigFieldType.BOOLEAN,
+ disable_vkbasalt: ConfigFieldType.BOOLEAN,
};
}
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index 9ac31a0..0000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""
-Test configuration for the lsfg-vk plugin tests.
-"""
-
-import pytest
-from unittest.mock import Mock
-
-
-@pytest.fixture
-def mock_logger():
- """Provide a mock logger for testing"""
- return Mock()
-
-
-@pytest.fixture
-def mock_decky_logger(monkeypatch):
- """Mock decky.logger for tests that import decky"""
- mock_logger = Mock()
-
- # Create a mock decky module
- mock_decky = Mock()
- mock_decky.logger = mock_logger
-
- # Monkeypatch the import
- monkeypatch.setattr('lsfg_vk.base_service.decky', mock_decky)
- monkeypatch.setattr('lsfg_vk.installation.decky', mock_decky)
- monkeypatch.setattr('lsfg_vk.dll_detection.decky', mock_decky)
- monkeypatch.setattr('lsfg_vk.configuration.decky', mock_decky)
- monkeypatch.setattr('lsfg_vk.plugin.decky', mock_decky)
-
- return mock_logger
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
deleted file mode 100644
index 3e0ad79..0000000
--- a/tests/test_configuration.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""
-Tests for the configuration service.
-"""
-
-import tempfile
-from pathlib import Path
-from unittest.mock import Mock
-
-from lsfg_vk.configuration import ConfigurationService
-from lsfg_vk.config_schema import ConfigurationManager
-
-
-def test_parse_script_content():
- """Test parsing of script content with current environment variable format"""
-
- # Test script content matching current format
- script_content = """#!/bin/bash
-# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin
-# This script sets up the environment for lsfg-vk to work with the plugin configuration
-export PROTON_USE_WOW64=1
-export SteamDeck=0
-export DXVK_FRAME_RATE=18
-export LSFG_PROCESS=decky-lsfg-vk
-exec "$@"
-"""
-
- script_values = ConfigurationManager.parse_script_content(script_content)
-
- assert script_values["enable_wow64"] is True
- assert script_values["disable_steamdeck_mode"] is True # SteamDeck=0 means disable
- assert script_values["dxvk_frame_rate"] == 18
-
-
-def test_parse_script_content_minimal():
- """Test parsing when only required exports are present"""
-
- script_content = """#!/bin/bash
-# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin
-export LSFG_PROCESS=decky-lsfg-vk
-exec "$@"
-"""
-
- script_values = ConfigurationManager.parse_script_content(script_content)
-
- # Should be empty dict since no tracked env vars are present
- assert script_values == {}
-
-
-def test_merge_config_with_script():
- """Test merging TOML config with script environment variables"""
-
- # Get defaults
- toml_config = ConfigurationManager.get_defaults()
-
- # Script values from parsing
- script_values = {
- "enable_wow64": True,
- "disable_steamdeck_mode": True,
- "dxvk_frame_rate": 30
- }
-
- merged = ConfigurationManager.merge_config_with_script(toml_config, script_values)
-
- # TOML fields should be preserved
- assert merged["multiplier"] == 1 # default from TOML
- assert merged["flow_scale"] == 0.8 # default from TOML
- assert merged["performance_mode"] is True # default from TOML
-
- # Script fields should be overlaid
- assert merged["enable_wow64"] is True
- assert merged["disable_steamdeck_mode"] is True
- assert merged["dxvk_frame_rate"] == 30
-
- with tempfile.TemporaryDirectory() as temp_dir:
- temp_home = Path(temp_dir)
-
- service = ConfigurationService(logger=mock_logger)
- service.user_home = temp_home
- service.lsfg_script_path = temp_home / "lsfg"
-
- script_content = """#!/bin/bash
-
-# export ENABLE_LSFG=1
-export LSFG_MULTIPLIER=2
-export LSFG_FLOW_SCALE=1.0
-# export LSFG_HDR=1
-# export LSFG_PERF_MODE=1
-# export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync
-
-exec "$@"
-"""
-
- config = service._parse_script_content(script_content)
-
- assert config["enable_lsfg"] is False
- assert config["multiplier"] == 2
- assert config["flow_scale"] == 1.0
- assert config["hdr"] is False
- assert config["perf_mode"] is False
- assert config["immediate_mode"] is False
-
-
-def test_generate_script_content():
- """Test script content generation"""
- mock_logger = Mock()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- temp_home = Path(temp_dir)
-
- service = ConfigurationService(logger=mock_logger)
- service.user_home = temp_home
- service.lsfg_script_path = temp_home / "lsfg"
-
- # Test with no toggles enabled
- config = {
- "enable_wow64": False,
- "disable_steamdeck_mode": False
- }
- content = service._generate_script_content(config)
-
- assert "#!/bin/bash" in content
- assert "export LSFG_PROCESS=decky-lsfg-vk" in content
- assert "export PROTON_USE_WOW64=1" not in content
- assert "export SteamDeck=0" not in content
- assert 'exec "$@"' in content
-
- # Test with both toggles enabled
- config = {
- "enable_wow64": True,
- "disable_steamdeck_mode": True
- }
- content = service._generate_script_content(config)
-
- assert "#!/bin/bash" in content
- assert "export PROTON_USE_WOW64=1" in content
- assert "export SteamDeck=0" in content
- assert "export LSFG_PROCESS=decky-lsfg-vk" in content
- assert 'exec "$@"' in content
-
-
-def test_config_roundtrip():
- """Test that we can write config and read it back correctly"""
- mock_logger = Mock()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- temp_home = Path(temp_dir)
-
- service = ConfigurationService(logger=mock_logger)
- service.user_home = temp_home
- service.lsfg_script_path = temp_home / "lsfg"
-
- # Update config
- result = service.update_config(
- dll="/path/to/dll",
- multiplier=3,
- flow_scale=1.5,
- performance_mode=False,
- hdr_mode=True,
- experimental_present_mode="immediate",
- dxvk_frame_rate=30,
- enable_wow64=True,
- disable_steamdeck_mode=False
- )
-
- assert result["success"] is True
-
- # Read it back
- read_result = service.get_config()
-
- assert read_result["success"] is True
- config = read_result["config"]
- assert config["dll"] == "/path/to/dll"
- assert config["multiplier"] == 3
- assert config["flow_scale"] == 1.5
- assert config["performance_mode"] is False
- assert config["hdr_mode"] is True
- assert config["experimental_present_mode"] == "immediate"
- assert config["dxvk_frame_rate"] == 30
- assert config["enable_wow64"] is True
- assert config["disable_steamdeck_mode"] is False
-
- assert read_result["success"] is True
- config = read_result["config"]
-
- assert config["enable_lsfg"] is True
- assert config["multiplier"] == 3
- assert config["flow_scale"] == 1.5
- assert config["hdr"] is True
- assert config["perf_mode"] is False
- assert config["immediate_mode"] is True
diff --git a/tests/test_dll_detection.py b/tests/test_dll_detection.py
deleted file mode 100644
index e50d733..0000000
--- a/tests/test_dll_detection.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""
-Tests for the DLL detection service.
-"""
-
-import os
-import tempfile
-from pathlib import Path
-from unittest.mock import Mock, patch
-
-from lsfg_vk.dll_detection import DllDetectionService
-from lsfg_vk.constants import LOSSLESS_DLL_NAME
-
-
-def test_dll_detection_via_env_variable():
- """Test DLL detection via LSFG_DLL_PATH environment variable"""
- mock_logger = Mock()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- # Create a fake DLL file
- dll_path = Path(temp_dir) / LOSSLESS_DLL_NAME
- dll_path.write_text("fake dll content")
-
- service = DllDetectionService(logger=mock_logger)
-
- # Test with environment variable set
- with patch.dict(os.environ, {"LSFG_DLL_PATH": str(dll_path)}):
- result = service.check_lossless_scaling_dll()
-
- assert result["detected"] is True
- assert result["path"] == str(dll_path)
- assert "LSFG_DLL_PATH" in result["source"]
- assert result["error"] is None
-
-
-def test_dll_detection_via_xdg_data_home():
- """Test DLL detection via XDG_DATA_HOME"""
- mock_logger = Mock()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- # Create the expected directory structure
- steam_dir = Path(temp_dir) / "Steam" / "steamapps" / "common" / "Lossless Scaling"
- steam_dir.mkdir(parents=True)
-
- dll_path = steam_dir / LOSSLESS_DLL_NAME
- dll_path.write_text("fake dll content")
-
- service = DllDetectionService(logger=mock_logger)
-
- # Test with XDG_DATA_HOME set, no LSFG_DLL_PATH
- with patch.dict(os.environ, {"XDG_DATA_HOME": temp_dir}, clear=True):
- result = service.check_lossless_scaling_dll()
-
- assert result["detected"] is True
- assert result["path"] == str(dll_path)
- assert "XDG_DATA_HOME" in result["source"]
- assert result["error"] is None
-
-
-def test_dll_detection_via_home_local_share():
- """Test DLL detection via HOME/.local/share"""
- mock_logger = Mock()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- # Create the expected directory structure
- steam_dir = Path(temp_dir) / ".local" / "share" / "Steam" / "steamapps" / "common" / "Lossless Scaling"
- steam_dir.mkdir(parents=True)
-
- dll_path = steam_dir / LOSSLESS_DLL_NAME
- dll_path.write_text("fake dll content")
-
- service = DllDetectionService(logger=mock_logger)
-
- # Test with HOME set, no other env vars
- env = {"HOME": temp_dir}
- with patch.dict(os.environ, env, clear=True):
- result = service.check_lossless_scaling_dll()
-
- assert result["detected"] is True
- assert result["path"] == str(dll_path)
- assert "HOME/.local/share" in result["source"]
- assert result["error"] is None
-
-
-def test_dll_detection_not_found():
- """Test DLL detection when DLL is not found"""
- mock_logger = Mock()
-
- service = DllDetectionService(logger=mock_logger)
-
- # Test with no environment variables set
- with patch.dict(os.environ, {}, clear=True):
- result = service.check_lossless_scaling_dll()
-
- assert result["detected"] is False
- assert result["path"] is None
- assert result["source"] is None
- assert "not found" in result["message"]
- assert result["error"] is None
-
-
-def test_dll_detection_priority():
- """Test that LSFG_DLL_PATH takes priority over other locations"""
- mock_logger = Mock()
-
- with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
- # Create DLL in both locations
- dll_path1 = Path(temp_dir1) / LOSSLESS_DLL_NAME
- dll_path1.write_text("fake dll content 1")
-
- steam_dir = Path(temp_dir2) / "Steam" / "steamapps" / "common" / "Lossless Scaling"
- steam_dir.mkdir(parents=True)
- dll_path2 = steam_dir / LOSSLESS_DLL_NAME
- dll_path2.write_text("fake dll content 2")
-
- service = DllDetectionService(logger=mock_logger)
-
- # Set both environment variables
- env = {
- "LSFG_DLL_PATH": str(dll_path1),
- "XDG_DATA_HOME": temp_dir2
- }
-
- with patch.dict(os.environ, env, clear=True):
- result = service.check_lossless_scaling_dll()
-
- # Should prefer LSFG_DLL_PATH
- assert result["detected"] is True
- assert result["path"] == str(dll_path1)
- assert "LSFG_DLL_PATH" in result["source"]
diff --git a/tests/test_installation.py b/tests/test_installation.py
deleted file mode 100644
index 2b3690e..0000000
--- a/tests/test_installation.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""
-Tests for the installation service.
-"""
-
-import os
-import tempfile
-import zipfile
-from pathlib import Path
-from unittest.mock import Mock, patch
-
-import pytest
-from pyfakefs.fake_filesystem_unittest import TestCase
-
-from lsfg_vk.installation import InstallationService
-from lsfg_vk.constants import LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME
-
-
-class TestInstallationService(TestCase):
- """Test cases for InstallationService using pyfakefs"""
-
- def setUp(self):
- """Set up fake filesystem"""
- self.setUpPyfakefs()
- self.mock_logger = Mock()
-
- # Create a test home directory
- self.test_home = Path("/home/testuser")
- self.fs.create_dir(self.test_home)
-
- # Patch Path.home() to return our test home
- with patch('lsfg_vk.base_service.Path.home', return_value=self.test_home):
- self.service = InstallationService(logger=self.mock_logger)
-
- def test_check_installation_no_files(self):
- """Test installation check when no files are installed"""
- result = self.service.check_installation()
-
- assert result["installed"] is False
- assert result["lib_exists"] is False
- assert result["json_exists"] is False
- assert result["script_exists"] is False
- assert result["error"] is None
-
- def test_check_installation_all_files_exist(self):
- """Test installation check when all files exist"""
- # Create the files
- self.service.lib_file.parent.mkdir(parents=True, exist_ok=True)
- self.service.lib_file.touch()
-
- self.service.json_file.parent.mkdir(parents=True, exist_ok=True)
- self.service.json_file.touch()
-
- self.service.lsfg_script_path.touch()
-
- result = self.service.check_installation()
-
- assert result["installed"] is True
- assert result["lib_exists"] is True
- assert result["json_exists"] is True
- assert result["script_exists"] is True
- assert result["error"] is None
-
- def test_create_zip_for_testing(self):
- """Helper to create a test zip file"""
- # Create temp directory for zip contents
- zip_content_dir = Path("/tmp/zip_content")
- self.fs.create_dir(zip_content_dir)
-
- # Create test files
- lib_file = zip_content_dir / LIB_FILENAME
- json_file = zip_content_dir / JSON_FILENAME
-
- lib_file.write_text("fake library content")
- json_file.write_text('{"layer": {"name": "VK_LAYER_LS_frame_generation"}}')
-
- # Create zip file
- zip_path = Path("/tmp/test.zip")
- with zipfile.ZipFile(zip_path, 'w') as zip_file:
- zip_file.write(lib_file, LIB_FILENAME)
- zip_file.write(json_file, JSON_FILENAME)
-
- return zip_path
-
- @patch('lsfg_vk.installation.Path.home')
- def test_install_success(self, mock_home):
- """Test successful installation"""
- mock_home.return_value = self.test_home
-
- # Create the plugin directory and zip file
- plugin_dir = Path("/plugin")
- bin_dir = plugin_dir / "bin"
- self.fs.create_dir(bin_dir)
-
- # Create a test zip file
- zip_path = self.test_create_zip_for_testing()
- zip_dest = bin_dir / ZIP_FILENAME
-
- # Copy our test zip to the expected location
- with open(zip_path, 'rb') as src, open(zip_dest, 'wb') as dst:
- dst.write(src.read())
-
- # Mock the plugin directory detection
- with patch('lsfg_vk.installation.Path.__file__', f"{plugin_dir}/lsfg_vk/installation.py"):
- result = self.service.install()
-
- assert result["success"] is True
- assert "successfully" in result["message"]
- assert result["error"] is None
-
- # Check that files were created
- assert self.service.lib_file.exists()
- assert self.service.json_file.exists()
- assert self.service.lsfg_script_path.exists()
-
- def test_uninstall_no_files(self):
- """Test uninstall when no files exist"""
- result = self.service.uninstall()
-
- assert result["success"] is True
- assert "No lsfg-vk files found" in result["message"]
- assert result["removed_files"] is None
-
- def test_uninstall_with_files(self):
- """Test uninstall when files exist"""
- # Create the files
- self.service.lib_file.parent.mkdir(parents=True, exist_ok=True)
- self.service.lib_file.touch()
-
- self.service.json_file.parent.mkdir(parents=True, exist_ok=True)
- self.service.json_file.touch()
-
- self.service.lsfg_script_path.touch()
-
- result = self.service.uninstall()
-
- assert result["success"] is True
- assert "uninstalled successfully" in result["message"]
- assert len(result["removed_files"]) == 3
-
- # Check that files were removed
- assert not self.service.lib_file.exists()
- assert not self.service.json_file.exists()
- assert not self.service.lsfg_script_path.exists()
-
-
-def test_installation_service_with_mock_logger():
- """Test that InstallationService accepts a mock logger"""
- mock_logger = Mock()
- service = InstallationService(logger=mock_logger)
- assert service.log == mock_logger