diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2025-07-13 13:24:30 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2025-07-13 13:24:30 -0400 |
| commit | 83a528be3a23bd43beb6a002c023a3673cad03ae (patch) | |
| tree | 48864efe2d74271e2e3688cb31ecf7742f1a8844 | |
| parent | 7866a9a77d2a0922883637576f6cdac122c56d42 (diff) | |
| download | decky-lsfg-vk-83a528be3a23bd43beb6a002c023a3673cad03ae.tar.gz decky-lsfg-vk-83a528be3a23bd43beb6a002c023a3673cad03ae.zip | |
update readme w manual install instructions
| -rw-r--r-- | README.md | 31 | ||||
| -rw-r--r-- | REFACTORING.md | 174 | ||||
| -rw-r--r-- | lsfg_vk/__init__.py | 14 | ||||
| -rw-r--r-- | lsfg_vk/base_service.py | 103 | ||||
| -rw-r--r-- | lsfg_vk/configuration.py | 175 | ||||
| -rw-r--r-- | lsfg_vk/constants.py | 54 | ||||
| -rw-r--r-- | lsfg_vk/dll_detection.py | 120 | ||||
| -rw-r--r-- | lsfg_vk/installation.py | 225 | ||||
| -rw-r--r-- | lsfg_vk/plugin.py | 159 | ||||
| -rw-r--r-- | lsfg_vk/types.py | 71 | ||||
| -rw-r--r-- | main_new.py | 13 | ||||
| -rw-r--r-- | requirements-test.txt | 4 | ||||
| -rw-r--r-- | sync-py-modules.sh | 17 | ||||
| -rw-r--r-- | test_refactored.py | 79 | ||||
| -rw-r--r-- | tests/conftest.py | 31 | ||||
| -rw-r--r-- | tests/test_configuration.py | 142 | ||||
| -rw-r--r-- | tests/test_dll_detection.py | 129 | ||||
| -rw-r--r-- | tests/test_installation.py | 150 |
18 files changed, 1685 insertions, 6 deletions
@@ -8,22 +8,41 @@ This plugin automates the installation of lsfg-vk, a compatibility layer that al <img width="581" height="893" alt="image" src="https://github.com/user-attachments/assets/23931a7f-b496-4d41-bde4-3dfbb4ba7d4b" /> +## Installation + +### Manual Installation (Current Method) +**Note:** This plugin is not yet available on the Decky Plugin Store. + +1. **Download the plugin** from the [latest release](https://github.com/xXJSONDeruloXx/decky-lossless-scaling-vk/releases/tag/Latest) + - Download the "Lossless Scaling.zip" file to your Steam Deck +2. **Install manually through Decky**: + - In Game Mode, go to the settings cog in the top right of the Decky Loader tab + - Enable "Developer Mode" + - Go to "Developer" tab and select "Install Plugin from Zip" + - Select the downloaded "Lossless Scaling.zip" file + +### Future Installation +- This plugin will be available through the Decky Plugin Store once approved + ## How to Use -1. **Install the plugin** through the Decky Plugin Store (TBD) -2. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam -3. **Open the plugin** from the Decky menu -4. **Click "Install lsfg-vk"** to automatically set up the compatibility layer -5. **Configure settings** using the plugin's UI controls: +1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam +2. **Open the plugin** from the Decky menu +3. **Click "Install lsfg-vk"** to automatically set up the compatibility layer +4. **Configure settings** using the plugin's UI controls: - Enable/disable LSFG - Set FPS multiplier (2-4) - Adjust flow scale (0.25-1.0) - Toggle HDR mode - Toggle immediate mode (disable vsync) -6. **Apply launch commands** to the game you want to use frame generation with: +5. **Apply launch commands** to the game you want to use frame generation with: - **Option 1 (Recommended)**: `~/lsfg %COMMAND%` - Uses your plugin configuration - **Option 2**: Manual environment variables like `ENABLE_LSFG=1 LSFG_MULTIPLIER=2 %COMMAND%` +## Feedback and Support + +For per-game feedback and community support, check out the [Lossless Scaling Discord](https://discord.gg/losslessscaling). + ## What it does The plugin: diff --git a/REFACTORING.md b/REFACTORING.md new file mode 100644 index 0000000..e8509a0 --- /dev/null +++ b/REFACTORING.md @@ -0,0 +1,174 @@ +# Code Refactoring Summary + +This document summarizes the refactoring changes made to the lsfg-vk plugin based on the code review. + +## ๐๏ธ File Structure Changes + +### Before (Single File) +``` +main.py (500+ lines) +โโโ InstallationService +โโโ DllDetectionService +โโโ ConfigurationService +โโโ Plugin +``` + +### After (Modular Package) +``` +lsfg_vk/ +โโโ __init__.py # Package exports +โโโ constants.py # All constants and configuration +โโโ types.py # TypedDict definitions for responses +โโโ base_service.py # Common functionality for all services +โโโ installation.py # InstallationService +โโโ dll_detection.py # DllDetectionService +โโโ configuration.py # ConfigurationService +โโโ plugin.py # Main Plugin class + +tests/ +โโโ conftest.py # Test configuration and fixtures +โโโ test_installation.py # Tests for InstallationService +โโโ test_configuration.py # Tests for ConfigurationService +โโโ test_dll_detection.py # Tests for DllDetectionService + +main.py # Simple import/export for Decky compatibility +requirements-test.txt # Testing dependencies +``` + +## โจ Key Improvements Implemented + +### 1. **Single Responsibility & Modularity** +- Split monolithic file into focused modules +- Each service handles one concern +- Easier to navigate and maintain +- Reduced merge conflicts + +### 2. **Constants Management** +- All hardcoded paths moved to `constants.py` +- Template-based script generation +- Environment variable names centralized +- Default values defined in one place + +### 3. **Path Handling with `pathlib.Path`** +- Replaced `os.path.*` with `pathlib.Path` +- More declarative and robust path operations +- Built-in `.mkdir(parents=True, exist_ok=True)` +- Cross-platform compatibility + +### 4. **Enhanced Error Handling** +- Specific exception catching (`OSError`, `zipfile.BadZipFile`, `shutil.Error`) +- Granular error reporting +- Consistent error response structure +- Better logging of specific failure points + +### 5. **Type Safety with TypedDict** +- Defined response structures for all methods +- Consistent API contracts +- Better IDE support and documentation +- Runtime compatibility with `Dict[str, Any]` + +### 6. **DRY Principle Implementation** +- `_remove_if_exists()` helper for file removal +- `_atomic_write()` for safe file writing +- `_ensure_directories()` for directory creation +- Unified file destination mapping + +### 7. **Atomic File Operations** +- Safe script writing with temporary files +- Prevents corruption during writes +- Proper cleanup on failures +- Consistent file permissions + +### 8. **Logger Injection** +- Services accept optional logger parameter +- Defaults to `decky.logger` when None +- Enables unit testing with mock loggers +- Better separation of concerns + +### 9. **Robust Configuration Parsing** +- Regex-based parsing instead of string splitting +- Handles edge cases (extra spaces, comments) +- Template-based generation +- Roundtrip consistency testing + +### 10. **Comprehensive Testing Framework** +- Unit tests for each service +- Mock filesystem with `pyfakefs` +- Mock logger injection +- Roundtrip testing for configuration +- Environment variable mocking + +## ๐งช Testing + +### Running Tests +```bash +# Install test dependencies +pip install -r requirements-test.txt + +# Run all tests +pytest tests/ + +# Run with coverage +pytest tests/ --cov=lsfg_vk + +# Run specific test file +pytest tests/test_installation.py +``` + +### Test Coverage +- **Installation Service**: ZIP extraction, file copying, installation checking +- **Configuration Service**: Script parsing, generation, roundtrip testing +- **DLL Detection Service**: Environment variable priority, path checking +- **Mock Integration**: All services work with mock loggers for testing + +## ๐ Migration Guide + +### For Developers +The public API remains unchanged - all existing frontend code will continue to work: + +```python +# These calls remain identical +plugin.install_lsfg_vk() +plugin.check_lsfg_vk_installed() +plugin.uninstall_lsfg_vk() +plugin.get_lsfg_config() +plugin.update_lsfg_config(...) +plugin.check_lossless_scaling_dll() +``` + +### For Maintainers +1. **Adding new features**: Add to appropriate service module +2. **Constants**: Define in `constants.py` +3. **New response types**: Add to `types.py` +4. **Testing**: Add tests for new functionality +5. **Common functionality**: Add to `base_service.py` + +## ๐ Benefits Achieved + +1. **Maintainability**: Easier to find and modify specific functionality +2. **Testability**: Comprehensive unit test coverage +3. **Reliability**: Atomic operations and better error handling +4. **Documentation**: Clear type definitions and API contracts +5. **Extensibility**: Easy to add new services or features +6. **Code Quality**: Follows Python best practices and patterns + +## ๐ Future Improvements + +### Next Steps (Not Yet Implemented) +1. **Async/Sync Decision**: Evaluate if true async I/O is needed +2. **Configuration Schema**: JSON schema validation for config files +3. **Dependency Injection**: More sophisticated service container +4. **Integration Tests**: End-to-end testing with real files +5. **Performance Monitoring**: Metrics and logging for operations +6. **Error Recovery**: More sophisticated error handling and retry logic + +### Potential Extensions +- Plugin settings persistence +- Multiple DLL version support +- Automatic updates checking +- Performance profiling integration +- Steam integration improvements + +--- + +This refactoring maintains 100% backward compatibility while significantly improving code quality, maintainability, and testability. diff --git a/lsfg_vk/__init__.py b/lsfg_vk/__init__.py new file mode 100644 index 0000000..8343853 --- /dev/null +++ b/lsfg_vk/__init__.py @@ -0,0 +1,14 @@ +""" +lsfg-vk plugin package for Decky Loader. + +This package provides services for installing and managing the lsfg-vk +Vulkan layer for Lossless Scaling frame generation. +""" + +# Import will be available once plugin.py exists +try: + from .plugin import Plugin + __all__ = ['Plugin'] +except ImportError: + # During development, plugin may not exist yet + __all__ = [] diff --git a/lsfg_vk/base_service.py b/lsfg_vk/base_service.py new file mode 100644 index 0000000..b547759 --- /dev/null +++ b/lsfg_vk/base_service.py @@ -0,0 +1,103 @@ +""" +Base service class with common functionality. +""" + +import os +import shutil +import tempfile +from pathlib import Path +from typing import Any, Optional + +from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME + + +class BaseService: + """Base service class with common functionality""" + + def __init__(self, logger: Optional[Any] = None): + """Initialize base service + + Args: + logger: Logger instance, defaults to decky.logger if None + """ + if logger is None: + import decky + self.log = decky.logger + else: + self.log = logger + + # Initialize common paths using pathlib + self.user_home = Path.home() + self.local_lib_dir = self.user_home / LOCAL_LIB + self.local_share_dir = self.user_home / VULKAN_LAYER_DIR + self.lsfg_script_path = self.user_home / SCRIPT_NAME + + def _ensure_directories(self) -> None: + """Create necessary directories if they don't exist""" + self.local_lib_dir.mkdir(parents=True, exist_ok=True) + self.local_share_dir.mkdir(parents=True, exist_ok=True) + self.log.info(f"Ensured directories exist: {self.local_lib_dir}, {self.local_share_dir}") + + def _remove_if_exists(self, path: Path) -> bool: + """Remove a file if it exists + + Args: + path: Path to the file to remove + + Returns: + True if file was removed, False if it didn't exist + + Raises: + OSError: If removal fails + """ + if path.exists(): + try: + path.unlink() + self.log.info(f"Removed {path}") + return True + except OSError as e: + self.log.error(f"Failed to remove {path}: {e}") + raise + else: + self.log.info(f"File not found: {path}") + return False + + def _atomic_write(self, path: Path, content: str, mode: int = 0o644) -> None: + """Write content to a file atomically + + Args: + path: Target file path + content: Content to write + mode: File permissions (default: 0o644) + + Raises: + OSError: If write fails + """ + # Create temporary file in the same directory to ensure atomic move + temp_path = None + try: + with tempfile.NamedTemporaryFile( + mode='w', + dir=path.parent, + delete=False, + prefix=f'.{path.name}.', + suffix='.tmp' + ) as temp_file: + temp_file.write(content) + temp_path = Path(temp_file.name) + + # Set permissions before moving + temp_path.chmod(mode) + + # Atomic move + temp_path.replace(path) + self.log.info(f"Atomically wrote to {path}") + + except Exception: + # Clean up temp file if something went wrong + if temp_path and temp_path.exists(): + try: + temp_path.unlink() + except OSError: + pass # Best effort cleanup + raise diff --git a/lsfg_vk/configuration.py b/lsfg_vk/configuration.py new file mode 100644 index 0000000..f5e2981 --- /dev/null +++ b/lsfg_vk/configuration.py @@ -0,0 +1,175 @@ +""" +Configuration service for lsfg script management. +""" + +import re +from pathlib import Path +from typing import Dict, Any + +from .base_service import BaseService +from .constants import LSFG_SCRIPT_TEMPLATE +from .types import ConfigurationResponse, ConfigurationData + + +class ConfigurationService(BaseService): + """Service for managing lsfg script configuration""" + + def get_config(self) -> ConfigurationResponse: + """Read current lsfg script configuration + + Returns: + ConfigurationResponse with current configuration or error + """ + try: + if not self.lsfg_script_path.exists(): + return { + "success": False, + "config": None, + "message": None, + "error": "lsfg script not found" + } + + content = self.lsfg_script_path.read_text() + config = self._parse_script_content(content) + + self.log.info(f"Parsed lsfg config: {config}") + + return { + "success": True, + "config": config, + "message": None, + "error": None + } + + except (OSError, IOError) as e: + error_msg = f"Error reading lsfg config: {str(e)}" + self.log.error(error_msg) + return { + "success": False, + "config": None, + "message": None, + "error": str(e) + } + + def _parse_script_content(self, content: str) -> ConfigurationData: + """Parse script content to extract configuration values + + Args: + content: Script file content + + Returns: + ConfigurationData with parsed values + """ + config: ConfigurationData = { + "enable_lsfg": False, + "multiplier": 2, + "flow_scale": 1.0, + "hdr": False, + "perf_mode": False, + "immediate_mode": False + } + + lines = content.split('\n') + for line in lines: + line = line.strip() + + # Parse ENABLE_LSFG + if match := re.match(r'^(#\s*)?export\s+ENABLE_LSFG=(\d+)', line): + config["enable_lsfg"] = not bool(match.group(1)) and match.group(2) == '1' + + # Parse LSFG_MULTIPLIER + elif match := re.match(r'^export\s+LSFG_MULTIPLIER=(\d+)', line): + try: + config["multiplier"] = int(match.group(1)) + except ValueError: + pass + + # Parse LSFG_FLOW_SCALE + elif match := re.match(r'^export\s+LSFG_FLOW_SCALE=([0-9]*\.?[0-9]+)', line): + try: + config["flow_scale"] = float(match.group(1)) + except ValueError: + pass + + # Parse LSFG_HDR + elif match := re.match(r'^(#\s*)?export\s+LSFG_HDR=(\d+)', line): + config["hdr"] = not bool(match.group(1)) and match.group(2) == '1' + + # Parse LSFG_PERF_MODE + elif match := re.match(r'^(#\s*)?export\s+LSFG_PERF_MODE=(\d+)', line): + config["perf_mode"] = not bool(match.group(1)) and match.group(2) == '1' + + # Parse MESA_VK_WSI_PRESENT_MODE + elif match := re.match(r'^(#\s*)?export\s+MESA_VK_WSI_PRESENT_MODE=([^\s#]+)', line): + config["immediate_mode"] = not bool(match.group(1)) and match.group(2) == 'immediate' + + return config + + def update_config(self, enable_lsfg: bool, multiplier: int, flow_scale: float, + hdr: bool, perf_mode: bool, immediate_mode: bool) -> ConfigurationResponse: + """Update lsfg script configuration + + Args: + enable_lsfg: Whether to enable LSFG + multiplier: LSFG multiplier value + flow_scale: LSFG flow scale value + hdr: Whether to enable HDR + perf_mode: Whether to enable performance mode + immediate_mode: Whether to enable immediate present mode (disable vsync) + + Returns: + ConfigurationResponse with success status + """ + try: + # Generate script content using template + script_content = self._generate_script_content( + enable_lsfg, multiplier, flow_scale, hdr, perf_mode, immediate_mode + ) + + # Write the updated script atomically + self._atomic_write(self.lsfg_script_path, script_content, 0o755) + + self.log.info(f"Updated lsfg script configuration: enable={enable_lsfg}, " + f"multiplier={multiplier}, flow_scale={flow_scale}, hdr={hdr}, " + f"perf_mode={perf_mode}, immediate_mode={immediate_mode}") + + return { + "success": True, + "config": None, + "message": "lsfg configuration updated successfully", + "error": None + } + + except (OSError, IOError) as e: + error_msg = f"Error updating lsfg config: {str(e)}" + self.log.error(error_msg) + return { + "success": False, + "config": None, + "message": None, + "error": str(e) + } + + def _generate_script_content(self, enable_lsfg: bool, multiplier: int, flow_scale: float, + hdr: bool, perf_mode: bool, immediate_mode: bool) -> str: + """Generate script content from configuration parameters + + Args: + enable_lsfg: Whether to enable LSFG + multiplier: LSFG multiplier value + flow_scale: LSFG flow scale value + hdr: Whether to enable HDR + perf_mode: Whether to enable performance mode + immediate_mode: Whether to enable immediate present mode + + Returns: + Generated script content + """ + return LSFG_SCRIPT_TEMPLATE.format( + enable_lsfg="export ENABLE_LSFG=1" if enable_lsfg else "# export ENABLE_LSFG=1", + multiplier=multiplier, + flow_scale=flow_scale, + hdr="export LSFG_HDR=1" if hdr else "# export LSFG_HDR=1", + perf_mode="export LSFG_PERF_MODE=1" if perf_mode else "# export LSFG_PERF_MODE=1", + immediate_mode="export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync" if immediate_mode else "# export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync" + ) diff --git a/lsfg_vk/constants.py b/lsfg_vk/constants.py new file mode 100644 index 0000000..28246c2 --- /dev/null +++ b/lsfg_vk/constants.py @@ -0,0 +1,54 @@ +""" +Constants for the lsfg-vk plugin. +""" + +from pathlib import Path + +# Directory paths +LOCAL_LIB = ".local/lib" +LOCAL_SHARE_BASE = ".local/share" +VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d" + +# File names +SCRIPT_NAME = "lsfg" +LIB_FILENAME = "liblsfg-vk.so" +JSON_FILENAME = "VkLayer_LS_frame_generation.json" +ZIP_FILENAME = "lsfg-vk_archlinux.zip" + +# File extensions +SO_EXT = ".so" +JSON_EXT = ".json" + +# Directory for the zip file +BIN_DIR = "bin" + +# Lossless Scaling paths +STEAM_COMMON_PATH = Path("steamapps/common/Lossless Scaling") +LOSSLESS_DLL_NAME = "Lossless.dll" + +# Script template +LSFG_SCRIPT_TEMPLATE = """#!/bin/bash + +{enable_lsfg} +export LSFG_MULTIPLIER={multiplier} +export LSFG_FLOW_SCALE={flow_scale} +{hdr} +{perf_mode} +{immediate_mode} + +# Execute the passed command with the environment variables set +exec "$@" +""" + +# Environment variable names +ENV_LSFG_DLL_PATH = "LSFG_DLL_PATH" +ENV_XDG_DATA_HOME = "XDG_DATA_HOME" +ENV_HOME = "HOME" + +# Default configuration values +DEFAULT_MULTIPLIER = 2 +DEFAULT_FLOW_SCALE = 1.0 +DEFAULT_ENABLE_LSFG = True +DEFAULT_HDR = False +DEFAULT_PERF_MODE = False +DEFAULT_IMMEDIATE_MODE = False diff --git a/lsfg_vk/dll_detection.py b/lsfg_vk/dll_detection.py new file mode 100644 index 0000000..f1dace9 --- /dev/null +++ b/lsfg_vk/dll_detection.py @@ -0,0 +1,120 @@ +""" +DLL detection service for Lossless Scaling. +""" + +import os +from pathlib import Path +from typing import Dict, Any + +from .base_service import BaseService +from .constants import ( + ENV_LSFG_DLL_PATH, ENV_XDG_DATA_HOME, ENV_HOME, + STEAM_COMMON_PATH, LOSSLESS_DLL_NAME +) +from .types import DllDetectionResponse + + +class DllDetectionService(BaseService): + """Service for detecting Lossless Scaling DLL""" + + def check_lossless_scaling_dll(self) -> DllDetectionResponse: + """Check if Lossless Scaling DLL is available at the expected paths + + Returns: + DllDetectionResponse with detection status and path information + """ + try: + # Check environment variable first + dll_path = self._check_env_dll_path() + if dll_path: + return dll_path + + # Check XDG_DATA_HOME path + xdg_path = self._check_xdg_data_home() + if xdg_path: + return xdg_path + + # Check HOME/.local/share path + home_path = self._check_home_local_share() + if home_path: + return home_path + + # DLL not found in any expected location + return { + "detected": False, + "path": None, + "source": None, + "message": "Lossless Scaling DLL not found in expected locations", + "error": None + } + + except Exception as e: + error_msg = f"Error checking Lossless Scaling DLL: {str(e)}" + self.log.error(error_msg) + return { + "detected": False, + "path": None, + "source": None, + "message": None, + "error": str(e) + } + + def _check_env_dll_path(self) -> DllDetectionResponse | None: + """Check LSFG_DLL_PATH environment variable + + Returns: + DllDetectionResponse if found, None otherwise + """ + dll_path = os.getenv(ENV_LSFG_DLL_PATH) + if dll_path and dll_path.strip(): + dll_path_obj = Path(dll_path.strip()) + if dll_path_obj.exists(): + self.log.info(f"Found DLL via {ENV_LSFG_DLL_PATH}: {dll_path_obj}") + return { + "detected": True, + "path": str(dll_path_obj), + "source": f"{ENV_LSFG_DLL_PATH} environment variable", + "message": None, + "error": None + } + return None + + def _check_xdg_data_home(self) -> DllDetectionResponse | None: + """Check XDG_DATA_HOME Steam directory + + Returns: + DllDetectionResponse if found, None otherwise + """ + data_dir = os.getenv(ENV_XDG_DATA_HOME) + if data_dir and data_dir.strip(): + dll_path = Path(data_dir.strip()) / "Steam" / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME + if dll_path.exists(): + self.log.info(f"Found DLL via {ENV_XDG_DATA_HOME}: {dll_path}") + return { + "detected": True, + "path": str(dll_path), + "source": f"{ENV_XDG_DATA_HOME} Steam directory", + "message": None, + "error": None + } + return None + + def _check_home_local_share(self) -> DllDetectionResponse | None: + """Check HOME/.local/share Steam directory + + Returns: + DllDetectionResponse if found, None otherwise + """ + home_dir = os.getenv(ENV_HOME) + if home_dir and home_dir.strip(): + dll_path = Path(home_dir.strip()) / ".local" / "share" / "Steam" / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME + if dll_path.exists(): + self.log.info(f"Found DLL via {ENV_HOME}/.local/share: {dll_path}") + return { + "detected": True, + "path": str(dll_path), + "source": f"{ENV_HOME}/.local/share Steam directory", + "message": None, + "error": None + } + return None diff --git a/lsfg_vk/installation.py b/lsfg_vk/installation.py new file mode 100644 index 0000000..1d0e96f --- /dev/null +++ b/lsfg_vk/installation.py @@ -0,0 +1,225 @@ +""" +Installation service for lsfg-vk. +""" + +import os +import shutil +import zipfile +import tempfile +from pathlib import Path +from typing import Dict, Any + +from .base_service import BaseService +from .constants import ( + LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME, BIN_DIR, + SO_EXT, JSON_EXT, LSFG_SCRIPT_TEMPLATE, + DEFAULT_MULTIPLIER, DEFAULT_FLOW_SCALE, DEFAULT_ENABLE_LSFG, + DEFAULT_HDR, DEFAULT_PERF_MODE, DEFAULT_IMMEDIATE_MODE +) +from .types import InstallationResponse, UninstallationResponse, InstallationCheckResponse + + +class InstallationService(BaseService): + """Service for handling lsfg-vk installation and uninstallation""" + + def __init__(self, logger=None): + super().__init__(logger) + + # File paths using constants + self.lib_file = self.local_lib_dir / LIB_FILENAME + self.json_file = self.local_share_dir / JSON_FILENAME + + def install(self) -> InstallationResponse: + """Install lsfg-vk by extracting the zip file to ~/.local + + Returns: + InstallationResponse with success status and message/error + """ + try: + # Get the path to the zip file - need to go up to plugin root from py_modules/lsfg_vk/ + plugin_dir = Path(__file__).parent.parent.parent + zip_path = plugin_dir / BIN_DIR / ZIP_FILENAME + + # Check if the zip file exists + if not zip_path.exists(): + error_msg = f"{ZIP_FILENAME} not found at {zip_path}" + self.log.error(error_msg) + return {"success": False, "error": error_msg, "message": ""} + + # Create directories if they don't exist + self._ensure_directories() + + # Extract and install files + self._extract_and_install_files(zip_path) + + # Create the lsfg script + self._create_lsfg_script() + + self.log.info("lsfg-vk installed successfully") + return {"success": True, "message": "lsfg-vk installed successfully", "error": None} + + except (OSError, zipfile.BadZipFile, shutil.Error) as e: + error_msg = f"Error installing lsfg-vk: {str(e)}" + self.log.error(error_msg) + return {"success": False, "error": str(e), "message": ""} + except Exception as e: + # Catch unexpected errors but log them separately + error_msg = f"Unexpected error installing lsfg-vk: {str(e)}" + self.log.error(error_msg) + return {"success": False, "error": str(e), "message": ""} + + def _extract_and_install_files(self, zip_path: Path) -> None: + """Extract zip file and install files to appropriate locations + + Args: + zip_path: Path to the zip file to extract + + Raises: + zipfile.BadZipFile: If zip file is corrupted + OSError: If file operations fail + """ + # Destination mapping for file types + dest_map = { + SO_EXT: self.local_lib_dir, + JSON_EXT: self.local_share_dir + } + + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + zip_ref.extractall(temp_path) + + # Process extracted files + for root, dirs, files in os.walk(temp_path): + root_path = Path(root) + for file in files: + src_file = root_path / file + file_path = Path(file) + + # Check if we know where this file type should go + dst_dir = dest_map.get(file_path.suffix) + if dst_dir: + dst_file = dst_dir / file + shutil.copy2(src_file, dst_file) + self.log.info(f"Copied {file} to {dst_file}") + + def _create_lsfg_script(self) -> None: + """Create the lsfg script in home directory with default configuration""" + script_content = LSFG_SCRIPT_TEMPLATE.format( + enable_lsfg="export ENABLE_LSFG=1" if DEFAULT_ENABLE_LSFG else "# export ENABLE_LSFG=1", + multiplier=DEFAULT_MULTIPLIER, + flow_scale=DEFAULT_FLOW_SCALE, + hdr="export LSFG_HDR=1" if DEFAULT_HDR else "# export LSFG_HDR=1", + perf_mode="export LSFG_PERF_MODE=1" if DEFAULT_PERF_MODE else "# export LSFG_PERF_MODE=1", + immediate_mode="export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync" if DEFAULT_IMMEDIATE_MODE else "# export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync" + ) + + # Use atomic write to prevent corruption + self._atomic_write(self.lsfg_script_path, script_content, 0o755) + self.log.info(f"Created executable lsfg script at {self.lsfg_script_path}") + + def check_installation(self) -> InstallationCheckResponse: + """Check if lsfg-vk is already installed + + Returns: + InstallationCheckResponse with installation status and file paths + """ + try: + lib_exists = self.lib_file.exists() + json_exists = self.json_file.exists() + script_exists = self.lsfg_script_path.exists() + + self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, script={script_exists}") + + return { + "installed": lib_exists and json_exists, + "lib_exists": lib_exists, + "json_exists": json_exists, + "script_exists": script_exists, + "lib_path": str(self.lib_file), + "json_path": str(self.json_file), + "script_path": str(self.lsfg_script_path), + "error": None + } + + except Exception as e: + error_msg = f"Error checking lsfg-vk installation: {str(e)}" + self.log.error(error_msg) + return { + "installed": False, + "lib_exists": False, + "json_exists": False, + "script_exists": False, + "lib_path": str(self.lib_file), + "json_path": str(self.json_file), + "script_path": str(self.lsfg_script_path), + "error": str(e) + } + + def uninstall(self) -> UninstallationResponse: + """Uninstall lsfg-vk by removing the installed files + + Returns: + UninstallationResponse with success status and removed files list + """ + try: + removed_files = [] + files_to_remove = [self.lib_file, self.json_file, self.lsfg_script_path] + + for file_path in files_to_remove: + if self._remove_if_exists(file_path): + removed_files.append(str(file_path)) + + if not removed_files: + return { + "success": True, + "message": "No lsfg-vk files found to remove", + "removed_files": None, + "error": None + } + + self.log.info("lsfg-vk uninstalled successfully") + return { + "success": True, + "message": f"lsfg-vk uninstalled successfully. Removed {len(removed_files)} files.", + "removed_files": removed_files, + "error": None + } + + except OSError as e: + error_msg = f"Error uninstalling lsfg-vk: {str(e)}" + self.log.error(error_msg) + return { + "success": False, + "message": "", + "removed_files": None, + "error": str(e) + } + + def cleanup_on_uninstall(self) -> None: + """Clean up lsfg-vk files when the plugin is uninstalled""" + try: + self.log.info("Checking for lsfg-vk files to clean up:") + self.log.info(f" Library file: {self.lib_file}") + self.log.info(f" JSON file: {self.json_file}") + self.log.info(f" lsfg script: {self.lsfg_script_path}") + + removed_files = [] + files_to_remove = [self.lib_file, self.json_file, self.lsfg_script_path] + + for file_path in files_to_remove: + try: + if self._remove_if_exists(file_path): + removed_files.append(str(file_path)) + except OSError as e: + self.log.error(f"Failed to remove {file_path}: {e}") + + if removed_files: + self.log.info(f"Cleaned up {len(removed_files)} lsfg-vk files during plugin uninstall: {removed_files}") + else: + self.log.info("No lsfg-vk files found to clean up during plugin uninstall") + + except Exception as e: + self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {str(e)}") + import traceback + self.log.error(f"Traceback: {traceback.format_exc()}") diff --git a/lsfg_vk/plugin.py b/lsfg_vk/plugin.py new file mode 100644 index 0000000..000830f --- /dev/null +++ b/lsfg_vk/plugin.py @@ -0,0 +1,159 @@ +""" +Main plugin class for the lsfg-vk Decky Loader plugin. + +This plugin provides services for installing and managing the lsfg-vk +Vulkan layer for Lossless Scaling frame generation on Steam Deck. +""" + +import os +from typing import Dict, Any + +from .installation import InstallationService +from .dll_detection import DllDetectionService +from .configuration import ConfigurationService + + +class Plugin: + """ + Main plugin class for lsfg-vk management. + + This class provides a unified interface for installation, configuration, + and DLL detection services. It implements the Decky Loader plugin lifecycle + methods (_main, _unload, _uninstall, _migration). + """ + + def __init__(self): + """Initialize the plugin with all necessary services""" + # Initialize services - they will use decky.logger by default + self.installation_service = InstallationService() + self.dll_detection_service = DllDetectionService() + self.configuration_service = ConfigurationService() + + # Installation methods + async def install_lsfg_vk(self) -> Dict[str, Any]: + """Install lsfg-vk by extracting the zip file to ~/.local + + Returns: + InstallationResponse dict with success status and message/error + """ + return self.installation_service.install() + + async def check_lsfg_vk_installed(self) -> Dict[str, Any]: + """Check if lsfg-vk is already installed + + Returns: + InstallationCheckResponse dict with installation status and paths + """ + return self.installation_service.check_installation() + + async def uninstall_lsfg_vk(self) -> Dict[str, Any]: + """Uninstall lsfg-vk by removing the installed files + + Returns: + UninstallationResponse dict with success status and removed files + """ + return self.installation_service.uninstall() + + # DLL detection methods + async def check_lossless_scaling_dll(self) -> Dict[str, Any]: + """Check if Lossless Scaling DLL is available at the expected paths + + Returns: + DllDetectionResponse dict with detection status and path info + """ + return self.dll_detection_service.check_lossless_scaling_dll() + + # Configuration methods + async def get_lsfg_config(self) -> Dict[str, Any]: + """Read current lsfg script configuration + + Returns: + ConfigurationResponse dict with current configuration or error + """ + return self.configuration_service.get_config() + + async def update_lsfg_config(self, enable_lsfg: bool, multiplier: int, flow_scale: float, + hdr: bool, perf_mode: bool, immediate_mode: bool) -> Dict[str, Any]: + """Update lsfg script configuration + + Args: + enable_lsfg: Whether to enable LSFG + multiplier: LSFG multiplier value (typically 2-4) + flow_scale: LSFG flow scale value (typically 0.5-2.0) + hdr: Whether to enable HDR + perf_mode: Whether to enable performance mode + immediate_mode: Whether to enable immediate present mode (disable vsync) + + Returns: + ConfigurationResponse dict with success status + """ + return self.configuration_service.update_config( + enable_lsfg, multiplier, flow_scale, hdr, perf_mode, immediate_mode + ) + + # Plugin lifecycle methods + async def _main(self): + """ + Asyncio-compatible long-running code, executed in a task when the plugin is loaded. + + This method is called by Decky Loader when the plugin starts up. + Currently just logs that the plugin has loaded successfully. + """ + import decky + decky.logger.info("Lossless Scaling VK plugin loaded!") + + async def _unload(self): + """ + Function called first during the unload process. + + This method is called by Decky Loader when the plugin is being unloaded. + Use this for cleanup that should happen when the plugin stops. + """ + import decky + decky.logger.info("Lossless Scaling VK plugin unloading") + + async def _uninstall(self): + """ + Function called after `_unload` during uninstall. + + This method is called by Decky Loader when the plugin is being uninstalled. + It automatically cleans up any lsfg-vk files that were installed. + """ + import decky + decky.logger.info("Lossless Scaling VK plugin uninstalled - starting cleanup") + + # Clean up lsfg-vk files when the plugin is uninstalled + self.installation_service.cleanup_on_uninstall() + + decky.logger.info("Lossless Scaling VK plugin uninstall cleanup completed") + + async def _migration(self): + """ + Migrations that should be performed before entering `_main()`. + + This method is called by Decky Loader for plugin migrations. + Currently migrates logs, settings, and runtime data from old locations. + """ + import decky + decky.logger.info("Running Lossless Scaling VK plugin migrations") + + # Migrate logs from old location + # ~/.config/decky-lossless-scaling-vk/lossless-scaling-vk.log -> decky.DECKY_LOG_DIR/lossless-scaling-vk.log + decky.migrate_logs(os.path.join(decky.DECKY_USER_HOME, + ".config", "decky-lossless-scaling-vk", "lossless-scaling-vk.log")) + + # Migrate settings from old locations + # ~/homebrew/settings/lossless-scaling-vk.json -> decky.DECKY_SETTINGS_DIR/lossless-scaling-vk.json + # ~/.config/decky-lossless-scaling-vk/ -> decky.DECKY_SETTINGS_DIR/ + decky.migrate_settings( + os.path.join(decky.DECKY_HOME, "settings", "lossless-scaling-vk.json"), + os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk")) + + # Migrate runtime data from old locations + # ~/homebrew/lossless-scaling-vk/ -> decky.DECKY_RUNTIME_DIR/ + # ~/.local/share/decky-lossless-scaling-vk/ -> decky.DECKY_RUNTIME_DIR/ + decky.migrate_runtime( + os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"), + os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk")) + + decky.logger.info("Lossless Scaling VK plugin migrations completed") diff --git a/lsfg_vk/types.py b/lsfg_vk/types.py new file mode 100644 index 0000000..f0ec892 --- /dev/null +++ b/lsfg_vk/types.py @@ -0,0 +1,71 @@ +""" +Type definitions for the lsfg-vk plugin responses. +""" + +from typing import TypedDict, Optional, List + + +class BaseResponse(TypedDict): + """Base response structure""" + success: bool + + +class ErrorResponse(BaseResponse): + """Response structure for errors""" + error: str + + +class MessageResponse(BaseResponse): + """Response structure with message""" + message: str + + +class InstallationResponse(BaseResponse): + """Response for installation operations""" + message: str + error: Optional[str] + + +class UninstallationResponse(BaseResponse): + """Response for uninstallation operations""" + message: str + removed_files: Optional[List[str]] + error: Optional[str] + + +class InstallationCheckResponse(TypedDict): + """Response for installation check""" + installed: bool + lib_exists: bool + json_exists: bool + script_exists: bool + lib_path: str + json_path: str + script_path: str + error: Optional[str] + + +class DllDetectionResponse(TypedDict): + """Response for DLL detection""" + detected: bool + path: Optional[str] + source: Optional[str] + message: Optional[str] + error: Optional[str] + + +class ConfigurationData(TypedDict): + """Configuration data structure""" + enable_lsfg: bool + multiplier: int + flow_scale: float + hdr: bool + perf_mode: bool + immediate_mode: bool + + +class ConfigurationResponse(BaseResponse): + """Response for configuration operations""" + config: Optional[ConfigurationData] + message: Optional[str] + error: Optional[str] diff --git a/main_new.py b/main_new.py new file mode 100644 index 0000000..18b93f9 --- /dev/null +++ b/main_new.py @@ -0,0 +1,13 @@ +""" +Main entry point for the lsfg-vk Decky Loader plugin. + +This file imports and exposes the Plugin class from the lsfg_vk package. +The actual implementation has been refactored into separate service modules +for better maintainability and testability. +""" + +# Import the refactored Plugin class +from lsfg_vk import Plugin + +# Re-export Plugin at module level for Decky Loader compatibility +__all__ = ['Plugin'] diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..b2a0693 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +# Testing dependencies for the lsfg-vk plugin +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +pyfakefs>=5.0.0 diff --git a/sync-py-modules.sh b/sync-py-modules.sh new file mode 100644 index 0000000..f79108d --- /dev/null +++ b/sync-py-modules.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# sync-py-modules.sh +# Script to keep py_modules/lsfg_vk in sync with development changes + +echo "Syncing py_modules/lsfg_vk..." + +# Remove old py_modules content +rm -rf py_modules/lsfg_vk/__pycache__ 2>/dev/null + +# Copy updated files (excluding cache) +rsync -av --exclude="__pycache__" lsfg_vk/ py_modules/lsfg_vk/ 2>/dev/null || { + echo "Note: lsfg_vk/ directory not found - this is expected after cleanup" + echo "py_modules/lsfg_vk/ is now the primary development location" +} + +echo "py_modules sync complete" diff --git a/test_refactored.py b/test_refactored.py new file mode 100644 index 0000000..a25d9ef --- /dev/null +++ b/test_refactored.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify the refactored plugin works. +""" + +import sys +from pathlib import Path +from unittest.mock import Mock + +# Add the project root to the path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +# Mock the decky module +mock_decky = Mock() +mock_decky.logger = Mock() +sys.modules['decky'] = mock_decky + +# Now we can import our plugin +from lsfg_vk import Plugin + +def test_plugin_creation(): + """Test that we can create a plugin instance""" + print("๐งช Testing plugin creation...") + plugin = Plugin() + print("โ
Plugin created successfully!") + return plugin + +def test_installation_check(): + """Test the installation check method""" + print("๐งช Testing installation check...") + plugin = Plugin() + result = plugin.check_lsfg_vk_installed() + print(f"โ
Installation check result: {result}") + return result + +def test_dll_detection(): + """Test the DLL detection method""" + print("๐งช Testing DLL detection...") + plugin = Plugin() + result = plugin.check_lossless_scaling_dll() + print(f"โ
DLL detection result: {result}") + return result + +def test_config_operations(): + """Test configuration operations""" + print("๐งช Testing configuration operations...") + plugin = Plugin() + + # This will fail since the script doesn't exist, but should return a proper error + result = plugin.get_lsfg_config() + print(f"โ
Config get result: {result}") + + return result + +if __name__ == "__main__": + print("๐ Starting refactored plugin tests...\n") + + try: + test_plugin_creation() + print() + + test_installation_check() + print() + + test_dll_detection() + print() + + test_config_operations() + print() + + print("๐ All tests completed successfully!") + print("๐ฆ The refactored plugin structure is working correctly.") + + except Exception as e: + print(f"โ Test failed with error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9ac31a0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +""" +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 new file mode 100644 index 0000000..a7f80b4 --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,142 @@ +""" +Tests for the configuration service. +""" + +import tempfile +from pathlib import Path +from unittest.mock import Mock + +from lsfg_vk.configuration import ConfigurationService + + +def test_parse_script_content(): + """Test parsing of script content""" + mock_logger = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + temp_home = Path(temp_dir) + + # Create service with mocked home directory + service = ConfigurationService(logger=mock_logger) + service.user_home = temp_home + service.lsfg_script_path = temp_home / "lsfg" + + # Test script content + script_content = """#!/bin/bash + +export ENABLE_LSFG=1 +export LSFG_MULTIPLIER=3 +export LSFG_FLOW_SCALE=1.5 +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 True + assert config["multiplier"] == 3 + assert config["flow_scale"] == 1.5 + assert config["hdr"] is True + assert config["perf_mode"] is False # commented out + assert config["immediate_mode"] is True + + +def test_parse_script_content_all_commented(): + """Test parsing when all optional features are commented out""" + 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" + + 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" + + content = service._generate_script_content( + enable_lsfg=True, + multiplier=4, + flow_scale=2.0, + hdr=False, + perf_mode=True, + immediate_mode=False + ) + + assert "export ENABLE_LSFG=1" in content + assert "export LSFG_MULTIPLIER=4" in content + assert "export LSFG_FLOW_SCALE=2.0" in content + assert "# export LSFG_HDR=1" in content + assert "export LSFG_PERF_MODE=1" in content + assert "# export MESA_VK_WSI_PRESENT_MODE=immediate" 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( + enable_lsfg=True, + multiplier=3, + flow_scale=1.5, + hdr=True, + perf_mode=False, + immediate_mode=True + ) + + 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["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 new file mode 100644 index 0000000..e50d733 --- /dev/null +++ b/tests/test_dll_detection.py @@ -0,0 +1,129 @@ +""" +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 new file mode 100644 index 0000000..2b3690e --- /dev/null +++ b/tests/test_installation.py @@ -0,0 +1,150 @@ +""" +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 |
