summaryrefslogtreecommitdiff
path: root/py_modules/lsfg_vk
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-12 21:29:08 -0400
committerGitHub <noreply@github.com>2026-09-12 21:29:08 -0400
commite580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch)
tree7fc6799626519e5b01fb137f5440c99fda5faca7 /py_modules/lsfg_vk
parente997a3fb74fa70f5e60b60807fb0897120e98313 (diff)
parent81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff)
downloaddecky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz
decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
Diffstat (limited to 'py_modules/lsfg_vk')
-rw-r--r--py_modules/lsfg_vk/base_service.py144
-rw-r--r--py_modules/lsfg_vk/config_schema.py687
-rw-r--r--py_modules/lsfg_vk/config_schema_generated.py125
-rw-r--r--py_modules/lsfg_vk/configuration.py627
-rw-r--r--py_modules/lsfg_vk/constants.py46
-rw-r--r--py_modules/lsfg_vk/dll_detection.py223
-rw-r--r--py_modules/lsfg_vk/flatpak_profile_service.py383
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py1037
-rw-r--r--py_modules/lsfg_vk/installation.py623
-rw-r--r--py_modules/lsfg_vk/plugin.py605
-rw-r--r--py_modules/lsfg_vk/runtime_service.py120
-rw-r--r--py_modules/lsfg_vk/steam_service.py290
-rw-r--r--py_modules/lsfg_vk/types.py82
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py464
14 files changed, 2629 insertions, 2827 deletions
diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py
index 262e2b0..036dfc6 100644
--- a/py_modules/lsfg_vk/base_service.py
+++ b/py_modules/lsfg_vk/base_service.py
@@ -1,132 +1,66 @@
-"""
-Base service class with common functionality.
-"""
-
-import logging
import os
-import shutil
+import tempfile
from pathlib import Path
-from typing import Any, Optional, TypeVar, Dict
+from typing import Any, Dict, Optional, TypeVar
import decky
-from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME
+from .constants import CONFIG_DIR, CONFIG_FILENAME, LOCAL_BIN, LOCAL_LIB, SCRIPT_NAME, VULKAN_LAYER_DIR
-ResponseType = TypeVar('ResponseType', bound=Dict[str, Any])
+ResponseType = TypeVar("ResponseType", bound=Dict[str, Any])
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:
- self.log = decky.logger
- else:
- self.log = logger
-
- self.user_home = Path.home()
+ self.log = decky.logger if logger is None else logger
+ decky_user_home = getattr(decky, "DECKY_USER_HOME", None)
+ self.user_home = Path(decky_user_home) if decky_user_home else Path.home()
+ self.local_bin_dir = self.user_home / LOCAL_BIN
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
- self.lsfg_launch_script_path = self.user_home / SCRIPT_NAME
+ self.legacy_script_path = self.user_home / SCRIPT_NAME
self.config_dir = self.user_home / CONFIG_DIR
self.config_file_path = self.config_dir / CONFIG_FILENAME
-
+
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.config_dir.mkdir(parents=True, exist_ok=True)
- self.log.info(f"Ensured directories exist: {self.local_lib_dir}, {self.local_share_dir}, {self.config_dir}")
-
+ for directory in (self.local_bin_dir, self.local_lib_dir, self.local_share_dir, self.config_dir):
+ directory.mkdir(parents=True, exist_ok=True)
+
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}")
+ if not path.exists() and not path.is_symlink():
return False
-
+ path.unlink()
+ self.log.info(f"Removed {path}")
+ return True
+
def _write_file(self, path: Path, content: str, mode: int = 0o644) -> None:
- """Write content to a file
-
- Args:
- path: Target file path
- content: Content to write
- mode: File permissions (default: 0o644)
-
- Raises:
- OSError: If write fails
- """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary_path = None
try:
- with open(path, 'w', encoding='utf-8') as f:
- f.write(content)
- f.flush()
- os.fsync(f.fileno())
-
- path.chmod(mode)
- self.log.info(f"Wrote to {path}")
-
- except (OSError, IOError, PermissionError) as e:
- self.log.error(f"Failed to write to {path}: {e}")
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ dir=path.parent,
+ prefix=f".{path.name}.",
+ delete=False,
+ ) as temporary_file:
+ temporary_path = Path(temporary_file.name)
+ temporary_file.write(content)
+ temporary_file.flush()
+ os.fsync(temporary_file.fileno())
+ temporary_path.chmod(mode)
+ os.replace(temporary_path, path)
+ except Exception:
+ if temporary_path is not None:
+ temporary_path.unlink(missing_ok=True)
raise
def _success_response(self, response_type: type, message: str = "", **kwargs) -> Any:
- """Create a standardized success response
-
- Args:
- response_type: The TypedDict response type to create
- message: Success message
- **kwargs: Additional response fields
-
- Returns:
- Success response dict
- """
- response = {
- "success": True,
- "message": message,
- "error": None
- }
+ response = {"success": True, "message": message, "error": None}
response.update(kwargs)
return response
-
+
def _error_response(self, response_type: type, error: str, message: str = "", **kwargs) -> Any:
- """Create a standardized error response
-
- Args:
- response_type: The TypedDict response type to create
- error: Error description
- message: Optional message
- **kwargs: Additional response fields
-
- Returns:
- Error response dict
- """
- response = {
- "success": False,
- "message": message,
- "error": error
- }
+ response = {"success": False, "message": message, "error": error}
response.update(kwargs)
return response
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index f76b5f2..bf3e174 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -1,605 +1,118 @@
-"""
-Centralized configuration schema for lsfg-vk.
+"""Small adapter for the upstream lsfg-vk v2 configuration format."""
-This module defines the complete configuration structure for lsfg-vk, managing TOML-based config files, including:
-- Field definitions with types, defaults, and metadata
-- TOML generation logic
-- Validation rules
-- Type definitions
-"""
+import json
+import tomllib
+from typing import Any, Dict, TypedDict
-import logging
-import re
-import sys
-from typing import TypedDict, Dict, Any, Union, cast, List
-from dataclasses import dataclass
-from enum import Enum
-from pathlib import Path
+ConfigurationData = Dict[str, Any]
-# Import shared configuration constants
-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:
- """Configuration field definition"""
- name: str
- field_type: ConfigFieldType
- default: Union[bool, int, float, str]
- description: str
-
- def get_toml_value(self, value: Union[bool, int, float, str]) -> Union[bool, int, float, str]:
- """Get the value for TOML output"""
- return value
-
-
-# Use shared configuration schema as source of truth
-CONFIG_SCHEMA: Dict[str, ConfigField] = {
- 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()
+class ProfileData(TypedDict):
+ profiles: Dict[str, Dict[str, Any]]
+ global_config: Dict[str, Any]
+
+
+PROFILE_DEFAULTS: Dict[str, Any] = {
+ "active_in": [],
+ "pacing_mode": "vsync",
+ "multiplier": 2,
+ "flow_scale": 0.8,
+ "performance_mode": False,
+ "override_present_mode": True,
+ "preserve_swapchain_image_count": False,
}
+GLOBAL_DEFAULTS: Dict[str, Any] = {"dll": "", "no_fp16": False}
-# Override DLL default to empty (will be populated dynamically)
-CONFIG_SCHEMA["dll"] = ConfigField(
- name="dll",
- field_type=ConfigFieldType.STRING,
- default="", # Will be populated dynamically based on detection
- description="specify where Lossless.dll is stored"
-)
-
-# Get script-only fields dynamically from shared config
-SCRIPT_ONLY_FIELDS = {
- 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}
+def _toml_value(value: Any) -> str:
+ if isinstance(value, bool):
+ return str(value).lower()
+ if isinstance(value, str):
+ return json.dumps(value)
+ if isinstance(value, list):
+ return "[ " + ", ".join(_toml_value(item) for item in value) + " ]"
+ return str(value)
-# Constants for profile management
-DEFAULT_PROFILE_NAME = "decky-lsfg-vk"
-GLOBAL_SECTION_FIELDS = {"dll", "no_fp16"}
-# Note: ConfigurationData is now imported from generated file
-# No need to manually maintain the TypedDict anymore!
-
-
-class ProfileData(TypedDict):
- """Profile data with current profile tracking"""
- current_profile: str
- profiles: Dict[str, ConfigurationData] # profile_name -> config
- global_config: Dict[str, Any] # Global settings (dll, no_fp16)
+def _normalize_active_in(value: Any) -> list[str]:
+ if value in (None, ""):
+ return []
+ if isinstance(value, str):
+ return [value]
+ if not isinstance(value, list):
+ raise ValueError("active_in must be a string or list of strings")
+ return [str(item) for item in value if str(item)]
class ConfigurationManager:
- """Centralized configuration management"""
-
- @staticmethod
- def get_defaults() -> ConfigurationData:
- """Get default configuration values"""
- # Use shared defaults and add script-only fields
- shared_defaults = get_defaults()
-
- # Add script-only fields that aren't in the shared schema
- script_defaults = {
- field.name: field.default
- for field in SCRIPT_ONLY_FIELDS.values()
- }
-
- return cast(ConfigurationData, {**shared_defaults, **script_defaults})
-
- @staticmethod
- def get_defaults_with_dll_detection(dll_detection_service=None) -> ConfigurationData:
- """Get default configuration values with DLL path detection
-
- Args:
- dll_detection_service: Optional DLL detection service instance
-
- Returns:
- ConfigurationData with detected DLL path if available
- """
- defaults = ConfigurationManager.get_defaults()
-
- # Try to detect DLL path if service provided
- if dll_detection_service:
- try:
- dll_result = dll_detection_service.check_lossless_scaling_dll()
- if dll_result.get("detected") and dll_result.get("path"):
- defaults["dll"] = dll_result["path"]
- except (OSError, IOError, KeyError, TypeError) as e:
- # If detection fails, keep empty default
- logging.getLogger(__name__).debug(f"DLL detection failed: {e}")
-
- # If DLL path is still empty, use a reasonable fallback
- if not defaults["dll"]:
- defaults["dll"] = "/home/deck/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"
-
- return defaults
-
- @staticmethod
- def get_field_names() -> list[str]:
- """Get ordered list of configuration field names"""
- # Use shared field names and add script-only fields
- shared_names = get_field_names()
- script_names = list(SCRIPT_ONLY_FIELDS.keys())
- return shared_names + script_names
-
- @staticmethod
- def get_field_types() -> Dict[str, ConfigFieldType]:
- """Get field type mapping"""
- # Use shared field types and add script-only field types
- shared_types = {name: ConfigFieldType(type_str) for name, type_str in get_field_types().items()}
- script_types = {field.name: field.field_type for field in SCRIPT_ONLY_FIELDS.values()}
- return {**shared_types, **script_types}
-
@staticmethod
- def validate_config(config: Dict[str, Any]) -> ConfigurationData:
- """Validate and convert configuration data"""
- validated = {}
-
- for field_name, field_def in COMPLETE_CONFIG_SCHEMA.items():
- value = config.get(field_name, field_def.default)
-
- # Type validation and conversion
- if field_def.field_type == ConfigFieldType.BOOLEAN:
- validated[field_name] = bool(value)
- elif field_def.field_type == ConfigFieldType.INTEGER:
- validated[field_name] = int(value)
- elif field_def.field_type == ConfigFieldType.FLOAT:
- validated[field_name] = float(value)
- elif field_def.field_type == ConfigFieldType.STRING:
- validated[field_name] = str(value)
- else:
- validated[field_name] = value
-
- return cast(ConfigurationData, validated)
-
- @staticmethod
- def generate_toml_content(config: ConfigurationData) -> str:
- """Generate TOML configuration file content for single profile (backward compatibility)"""
- # For backward compatibility, create a single profile structure
- profile_data: ProfileData = {
- "current_profile": DEFAULT_PROFILE_NAME,
- "profiles": {DEFAULT_PROFILE_NAME: config},
- "global_config": {
- "dll": config.get("dll", ""),
- "no_fp16": config.get("no_fp16", False)
- }
- }
- return ConfigurationManager.generate_toml_content_multi_profile(profile_data)
-
+ def get_defaults() -> Dict[str, Any]:
+ return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS}
+
+ @staticmethod
+ def validate_config(config: Dict[str, Any]) -> Dict[str, Any]:
+ result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS}
+ result.update({key: value for key, value in config.items() if key in result})
+ result["active_in"] = _normalize_active_in(result["active_in"])
+ result["pacing_mode"] = str(result["pacing_mode"]).lower()
+ if result["pacing_mode"] != "vsync":
+ raise ValueError("pacing_mode must be vsync")
+ result["multiplier"] = int(result["multiplier"])
+ if result["multiplier"] < 1:
+ raise ValueError("multiplier must be 1 or greater")
+ result["flow_scale"] = float(result["flow_scale"])
+ if not 0.25 <= result["flow_scale"] <= 1.0:
+ raise ValueError("flow_scale must be between 0.25 and 1.0")
+ for name in (
+ "no_fp16",
+ "performance_mode",
+ "override_present_mode",
+ "preserve_swapchain_image_count",
+ ):
+ result[name] = bool(result[name])
+ result["dll"] = str(result["dll"] or "")
+ return result
+
@staticmethod
def generate_toml_content_multi_profile(profile_data: ProfileData) -> str:
- """Generate TOML configuration file content with multiple profiles"""
- lines = ["version = 1"]
- lines.append("")
-
- # Add global section with global fields
- lines.append("[global]")
-
- # Add current_profile field
- lines.append(f"# Currently selected profile")
- lines.append(f'current_profile = "{profile_data["current_profile"]}"')
- lines.append("")
-
- # Add dll field if specified
- dll_path = profile_data["global_config"].get("dll", "")
- if dll_path:
- lines.append(f"# specify where Lossless.dll is stored")
- lines.append(f'dll = "{dll_path}"')
- lines.append("")
+ global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})}
+ lines = ["version = 2", "", "[global]"]
+ if global_config["dll"]:
+ lines.append(f"dll = {_toml_value(global_config['dll'])}")
+ lines.append(f"allow_fp16 = {_toml_value(not bool(global_config['no_fp16']))}")
+ profiles = sorted(profile_data["profiles"].items()) or [("", {})]
+ for name, raw in profiles:
+ config = ConfigurationManager.validate_config({**raw, **global_config})
+ lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"])
+ if config["active_in"]:
+ lines.append(f"active_in = {_toml_value(config['active_in'])}")
+ lines.extend([
+ f"pacing_mode = {_toml_value(config['pacing_mode'])}",
+ f"multiplier = {config['multiplier']}",
+ f"flow_scale = {config['flow_scale']}",
+ f"performance_mode = {_toml_value(config['performance_mode'])}",
+ f"override_present_mode = {_toml_value(config['override_present_mode'])}",
+ f"preserve_swapchain_image_count = {_toml_value(config['preserve_swapchain_image_count'])}",
+ ])
+ return "\n".join(lines) + "\n"
- lines.append(f"# FP16 acceleration")
- no_fp16 = bool(profile_data["global_config"].get("no_fp16", False))
- lines.append(f"no_fp16 = {str(no_fp16).lower()}")
- lines.append("")
-
- # Add game sections for each profile
- # Sort profiles to ensure consistent order (default profile first)
- sorted_profiles = sorted(profile_data["profiles"].items(),
- key=lambda x: (x[0] != DEFAULT_PROFILE_NAME, x[0]))
-
- for profile_name, config in sorted_profiles:
- lines.append("[[game]]")
- if profile_name == DEFAULT_PROFILE_NAME:
- lines.append("# Plugin-managed game entry (default profile)")
- else:
- lines.append(f"# Profile: {profile_name}")
- lines.append(f'exe = "{profile_name}"')
- lines.append("")
-
- # Add all configuration fields to the game section (excluding global fields)
- for field_name, field_def in CONFIG_SCHEMA.items():
- # Skip global fields - they go in global section
- if field_name in GLOBAL_SECTION_FIELDS:
- continue
-
- value = config.get(field_name, field_def.default)
-
- # Add field description comment
- lines.append(f"# {field_def.description}")
-
- # Format value based on type
- if isinstance(value, bool):
- lines.append(f"{field_name} = {str(value).lower()}")
- elif isinstance(value, str) and value: # Only add non-empty strings
- lines.append(f'{field_name} = "{value}"')
- elif isinstance(value, (int, float)): # Always include numbers, even if 0 or 1
- lines.append(f"{field_name} = {value}")
-
- lines.append("") # Empty line for readability
-
- return "\n".join(lines)
-
- @staticmethod
- def parse_toml_content(content: str) -> ConfigurationData:
- """Parse TOML content into configuration data for the currently selected profile (backward compatibility)"""
- profile_data = ConfigurationManager.parse_toml_content_multi_profile(content)
- current_profile = profile_data["current_profile"]
-
- # Merge global config with current profile config
- current_config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults())
-
- # Add global fields to the config
- for field_name in GLOBAL_SECTION_FIELDS:
- if field_name in profile_data["global_config"]:
- current_config[field_name] = profile_data["global_config"][field_name]
-
- return current_config
-
@staticmethod
def parse_toml_content_multi_profile(content: str) -> ProfileData:
- """Parse TOML content into profile data structure"""
- profiles: Dict[str, ConfigurationData] = {}
- global_config: Dict[str, Any] = {}
- current_profile = DEFAULT_PROFILE_NAME
-
- try:
- # Look for both [global] and [[game]] sections
- lines = content.split('\n')
- in_global_section = False
- in_game_section = False
- current_game_exe = None
- current_game_config: Dict[str, Any] = {}
-
- for line in lines:
- line = line.strip()
-
- # Skip comments and empty lines
- if not line or line.startswith('#'):
- continue
-
- # Check for section headers
- if line.startswith('[') and line.endswith(']'):
- # Save previous game section if we were in one
- if in_game_section and current_game_exe:
- # Validate and store the profile config
- validated_config = ConfigurationManager.get_defaults()
- for key, value in current_game_config.items():
- if key in CONFIG_SCHEMA:
- field_def = CONFIG_SCHEMA[key]
- try:
- if field_def.field_type == ConfigFieldType.BOOLEAN:
- validated_config[key] = value
- elif field_def.field_type == ConfigFieldType.INTEGER:
- validated_config[key] = int(value) if not isinstance(value, int) else value
- elif field_def.field_type == ConfigFieldType.FLOAT:
- validated_config[key] = float(value) if not isinstance(value, float) else value
- elif field_def.field_type == ConfigFieldType.STRING:
- validated_config[key] = str(value)
- except (ValueError, TypeError):
- # If conversion fails, keep default value
- pass
- profiles[current_game_exe] = validated_config
- current_game_config = {}
-
- # Set new section state
- if line == '[global]':
- in_global_section = True
- in_game_section = False
- elif line == '[[game]]':
- in_global_section = False
- in_game_section = True
- current_game_exe = None
- else:
- in_global_section = False
- in_game_section = False
- continue
-
- # Parse key = value lines
- if '=' in line:
- key, value = line.split('=', 1)
- key = key.strip()
- value = value.strip()
-
- # Remove quotes from string values
- if value.startswith('"') and value.endswith('"'):
- value = value[1:-1]
- elif value.startswith("'") and value.endswith("'"):
- value = value[1:-1]
-
- # Handle global section
- if in_global_section:
- if key == "current_profile":
- current_profile = value
- elif key == "dll":
- global_config["dll"] = value
- elif key == "no_fp16":
- global_config["no_fp16"] = value.lower() in ('true', '1', 'yes', 'on')
-
- # Handle game section
- elif in_game_section:
- # Track the exe for this game section
- if key == "exe":
- current_game_exe = value
- # Store config fields for current game
- elif key in CONFIG_SCHEMA:
- field_def = CONFIG_SCHEMA[key]
- try:
- if field_def.field_type == ConfigFieldType.BOOLEAN:
- current_game_config[key] = value.lower() in ('true', '1', 'yes', 'on')
- elif field_def.field_type == ConfigFieldType.INTEGER:
- current_game_config[key] = int(value)
- elif field_def.field_type == ConfigFieldType.FLOAT:
- current_game_config[key] = float(value)
- elif field_def.field_type == ConfigFieldType.STRING:
- current_game_config[key] = value
- except (ValueError, TypeError):
- # If conversion fails, keep default value
- pass
-
- # Handle final game section if we were in one
- if in_game_section and current_game_exe:
- validated_config = ConfigurationManager.get_defaults()
- for key, value in current_game_config.items():
- if key in CONFIG_SCHEMA:
- field_def = CONFIG_SCHEMA[key]
- try:
- if field_def.field_type == ConfigFieldType.BOOLEAN:
- validated_config[key] = value
- elif field_def.field_type == ConfigFieldType.INTEGER:
- validated_config[key] = int(value) if not isinstance(value, int) else value
- elif field_def.field_type == ConfigFieldType.FLOAT:
- validated_config[key] = float(value) if not isinstance(value, float) else value
- elif field_def.field_type == ConfigFieldType.STRING:
- validated_config[key] = str(value)
- except (ValueError, TypeError):
- # If conversion fails, keep default value
- pass
- profiles[current_game_exe] = validated_config
-
- # Ensure we have at least the default profile
- if not profiles:
- profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults()
-
- # Ensure current_profile exists in profiles
- if current_profile not in profiles:
- current_profile = DEFAULT_PROFILE_NAME
- if DEFAULT_PROFILE_NAME not in profiles:
- profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults()
-
- return ProfileData(
- current_profile=current_profile,
- profiles=profiles,
- global_config=global_config
- )
-
- except (ValueError, KeyError, TypeError, AttributeError) as e:
- # If parsing fails completely, return default profile structure
- logging.getLogger(__name__).warning(f"Failed to parse TOML profiles, using defaults: {e}")
- return ProfileData(
- current_profile=DEFAULT_PROFILE_NAME,
- profiles={DEFAULT_PROFILE_NAME: ConfigurationManager.get_defaults()},
- global_config={}
- )
-
- @staticmethod
- def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]:
- """Parse launch script content to extract environment variable values
-
- Args:
- script_content: Content of the launch script file
-
- Returns:
- Dict containing parsed script-only field 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:
- """Merge TOML configuration with script environment variable values
-
- Args:
- toml_config: Configuration loaded from TOML file
- script_values: Environment variable values parsed from script
-
- Returns:
- Complete configuration with script values overlaid on TOML config
- """
- merged_config = dict(toml_config)
-
- # Update script-only fields with values from script
- for field_name in SCRIPT_ONLY_FIELDS.keys():
- if field_name in script_values:
- merged_config[field_name] = script_values[field_name]
-
- return cast(ConfigurationData, merged_config)
-
- @staticmethod
- def normalize_profile_name(profile_name: str) -> str:
- """Normalize profile name by converting spaces to dashes and trimming
-
- This allows users to enter names with spaces, which are then safely
- converted to dashes for storage and shell script compatibility.
-
- Args:
- profile_name: The raw profile name from user input
-
- Returns:
- Normalized profile name with spaces converted to dashes
- """
- if not profile_name:
- return profile_name
-
- # Trim whitespace and convert spaces to dashes
- normalized = profile_name.strip().replace(' ', '-')
-
- # Collapse multiple consecutive dashes into one
- while '--' in normalized:
- normalized = normalized.replace('--', '-')
-
- # Remove leading/trailing dashes
- normalized = normalized.strip('-')
-
- return normalized
-
- @staticmethod
- def validate_profile_name(profile_name: str) -> bool:
- """Validate profile name for safety (after normalization)"""
- if not profile_name:
- return False
-
- # Normalize first - this converts spaces to dashes
- normalized = ConfigurationManager.normalize_profile_name(profile_name)
-
- if not normalized:
- return False
-
- # Check for invalid characters that could cause issues in shell scripts or TOML
- # Note: spaces are now allowed as input (they get converted to dashes)
- invalid_chars = set('\t\n\r\'"\\/$|&;()<>{}[]`*?')
- if any(char in invalid_chars for char in normalized):
- return False
-
- # Check for reserved names
- reserved_names = {'global', 'game', 'current_profile'}
- if normalized.lower() in reserved_names:
- return False
-
- return True
-
- @staticmethod
- def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData:
- """Create a new profile by copying from source profile or defaults"""
- if not ConfigurationManager.validate_profile_name(profile_name):
- raise ValueError(f"Invalid profile name: {profile_name}")
-
- # Normalize the profile name (converts spaces to dashes)
- profile_name = ConfigurationManager.normalize_profile_name(profile_name)
-
- if profile_name in profile_data["profiles"]:
- raise ValueError(f"Profile '{profile_name}' already exists")
-
- # Copy from source profile or use defaults
- if source_profile and source_profile in profile_data["profiles"]:
- new_config = dict(profile_data["profiles"][source_profile])
- else:
- new_config = ConfigurationManager.get_defaults()
-
- # Create new profile data structure
- new_profile_data = ProfileData(
- current_profile=profile_data["current_profile"],
- profiles=dict(profile_data["profiles"]),
- global_config=dict(profile_data["global_config"])
- )
- new_profile_data["profiles"][profile_name] = new_config
-
- return new_profile_data
-
- @staticmethod
- def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData:
- """Delete a profile (cannot delete default profile)"""
- if profile_name == DEFAULT_PROFILE_NAME:
- raise ValueError(f"Cannot delete default profile '{DEFAULT_PROFILE_NAME}'")
-
- if profile_name not in profile_data["profiles"]:
- raise ValueError(f"Profile '{profile_name}' does not exist")
-
- # Create new profile data structure
- new_profile_data = ProfileData(
- current_profile=profile_data["current_profile"],
- profiles=dict(profile_data["profiles"]),
- global_config=dict(profile_data["global_config"])
- )
-
- # Remove the profile
- del new_profile_data["profiles"][profile_name]
-
- # If we deleted the current profile, switch to default
- if new_profile_data["current_profile"] == profile_name:
- new_profile_data["current_profile"] = DEFAULT_PROFILE_NAME
- # Ensure default profile exists
- if DEFAULT_PROFILE_NAME not in new_profile_data["profiles"]:
- new_profile_data["profiles"][DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults()
-
- return new_profile_data
-
- @staticmethod
- def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData:
- """Rename a profile"""
- if old_name == DEFAULT_PROFILE_NAME:
- raise ValueError(f"Cannot rename default profile '{DEFAULT_PROFILE_NAME}'")
-
- if not ConfigurationManager.validate_profile_name(new_name):
- raise ValueError(f"Invalid profile name: {new_name}")
-
- # Normalize the new name (converts spaces to dashes)
- new_name = ConfigurationManager.normalize_profile_name(new_name)
-
- if old_name not in profile_data["profiles"]:
- raise ValueError(f"Profile '{old_name}' does not exist")
-
- if new_name in profile_data["profiles"]:
- raise ValueError(f"Profile '{new_name}' already exists")
-
- # Create new profile data structure
- new_profile_data = ProfileData(
- current_profile=profile_data["current_profile"],
- profiles={},
- global_config=dict(profile_data["global_config"])
- )
-
- # Copy profiles with new name
- for profile_name, config in profile_data["profiles"].items():
- if profile_name == old_name:
- new_profile_data["profiles"][new_name] = dict(config)
- else:
- new_profile_data["profiles"][profile_name] = dict(config)
-
- # Update current_profile if necessary
- if new_profile_data["current_profile"] == old_name:
- new_profile_data["current_profile"] = new_name
-
- return new_profile_data
-
- @staticmethod
- def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData:
- """Set the current active profile"""
- if profile_name not in profile_data["profiles"]:
- raise ValueError(f"Profile '{profile_name}' does not exist")
-
- # Create new profile data structure
- new_profile_data = ProfileData(
- current_profile=profile_name,
- profiles=dict(profile_data["profiles"]),
- global_config=dict(profile_data["global_config"])
- )
-
- return new_profile_data
+ data = tomllib.loads(content)
+ if data.get("version") != 2:
+ raise ValueError("unsupported lsfg-vk configuration version")
+ raw_global = data.get("global", {})
+ global_config = {
+ "dll": str(raw_global.get("dll", "") or ""),
+ "no_fp16": not bool(raw_global.get("allow_fp16", True)),
+ }
+ profiles: Dict[str, Dict[str, Any]] = {}
+ for profile in data.get("profile", []):
+ name = str(profile.get("name", ""))
+ config = ConfigurationManager.validate_config({
+ **profile,
+ **global_config,
+ })
+ if name or config["active_in"]:
+ profiles[name] = config
+ return {"profiles": profiles, "global_config": global_config}
diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py
deleted file mode 100644
index b320a97..0000000
--- a/py_modules/lsfg_vk/config_schema_generated.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""
-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
-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"
-NO_FP16 = "no_fp16"
-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"
-FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt"
-ENABLE_WSI = "enable_wsi"
-ENABLE_ZINK = "enable_zink"
-
-
-class ConfigurationData(TypedDict):
- """Type-safe configuration data structure - AUTO-GENERATED"""
- dll: str
- no_fp16: bool
- 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
- force_enable_vkbasalt: bool
- enable_wsi: bool
- enable_zink: 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"
- if key == "ENABLE_VKBASALT":
- script_values["force_enable_vkbasalt"] = value == "1"
- if key == "ENABLE_GAMESCOPE_WSI":
- script_values["enable_wsi"] = value != "0"
- if key == "DXVK_HDR":
- script_values["enable_wsi"] = value != "0"
- if key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa":
- script_values["enable_zink"] = True
- if key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink":
- script_values["enable_zink"] = True
- if key == "GALLIUM_DRIVER" and value == "zink":
- script_values["enable_zink"] = True
-
- 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")
- if config.get("force_enable_vkbasalt", False):
- lines.append("export ENABLE_VKBASALT=1")
- if not config.get("enable_wsi", False):
- lines.append("export ENABLE_GAMESCOPE_WSI=0")
- lines.append("export DXVK_HDR=0")
- if config.get("enable_zink", False):
- lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa")
- lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink")
- lines.append("export GALLIUM_DRIVER=zink")
- return lines
- return generate_script_lines
-
-
-ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink']
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index fce3738..3c65972 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -1,446 +1,213 @@
-"""
-Configuration service for TOML-based lsfg configuration management.
-"""
-
-from pathlib import Path
-from typing import Dict, Any
+import re
+from typing import Any, Dict
from .base_service import BaseService
-from .config_schema import ConfigurationManager, CONFIG_SCHEMA, ProfileData, DEFAULT_PROFILE_NAME
-from .config_schema_generated import ConfigurationData, get_script_generation_logic
-from .constants import ARMADA_DEVICE_ENV, ARMADA_GAME_LAUNCH
-from .types import ConfigurationResponse, ProfilesResponse, ProfileResponse
+from .config_schema import ConfigurationManager, ProfileData
+from .runtime_service import RuntimeService
class ConfigurationService(BaseService):
- """Service for managing TOML-based lsfg configuration"""
-
- def get_config(self) -> ConfigurationResponse:
- """Read current TOML configuration merged with launch script environment variables
-
- Returns:
- ConfigurationResponse with current configuration or error
- """
+ FLATPAK_PROFILE_PREFIX = "flatpak:"
+
+ def __init__(self, logger=None, runtime_service: RuntimeService = None):
+ super().__init__(logger)
+ self.runtime_service = runtime_service or RuntimeService(logger=self.log)
+
+ def _default_data(self) -> ProfileData:
+ return {"profiles": {}, "global_config": {"dll": "", "no_fp16": False}}
+
+ def _get_profile_data(self) -> ProfileData:
+ if not self.config_file_path.exists():
+ return self._default_data()
+ return ConfigurationManager.parse_toml_content_multi_profile(self.config_file_path.read_text(encoding="utf-8"))
+
+ def _save_profile_data(self, data: ProfileData) -> None:
+ content = ConfigurationManager.generate_toml_content_multi_profile(data)
+ self.runtime_service.validate_config_content(content)
+ self._write_file(self.config_file_path, content, 0o644)
+
+ @staticmethod
+ def _profile_name(data: ProfileData, appid: str, game_name: str) -> str:
+ name = str(game_name).strip()
+ if not name:
+ raise ValueError("game name is required")
+ existing = data["profiles"].get(name)
+ if existing is not None and str(appid) not in existing.get("active_in", []):
+ name = f"{name} ({appid})"
+ return name
+
+ @staticmethod
+ def _profile_for_appid(data: ProfileData, appid: str):
+ return next(
+ (
+ (name, profile)
+ for name, profile in data["profiles"].items()
+ if str(appid) in profile.get("active_in", [])
+ ),
+ (None, None),
+ )
+
+ @classmethod
+ def flatpak_profile_name(cls, app_id: str) -> str:
+ value = str(app_id).strip()
+ if not value:
+ raise ValueError("Flatpak application ID is required")
+ return f"{cls.FLATPAK_PROFILE_PREFIX}{value}"
+
+ @staticmethod
+ def _public_config(config: Dict[str, Any]) -> Dict[str, Any]:
+ return ConfigurationManager.validate_config(config)
+
+ def get_game_configs(self) -> Dict[str, Any]:
try:
- if not self.config_file_path.exists():
- from .dll_detection import DllDetectionService
- dll_service = DllDetectionService(self.log)
- toml_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- else:
- content = self.config_file_path.read_text(encoding='utf-8')
- toml_config = ConfigurationManager.parse_toml_content(content)
-
- script_values = {}
- if self.lsfg_script_path.exists():
- try:
- script_content = self.lsfg_script_path.read_text(encoding='utf-8')
- script_values = ConfigurationManager.parse_script_content(script_content)
- self.log.info(f"Parsed script values: {script_values}")
- except Exception as e:
- self.log.warning(f"Failed to parse launch script: {str(e)}")
-
- config = ConfigurationManager.merge_config_with_script(toml_config, script_values)
-
- return self._success_response(ConfigurationResponse, config=config)
-
- except (OSError, IOError) as e:
- error_msg = f"Error reading lsfg config: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ConfigurationResponse, str(e), config=None)
- except Exception as e:
- error_msg = f"Error parsing config file: {str(e)}"
- self.log.error(error_msg)
- from .dll_detection import DllDetectionService
- dll_service = DllDetectionService(self.log)
- config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- return self._success_response(ConfigurationResponse,
- f"Using default configuration due to parse error: {str(e)}",
- config=config)
-
- def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse:
- """Update TOML configuration from configuration dictionary (eliminates parameter duplication)
-
- Args:
- config: Complete configuration data dictionary
-
- Returns:
- ConfigurationResponse with success status
- """
+ data = self._get_profile_data()
+ games = []
+ for name, raw in data["profiles"].items():
+ active_in = raw.get("active_in", [])
+ if len(active_in) != 1 or not re.fullmatch(r"-?[0-9]+", str(active_in[0])):
+ continue
+ games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)})
+ return self._success_response(dict, global_config=dict(data["global_config"]), games=games)
+ except Exception as error:
+ self.log.error(f"Error reading game configs: {error}")
+ return self._error_response(dict, str(error), games=[])
+
+ def update_global_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
- current_profile = profile_data["current_profile"]
-
- return self.update_profile_config(current_profile, 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_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse:
- """Update the ~/lsfg launch script with current configuration
-
- Args:
- config: Configuration data to apply to the script
-
- Returns:
- ConfigurationResponse indicating success or failure
- """
+ data = self._get_profile_data()
+ merged_config = {**data["global_config"], **config}
+ validated = self._public_config(merged_config)
+ data["global_config"] = {
+ "dll": validated["dll"],
+ "no_fp16": validated["no_fp16"],
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]))
+ except Exception as error:
+ return self._error_response(dict, str(error), global_config=None)
+
+ def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
try:
- script_content = self._generate_script_content(config)
-
- self._write_file(self.lsfg_script_path, script_content, 0o755)
-
- self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path}")
-
- return self._success_response(ConfigurationResponse,
- "Launch script updated successfully",
- config=config)
-
- except Exception as e:
- error_msg = f"Error updating launch script: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ConfigurationResponse, str(e), config=None)
-
- def _generate_script_content(self, config: ConfigurationData) -> str:
- """Generate the content for the ~/lsfg launch script
-
- Args:
- config: Configuration data to apply to the script
-
- Returns:
- The complete script content as a string
- """
- lines = [
- "#!/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",
- ]
-
- generate_script_lines = get_script_generation_logic()
- lines.extend(generate_script_lines(config))
-
- lines.append("export LSFG_PROCESS=decky-lsfg-vk")
- lines.extend(self._generate_game_launch_lines())
-
- return "\n".join(lines) + "\n"
-
- def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str:
- """Generate the content for the ~/lsfg launch script with profile support
-
- Args:
- profile_data: Profile data containing current profile and configurations
-
- Returns:
- The complete script content as a string
- """
- current_profile = profile_data["current_profile"]
- config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults())
-
- merged_config = dict(config)
- for field_name, value in profile_data["global_config"].items():
- merged_config[field_name] = value
-
- lines = [
- "#!/bin/bash",
- f"# Current profile: {current_profile}",
- ]
-
- generate_script_lines = get_script_generation_logic()
- lines.extend(generate_script_lines(merged_config))
-
- lines.append(f"export LSFG_PROCESS={current_profile}")
- lines.extend(self._generate_game_launch_lines())
-
- return "\n".join(lines) + "\n"
+ data = self._get_profile_data()
+ old_name, _ = self._profile_for_appid(data, appid)
+ name = self._profile_name(data, appid, game_name)
+ merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}}
+ if not config.get("dll"):
+ merged_config["dll"] = data["global_config"].get("dll", "")
+ validated = self._public_config(merged_config)
+ validated["active_in"] = [str(appid)]
+ if old_name and old_name != name:
+ data["profiles"].pop(old_name, None)
+ data["profiles"][name] = validated
+ self._save_profile_data(data)
+ return self._success_response(dict, appid=str(appid), config=validated)
+ except Exception as error:
+ return self._error_response(dict, str(error), appid=str(appid), config=None)
- @staticmethod
- def _generate_game_launch_lines() -> list[str]:
- """Generate a portable exec block with Armada's host wrapper."""
- device_env = ARMADA_DEVICE_ENV.as_posix()
- game_launch = ARMADA_GAME_LAUNCH.as_posix()
- return [
- f'armada_game_launch="{game_launch}"',
- 'for argument in "$@"; do',
- ' if [ "$argument" = "$armada_game_launch" ]; then',
- ' exec "$@"',
- " fi",
- "done",
- f'if [ -f "{device_env}" ] && [ -x "$armada_game_launch" ]; then',
- ' exec "$armada_game_launch" "$@"',
- "fi",
- 'exec "$@"',
- ]
-
- def _get_profile_data(self) -> ProfileData:
- """Get current profile data from config file"""
- if not self.config_file_path.exists():
- from .dll_detection import DllDetectionService
- dll_service = DllDetectionService(self.log)
- default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- return ProfileData(
- current_profile=DEFAULT_PROFILE_NAME,
- profiles={DEFAULT_PROFILE_NAME: default_config},
- global_config={
- "dll": default_config.get("dll", ""),
- "no_fp16": False
- }
- )
-
- content = self.config_file_path.read_text(encoding='utf-8')
- return ConfigurationManager.parse_toml_content_multi_profile(content)
-
- def _save_profile_data(self, profile_data: ProfileData) -> None:
- """Save profile data to config file"""
- toml_content = ConfigurationManager.generate_toml_content_multi_profile(profile_data)
-
- self.config_dir.mkdir(parents=True, exist_ok=True)
-
- self._write_file(self.config_file_path, toml_content, 0o644)
-
- def get_profiles(self) -> ProfilesResponse:
- """Get list of all profiles and current profile
-
- Returns:
- ProfilesResponse with profile list and current profile
- """
+ def get_flatpak_config(self, app_id: str) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
-
- return self._success_response(ProfilesResponse,
- "Profiles retrieved successfully",
- profiles=list(profile_data["profiles"].keys()),
- current_profile=profile_data["current_profile"])
-
- except Exception as e:
- error_msg = f"Error getting profiles: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfilesResponse, str(e),
- profiles=None, current_profile=None)
-
- def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse:
- """Create a new profile
-
- Args:
- profile_name: Name for the new profile (spaces will be converted to dashes)
- source_profile: Optional source profile to copy from (default: current profile)
-
- Returns:
- ProfileResponse with success status and the normalized profile name
- """
+ data = self._get_profile_data()
+ name = self.flatpak_profile_name(app_id)
+ raw = data["profiles"].get(name)
+ return self._success_response(
+ dict,
+ app_id=str(app_id),
+ profile=name,
+ exists=raw is not None,
+ config=self._public_config(raw) if raw is not None else None,
+ global_config=dict(data["global_config"]),
+ )
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False)
+
+ def update_flatpak_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
-
- if not source_profile:
- source_profile = profile_data["current_profile"]
-
- # Get the normalized name that will be used for storage
- normalized_name = ConfigurationManager.normalize_profile_name(profile_name)
-
- new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile)
-
- self._save_profile_data(new_profile_data)
-
- self.log.info(f"Created profile '{normalized_name}' from '{source_profile}'")
-
- # Return the normalized name so frontend can use the actual stored name
- return self._success_response(ProfileResponse,
- f"Profile '{normalized_name}' created successfully",
- profile_name=normalized_name)
-
- except ValueError as e:
- error_msg = f"Invalid profile operation: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
- except Exception as e:
- error_msg = f"Error creating profile: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
-
- def delete_profile(self, profile_name: str) -> ProfileResponse:
- """Delete a profile
-
- Args:
- profile_name: Name of the profile to delete
-
- Returns:
- ProfileResponse with success status
- """
+ data = self._get_profile_data()
+ name = self.flatpak_profile_name(app_id)
+ merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}}
+ if not config.get("dll"):
+ merged_config["dll"] = data["global_config"].get("dll", "")
+ validated = self._public_config(merged_config)
+ validated["active_in"] = []
+ data["profiles"][name] = validated
+ self._save_profile_data(data)
+ return self._success_response(dict, app_id=str(app_id), profile=name, exists=True, config=validated)
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False)
+
+ def reset_flatpak_config(self, app_id: str) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
-
- new_profile_data = ConfigurationManager.delete_profile(profile_data, profile_name)
-
- self._save_profile_data(new_profile_data)
-
- script_result = self.update_lsfg_script_from_profile_data(new_profile_data)
- if not script_result["success"]:
- self.log.warning(f"Failed to update launch script: {script_result['error']}")
-
- self.log.info(f"Deleted profile '{profile_name}'")
-
- return self._success_response(ProfileResponse,
- f"Profile '{profile_name}' deleted successfully",
- profile_name=profile_name)
-
- except ValueError as e:
- error_msg = f"Invalid profile operation: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
- except Exception as e:
- error_msg = f"Error deleting profile: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
-
- def rename_profile(self, old_name: str, new_name: str) -> ProfileResponse:
- """Rename a profile
-
- Args:
- old_name: Current profile name
- new_name: New profile name (spaces will be converted to dashes)
-
- Returns:
- ProfileResponse with success status and the normalized profile name
- """
+ data = self._get_profile_data()
+ name = self.flatpak_profile_name(app_id)
+ data["profiles"].pop(name, None)
+ self._save_profile_data(data)
+ return self._success_response(dict, app_id=str(app_id), profile=name, exists=False)
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False)
+
+ def reset_all_flatpak_configs(self) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
-
- # Get the normalized name that will be used for storage
- normalized_name = ConfigurationManager.normalize_profile_name(new_name)
-
- new_profile_data = ConfigurationManager.rename_profile(profile_data, old_name, new_name)
-
- self._save_profile_data(new_profile_data)
-
- script_result = self.update_lsfg_script_from_profile_data(new_profile_data)
- if not script_result["success"]:
- self.log.warning(f"Failed to update launch script: {script_result['error']}")
-
- self.log.info(f"Renamed profile '{old_name}' to '{normalized_name}'")
-
- # Return the normalized name so frontend can use the actual stored name
- return self._success_response(ProfileResponse,
- f"Profile renamed from '{old_name}' to '{normalized_name}' successfully",
- profile_name=normalized_name)
-
- except ValueError as e:
- error_msg = f"Invalid profile operation: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
- except Exception as e:
- error_msg = f"Error renaming profile: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
-
- def set_current_profile(self, profile_name: str) -> ProfileResponse:
- """Set the current active profile
-
- Args:
- profile_name: Name of the profile to set as current
-
- Returns:
- ProfileResponse with success status
- """
+ data = self._get_profile_data()
+ data["profiles"] = {
+ name: profile
+ for name, profile in data["profiles"].items()
+ if not name.startswith(self.FLATPAK_PROFILE_PREFIX)
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]))
+ except Exception as error:
+ return self._error_response(dict, str(error))
+
+ def reset_game_config(self, appid: str) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
-
- new_profile_data = ConfigurationManager.set_current_profile(profile_data, profile_name)
-
- self._save_profile_data(new_profile_data)
-
- script_result = self.update_lsfg_script_from_profile_data(new_profile_data)
- if not script_result["success"]:
- self.log.warning(f"Failed to update launch script: {script_result['error']}")
-
- self.log.info(f"Set current profile to '{profile_name}'")
-
- return self._success_response(ProfileResponse,
- f"Current profile set to '{profile_name}' successfully",
- profile_name=profile_name)
-
- except ValueError as e:
- error_msg = f"Invalid profile operation: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
- except Exception as e:
- error_msg = f"Error setting current profile: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ProfileResponse, str(e), profile_name=None)
-
- def update_profile_config(self, profile_name: str, config: ConfigurationData) -> ConfigurationResponse:
- """Update configuration for a specific profile
-
- Args:
- profile_name: Name of the profile to update
- config: Configuration data to apply
-
- Returns:
- ConfigurationResponse with success status
- """
+ data = self._get_profile_data()
+ name, _ = self._profile_for_appid(data, appid)
+ if name:
+ data["profiles"].pop(name, None)
+ self._save_profile_data(data)
+ return self._success_response(dict, appid=str(appid), exists=False)
+ except Exception as error:
+ return self._error_response(dict, str(error), appid=str(appid), config=None)
+
+ def reset_game_configs(self, appids: list[str]) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
-
- if profile_name not in profile_data["profiles"]:
- return self._error_response(ConfigurationResponse,
- f"Profile '{profile_name}' does not exist",
- config=None)
-
- # Update the profile's config
- profile_data["profiles"][profile_name] = config
-
- # Update global config fields if they're in the config
- for field_name in ["dll", "no_fp16"]:
- if field_name in config:
- profile_data["global_config"][field_name] = config[field_name]
-
- self._save_profile_data(profile_data)
-
- if profile_name == profile_data["current_profile"]:
- script_result = self.update_lsfg_script_from_profile_data(profile_data)
- if not script_result["success"]:
- self.log.warning(f"Failed to update launch script: {script_result['error']}")
-
- field_values = ", ".join(f"{k}={repr(v)}" for k, v in config.items())
- self.log.info(f"Updated profile '{profile_name}' configuration: {field_values}")
-
- return self._success_response(ConfigurationResponse,
- f"Profile '{profile_name}' configuration updated successfully",
- config=config)
-
- except Exception as e:
- error_msg = f"Error updating profile configuration: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ConfigurationResponse, str(e), config=None)
-
- def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> ConfigurationResponse:
- """Update the ~/lsfg launch script from profile data
-
- Args:
- profile_data: Profile data to apply to the script
-
- Returns:
- ConfigurationResponse indicating success or failure
- """
+ if not isinstance(appids, list):
+ raise ValueError("appids must be a list")
+ requested = set()
+ for appid in appids:
+ if isinstance(appid, bool) or not isinstance(appid, (str, int)):
+ raise ValueError("appids must contain only strings or integers")
+ value = str(appid)
+ if not re.fullmatch(r"-?[0-9]+", value):
+ raise ValueError("appids must contain only numeric App IDs")
+ requested.add(value)
+
+ data = self._get_profile_data()
+ data["profiles"] = {
+ name: profile
+ for name, profile in data["profiles"].items()
+ if not (
+ len(profile.get("active_in", [])) == 1
+ and str(profile["active_in"][0]) in requested
+ )
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]), games=[])
+ except Exception as error:
+ return self._error_response(dict, str(error), games=[])
+
+ def reset_all_game_configs(self) -> Dict[str, Any]:
try:
- script_content = self._generate_script_content_for_profile(profile_data)
-
- # Write the script file
- self._write_file(self.lsfg_script_path, script_content, 0o755)
-
- self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path} for profile '{profile_data['current_profile']}'")
-
- # Get current profile config for response
- current_config = profile_data["profiles"].get(profile_data["current_profile"], ConfigurationManager.get_defaults())
-
- return self._success_response(ConfigurationResponse,
- "Launch script updated successfully",
- config=current_config)
-
- except Exception as e:
- error_msg = f"Error updating launch script: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(ConfigurationResponse, str(e), config=None)
+ data = self._get_profile_data()
+ data["profiles"] = {
+ name: profile
+ for name, profile in data["profiles"].items()
+ if not (
+ len(profile.get("active_in", [])) == 1
+ and re.fullmatch(r"-?[0-9]+", str(profile.get("active_in", [""])[0]))
+ )
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]), games=[])
+ except Exception as error:
+ return self._error_response(dict, str(error), games=[])
diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py
index 023894f..45ac4c5 100644
--- a/py_modules/lsfg_vk/constants.py
+++ b/py_modules/lsfg_vk/constants.py
@@ -1,37 +1,25 @@
-"""
-Constants for the lsfg-vk plugin.
-"""
-
-from pathlib import Path
-
+LOCAL_BIN = ".local/bin"
+LOCAL_SHARE = ".local/share"
LOCAL_LIB = ".local/lib"
-LOCAL_SHARE_BASE = ".local/share"
VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d"
CONFIG_DIR = ".config/lsfg-vk"
SCRIPT_NAME = "lsfg"
+WRAPPER_FILENAME = ".lsfg"
CONFIG_FILENAME = "conf.toml"
-LIB_FILENAME = "liblsfg-vk.so"
-JSON_FILENAME = "VkLayer_LS_frame_generation.json"
-ZIP_FILENAME = "lsfg-vk_noui.zip"
-ARM_LIB_FILENAME = "liblsfg-vk-arm64.so"
-
-FLATPAK_23_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak"
-FLATPAK_24_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak"
-FLATPAK_25_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak"
-
-SO_EXT = ".so"
-JSON_EXT = ".json"
+ARCHIVE_FILENAME = "lsfg-vk-2.0.0.tar.xz"
+LIB_FILENAME = "liblsfg-vk-layer.so"
+LIB_X86_FILENAME = "liblsfg-vk-layer.x86.so"
+JSON_FILENAME = "VkLayer_LSFGVK_frame_generation.json"
+JSON_X86_FILENAME = "VkLayer_LSFGVK_frame_generation.x86.json"
+CLI_FILENAME = "lsfg-vk-cli"
+UI_FILENAME = "lsfg-vk-ui"
+UI_DESKTOP_FILENAME = "gay.pancake.lsfg-vk-ui.desktop"
+UI_ICON_FILENAME = "gay.pancake.lsfg-vk-ui.png"
+STEAM_LOSSLESS_SCALING_APP_ID = "993090"
+STEAM_LOSSLESS_SCALING_BRANCH = "lsfg-vk"
+
+LEGACY_LIB_FILENAME = "liblsfg-vk.so"
+LEGACY_JSON_FILENAME = "VkLayer_LS_frame_generation.json"
BIN_DIR = "bin"
-
-ARMADA_DEVICE_ENV = Path("/usr/libexec/armada/device-env")
-ARMADA_GAME_LAUNCH = Path("/usr/libexec/armada/armada-game-launch")
-
-STEAM_COMMON_PATH = Path("steamapps/common/Lossless Scaling")
-LOSSLESS_DLL_NAME = "Lossless.dll"
-
-ENV_LSFG_DLL_PATH = "LSFG_DLL_PATH"
-ENV_XDG_DATA_HOME = "XDG_DATA_HOME"
-ENV_HOME = "HOME"
-
diff --git a/py_modules/lsfg_vk/dll_detection.py b/py_modules/lsfg_vk/dll_detection.py
deleted file mode 100644
index f7ba444..0000000
--- a/py_modules/lsfg_vk/dll_detection.py
+++ /dev/null
@@ -1,223 +0,0 @@
-"""
-DLL detection service for Lossless Scaling.
-"""
-
-import os
-import re
-from pathlib import Path
-from typing import Dict, Any, List
-
-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
-
- Search order:
- 1. LSFG_DLL_PATH environment variable
- 2. XDG_DATA_HOME Steam directory
- 3. HOME/.local/share Steam directory
- 4. All Steam library folders (including SD cards)
-
- Returns:
- DllDetectionResponse with detection status and path information
- """
- try:
- dll_path = self._check_env_dll_path()
- if dll_path:
- return dll_path
-
- xdg_path = self._check_xdg_data_home()
- if xdg_path:
- return xdg_path
-
- home_path = self._check_home_local_share()
- if home_path:
- return home_path
-
- steam_libraries_path = self._check_steam_library_folders()
- if steam_libraries_path:
- return steam_libraries_path
-
- 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
-
- def _check_steam_library_folders(self) -> DllDetectionResponse | None:
- """Check all Steam library folders for Lossless Scaling DLL
-
- This method parses Steam's libraryfolders.vdf file to find all
- Steam library locations and checks each one for the DLL.
-
- Returns:
- DllDetectionResponse if found, None otherwise
- """
- steam_libraries = self._get_steam_library_paths()
-
- for library_path in steam_libraries:
- dll_path = Path(library_path) / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME
- if dll_path.exists():
- self.log.info(f"Found DLL in Steam library: {dll_path}")
- return {
- "detected": True,
- "path": str(dll_path),
- "source": f"Steam library folder: {library_path}",
- "message": None,
- "error": None
- }
-
- return None
-
- def _get_steam_library_paths(self) -> List[str]:
- """Get all Steam library folder paths from libraryfolders.vdf
-
- Returns:
- List of Steam library folder paths
- """
- library_paths = []
-
- steam_paths = []
-
- data_dir = os.getenv(ENV_XDG_DATA_HOME)
- if data_dir and data_dir.strip():
- steam_paths.append(Path(data_dir.strip()) / "Steam")
-
- home_dir = os.getenv(ENV_HOME)
- if home_dir and home_dir.strip():
- steam_paths.append(Path(home_dir.strip()) / ".local" / "share" / "Steam")
-
- for steam_path in steam_paths:
- if steam_path.exists():
- library_paths.append(str(steam_path))
-
- vdf_path = steam_path / "steamapps" / "libraryfolders.vdf"
- if vdf_path.exists():
- try:
- additional_paths = self._parse_library_folders_vdf(vdf_path)
- library_paths.extend(additional_paths)
- except Exception as e:
- self.log.warning(f"Failed to parse {vdf_path}: {str(e)}")
-
- seen = set()
- unique_paths = []
- for path in library_paths:
- if path not in seen:
- seen.add(path)
- unique_paths.append(path)
-
- self.log.info(f"Found {len(unique_paths)} Steam library paths: {unique_paths}")
- return unique_paths
-
- def _parse_library_folders_vdf(self, vdf_path: Path) -> List[str]:
- """Parse Steam's libraryfolders.vdf file to extract library paths
-
- Args:
- vdf_path: Path to the libraryfolders.vdf file
-
- Returns:
- List of additional Steam library folder paths
- """
- library_paths = []
-
- try:
- with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
- content = f.read()
-
- path_pattern = r'"path"\s*"([^"]+)"'
- matches = re.findall(path_pattern, content, re.IGNORECASE)
-
- for path_match in matches:
- path = path_match.replace('\\\\', '/').replace('\\', '/')
- library_path = Path(path)
-
- if library_path.exists() and (library_path / "steamapps").exists():
- library_paths.append(str(library_path))
- self.log.info(f"Found additional Steam library: {library_path}")
-
- except Exception as e:
- self.log.error(f"Error parsing libraryfolders.vdf: {str(e)}")
-
- return library_paths
diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py
new file mode 100644
index 0000000..9d2d104
--- /dev/null
+++ b/py_modules/lsfg_vk/flatpak_profile_service.py
@@ -0,0 +1,383 @@
+from __future__ import annotations
+
+import re
+from typing import Any, Dict
+
+from .configuration import ConfigurationService
+from .flatpak_service import FlatpakService
+
+
+class FlatpakProfileService:
+ STATE_FIELDS = (
+ "dxvkFrameRate",
+ "disableGamescopeWsi",
+ "disableHdr",
+ "disableSteamdeckMode",
+ "disableVkbasalt",
+ "enableZink",
+ )
+ BOOLEAN_FIELDS = STATE_FIELDS[1:]
+ DXVK_FRAME_RATE_SEGMENT = re.compile(
+ r"^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=",
+ re.IGNORECASE,
+ )
+
+ def __init__(
+ self,
+ flatpak_service: FlatpakService,
+ configuration_service: ConfigurationService,
+ ):
+ self.flatpak_service = flatpak_service
+ self.configuration_service = configuration_service
+
+ @classmethod
+ def default_state(cls) -> Dict[str, Any]:
+ return {
+ "dxvkFrameRate": 0,
+ "disableGamescopeWsi": True,
+ "disableHdr": True,
+ "disableSteamdeckMode": False,
+ "disableVkbasalt": False,
+ "enableZink": False,
+ }
+
+ @classmethod
+ def _validate_state(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise ValueError("Workaround state must be an object")
+ missing = [field for field in cls.STATE_FIELDS if field not in raw]
+ if missing:
+ raise ValueError("Workaround state is missing: " + ", ".join(missing))
+ state = {field: raw[field] for field in cls.STATE_FIELDS}
+ frame_rate = state["dxvkFrameRate"]
+ if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60:
+ raise ValueError("Base FPS Cap must be an integer from 0 to 60")
+ for field in cls.BOOLEAN_FIELDS:
+ if type(state[field]) is not bool:
+ raise ValueError(f"{field} must be a boolean")
+ return state
+
+ def _state_entry(self, app_id: str) -> tuple[Dict[str, object], Dict[str, Any]]:
+ state = self.flatpak_service._read_state()
+ entry = state["prepared_apps"].get(app_id)
+ if not isinstance(entry, dict):
+ raise RuntimeError("Flatpak application is not owned by this plugin")
+ return state, entry
+
+ def _baseline_content(self, app_id: str, entry: Dict[str, Any]) -> str:
+ if not entry.get("override_existed"):
+ return ""
+ path = self.flatpak_service._backup_path(app_id)
+ if path.is_symlink() or not path.is_file():
+ raise RuntimeError("Flatpak override backup is unavailable")
+ return path.read_text(encoding="utf-8")
+
+ @staticmethod
+ def _environment_value(content: str, key: str) -> str:
+ section = None
+ for raw_line in content.splitlines():
+ line = raw_line.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1]
+ continue
+ if section != "Environment":
+ continue
+ name, separator, value = line.partition("=")
+ if separator and name == key:
+ return value
+ return ""
+
+ @classmethod
+ def _dxvk_config(cls, baseline: str, frame_rate: int) -> str:
+ existing = cls._environment_value(baseline, "DXVK_CONFIG")
+ parts = [part.strip() for part in existing.split(";") if part.strip()]
+ parts = [part for part in parts if not cls.DXVK_FRAME_RATE_SEGMENT.match(part)]
+ if frame_rate > 0:
+ parts.append(f"dxvk.maxFrameRate = {frame_rate}")
+ return "; ".join(parts)
+
+ def _restore_baseline(self, app_id: str, entry: Dict[str, Any]) -> None:
+ existed, current = self.flatpak_service._snapshot_override(app_id)
+ current_hash = self.flatpak_service._sha256(current) if existed else self.flatpak_service._sha256(b"")
+ if current_hash != entry.get("managed_sha256"):
+ raise RuntimeError("Flatpak override changed after preparation; refusing to overwrite unrelated settings")
+ path = self.flatpak_service._override_path(app_id)
+ if entry.get("override_existed"):
+ self.flatpak_service._write_file(path, self._baseline_content(app_id, entry))
+ else:
+ path.unlink(missing_ok=True)
+
+ def _apply_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]:
+ workaround_state = self._validate_state(workaround_state)
+ _, entry = self._state_entry(app_id)
+ baseline = self._baseline_content(app_id, entry)
+ self._restore_baseline(app_id, entry)
+ prepared = self.flatpak_service.prepare_app(app_id)
+ if not prepared.get("success") or not prepared.get("owned"):
+ raise RuntimeError(prepared.get("error") or "Could not restore plugin-owned Flatpak preparation")
+ profile = self.configuration_service.flatpak_profile_name(app_id)
+ args = [
+ "override",
+ "--user",
+ f"--env=LSFGVK_PROFILE={profile}",
+ "--unset-env=DISABLE_LSFGVK",
+ "--unset-env=DISABLE_LSFG",
+ ]
+ if workaround_state["disableGamescopeWsi"]:
+ args.extend(["--env=ENABLE_GAMESCOPE_WSI=0", "--unset-env=DISABLE_GAMESCOPE_WSI"])
+ if workaround_state["disableHdr"]:
+ args.append("--env=DXVK_HDR=0")
+ if workaround_state["disableSteamdeckMode"]:
+ args.append("--env=SteamDeck=0")
+ if workaround_state["disableVkbasalt"]:
+ args.extend(["--env=DISABLE_VKBASALT=1", "--unset-env=ENABLE_VKBASALT"])
+ if workaround_state["enableZink"]:
+ args.extend([
+ "--env=__GLX_VENDOR_LIBRARY_NAME=mesa",
+ "--env=MESA_LOADER_DRIVER_OVERRIDE=zink",
+ "--env=GALLIUM_DRIVER=zink",
+ ])
+ dxvk_config = self._dxvk_config(baseline, workaround_state["dxvkFrameRate"])
+ if dxvk_config:
+ args.append(f"--env=DXVK_CONFIG={dxvk_config}")
+ args.append(app_id)
+ result = self.flatpak_service._run_flatpak_command(args, capture_output=True, text=True)
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or f"Could not apply Flatpak workarounds for {app_id}")
+ existed, managed = self.flatpak_service._snapshot_override(app_id)
+ if not existed:
+ raise RuntimeError(f"Flatpak override for {app_id} was not created")
+ state = self.flatpak_service._read_state()
+ entry = state["prepared_apps"].get(app_id)
+ if not isinstance(entry, dict):
+ raise RuntimeError("Flatpak application ownership state disappeared")
+ entry["managed_sha256"] = self.flatpak_service._sha256(managed)
+ entry["workaround_state"] = workaround_state
+ self.flatpak_service._write_state(state)
+ return workaround_state
+
+ def enable_app(self, app_id: str) -> Dict[str, Any]:
+ created_profile = False
+ newly_owned = False
+ try:
+ existing = self.configuration_service.get_flatpak_config(app_id)
+ before = self.flatpak_service._read_state()
+ was_owned = app_id in before["prepared_apps"]
+ prepared = self.flatpak_service.prepare_app(app_id)
+ if not prepared.get("success"):
+ raise RuntimeError(prepared.get("error") or "Could not prepare Flatpak application")
+ if not prepared.get("owned"):
+ raise RuntimeError("Flatpak application is prepared outside this plugin and cannot be managed safely")
+ newly_owned = not was_owned
+ if not existing.get("exists"):
+ config = {
+ **self.configuration_service._public_config({}),
+ **(existing.get("global_config") or {}),
+ }
+ config["active_in"] = []
+ saved = self.configuration_service.update_flatpak_config(app_id, config)
+ if not saved.get("success"):
+ raise RuntimeError(saved.get("error") or "Could not create Flatpak profile")
+ created_profile = True
+ _, entry = self._state_entry(app_id)
+ workaround_state = self._validate_state(entry.get("workaround_state", self.default_state()))
+ self._apply_state(app_id, workaround_state)
+ return self.get_app(app_id)
+ except Exception as error:
+ if created_profile:
+ self.configuration_service.reset_flatpak_config(app_id)
+ if newly_owned:
+ self.flatpak_service.remove_app_override(app_id)
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+
+ def update_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ self._state_entry(app_id)
+ result = self.configuration_service.update_flatpak_config(app_id, config)
+ if not result.get("success"):
+ raise RuntimeError(result.get("error") or "Could not update Flatpak profile")
+ return result
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+
+ def get_workaround_state(self, app_id: str) -> Dict[str, Any]:
+ try:
+ _, entry = self._state_entry(app_id)
+ state = self._validate_state(entry.get("workaround_state", self.default_state()))
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "app_id": app_id,
+ "state": state,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "state": None,
+ }
+
+ def set_workaround_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ state = self._apply_state(app_id, workaround_state)
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "app_id": app_id,
+ "state": state,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "state": None,
+ }
+
+ def remove_app(self, app_id: str) -> Dict[str, Any]:
+ try:
+ removed = self.flatpak_service.remove_app_override(app_id)
+ if not removed.get("success"):
+ raise RuntimeError(removed.get("error") or "Could not remove Flatpak preparation")
+ reset = self.configuration_service.reset_flatpak_config(app_id)
+ if not reset.get("success"):
+ raise RuntimeError(reset.get("error") or "Could not remove Flatpak profile")
+ return {
+ "success": True,
+ "message": "Flatpak profile removed",
+ "error": None,
+ "app_id": app_id,
+ "enabled": False,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "app_id": str(app_id),
+ "enabled": True,
+ }
+
+ def get_app(self, app_id: str) -> Dict[str, Any]:
+ apps = self.get_apps()
+ if not apps.get("success"):
+ return {
+ "success": False,
+ "message": "",
+ "error": apps.get("error"),
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+ app = next((item for item in apps.get("apps", []) if item.get("app_id") == app_id), None)
+ if app is None:
+ return {
+ "success": False,
+ "message": "",
+ "error": "Flatpak application is not installed",
+ "app_id": str(app_id),
+ "enabled": False,
+ }
+ return {"success": True, "message": "", "error": None, **app}
+
+ def get_apps(self) -> Dict[str, Any]:
+ try:
+ result = self.flatpak_service.get_flatpak_apps()
+ if not result.get("success"):
+ raise RuntimeError(result.get("error") or "Could not list Flatpak applications")
+ ownership = self.flatpak_service._read_state()
+ apps = []
+ for item in result.get("apps", []):
+ app_id = item["app_id"]
+ config_result = self.configuration_service.get_flatpak_config(app_id)
+ entry = ownership["prepared_apps"].get(app_id)
+ workarounds = self.default_state()
+ if isinstance(entry, dict):
+ workarounds = self._validate_state(entry.get("workaround_state", workarounds))
+ profile = self.configuration_service.flatpak_profile_name(app_id)
+ selector_ready = False
+ if item.get("prepared"):
+ shown = self.flatpak_service._run_flatpak_command(
+ ["override", "--user", "--show", app_id],
+ capture_output=True,
+ text=True,
+ )
+ selector_ready = shown.returncode == 0 and f"LSFGVK_PROFILE={profile}" in shown.stdout.splitlines()
+ apps.append({
+ **item,
+ "profile": profile,
+ "enabled": bool(item.get("owned") and config_result.get("exists") and selector_ready),
+ "config": config_result.get("config"),
+ "workarounds": workarounds,
+ })
+ return {
+ "success": True,
+ "message": result.get("message", ""),
+ "error": None,
+ "apps": apps,
+ }
+ except Exception as error:
+ return {"success": False, "message": "", "error": str(error), "apps": []}
+
+ def get_running_apps(self) -> Dict[str, Any]:
+ try:
+ state = self.flatpak_service._read_state()
+ enabled = set()
+ for app_id, entry in state["prepared_apps"].items():
+ if not isinstance(entry, dict):
+ continue
+ config = self.configuration_service.get_flatpak_config(app_id)
+ if not config.get("exists"):
+ continue
+ existed, content = self.flatpak_service._snapshot_override(app_id)
+ if not existed or self.flatpak_service._sha256(content) != entry.get("managed_sha256"):
+ continue
+ profile = self.configuration_service.flatpak_profile_name(app_id)
+ try:
+ text = content.decode("utf-8")
+ except UnicodeDecodeError:
+ continue
+ if self._environment_value(text, "LSFGVK_PROFILE") == profile:
+ enabled.add(app_id)
+ if not enabled:
+ return {"success": True, "message": "", "error": None, "apps": []}
+ result = self.flatpak_service._run_flatpak_command(
+ ["ps", "--columns=application,active,pid"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Could not inspect running Flatpak applications")
+ running = []
+ for line in result.stdout.splitlines():
+ fields = line.split("\t") if "\t" in line else line.split()
+ if not fields or fields[0] not in enabled:
+ continue
+ active = len(fields) > 1 and fields[1].strip().lower() in {"1", "true", "yes", "active"}
+ running.append({
+ "app_id": fields[0],
+ "active": active,
+ "pid": fields[2].strip() if len(fields) > 2 else "",
+ "start_time": self.flatpak_service._process_start_time(
+ fields[2].strip() if len(fields) > 2 else ""
+ ),
+ })
+ return {"success": True, "message": "", "error": None, "apps": running}
+ except Exception as error:
+ return {"success": False, "message": "", "error": str(error), "apps": []}
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py
index c9be0ec..f2ed90c 100644
--- a/py_modules/lsfg_vk/flatpak_service.py
+++ b/py_modules/lsfg_vk/flatpak_service.py
@@ -1,459 +1,662 @@
-"""
-Flatpak service for managing lsfg-vk Flatpak runtime extensions.
-"""
+from __future__ import annotations
-import subprocess
+import hashlib
+import json
import os
+import pwd
+import re
+import shutil
+import subprocess
+import threading
from pathlib import Path
-from typing import Dict, Any, List, Optional
+from typing import Dict, Optional, Set
from .base_service import BaseService
-from .constants import (
- FLATPAK_23_08_FILENAME, FLATPAK_24_08_FILENAME, FLATPAK_25_08_FILENAME, BIN_DIR, CONFIG_DIR
-)
-from .types import BaseResponse
-
-
-class FlatpakExtensionStatus(BaseResponse):
- """Response for Flatpak extension status"""
- def __init__(self, success: bool = False, message: str = "", error: str = "",
- installed_23_08: bool = False, installed_24_08: bool = False, installed_25_08: bool = False):
- super().__init__(success, message, error)
- self.installed_23_08 = installed_23_08
- self.installed_24_08 = installed_24_08
- self.installed_25_08 = installed_25_08
-
-
-class FlatpakAppInfo(BaseResponse):
- """Response for Flatpak app information"""
- def __init__(self, success: bool = False, message: str = "", error: str = "",
- apps: List[Dict[str, Any]] = None, total_apps: int = 0):
- super().__init__(success, message, error)
- self.apps = apps or []
- self.total_apps = total_apps
-
-
-class FlatpakOverrideResponse(BaseResponse):
- """Response for Flatpak override operations"""
- def __init__(self, success: bool = False, message: str = "", error: str = "",
- app_id: str = "", operation: str = ""):
- super().__init__(success, message, error)
- self.app_id = app_id
- self.operation = operation
class FlatpakService(BaseService):
- """Service for handling Flatpak runtime extensions and app overrides"""
+ EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk"
+ FLATHUB_REMOTE = "flathub"
+ SUPPORTED_RUNTIMES = ("24.08", "25.08")
+ DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"}
+ RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL"
+ OWNERSHIP_FILENAME = "flatpak_state.json"
+ OWNERSHIP_VERSION = 2
+ APP_ID_PATTERN = re.compile(
+ r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$"
+ )
def __init__(self, logger=None):
super().__init__(logger)
- self.extension_id_23_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/23.08"
- self.extension_id_24_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08"
- self.extension_id_25_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/25.08"
- self.flatpak_command = None
-
- def _get_clean_env(self):
- """Get a clean environment without PyInstaller's bundled libraries"""
- env = os.environ.copy()
-
- if 'LD_LIBRARY_PATH' in env:
- del env['LD_LIBRARY_PATH']
-
- standard_paths = ['/usr/bin', '/usr/local/bin', '/bin']
- current_path = env.get('PATH', '')
+ self.flatpak_command: Optional[str] = None
+ self._verified_branches: Set[str] = set()
+ self._lock = threading.RLock()
- path_parts = current_path.split(':') if current_path else []
- for std_path in standard_paths:
- if std_path not in path_parts:
- path_parts.insert(0, std_path)
+ @property
+ def ownership_path(self) -> Path:
+ return self.config_dir / self.OWNERSHIP_FILENAME
- env['PATH'] = ':'.join(path_parts)
+ @property
+ def backup_dir(self) -> Path:
+ return self.config_dir / "flatpak-overrides"
+ def _clean_env(self) -> Dict[str, str]:
+ env = os.environ.copy()
+ env.pop("LD_LIBRARY_PATH", None)
+ env["HOME"] = str(self.user_home)
+ try:
+ user_id = self.user_home.stat().st_uid
+ except OSError:
+ user_id = None
+ if user_id is not None:
+ env["XDG_RUNTIME_DIR"] = f"/run/user/{user_id}"
+ env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{user_id}/bus"
+ path = [entry for entry in env.get("PATH", "").split(":") if entry]
+ for entry in ("/usr/bin", "/usr/local/bin", "/bin"):
+ if entry not in path:
+ path.insert(0, entry)
+ env["PATH"] = ":".join(path)
return env
- def _run_flatpak_command(self, args: List[str], **kwargs):
- """Run flatpak command with clean environment to avoid library conflicts"""
- if self.flatpak_command is None:
- raise FileNotFoundError("Flatpak command not available")
-
- env = self._get_clean_env()
-
- self.log.info(f"Running flatpak with PATH: {env.get('PATH')}")
- self.log.info(f"LD_LIBRARY_PATH removed: {'LD_LIBRARY_PATH' not in env}")
-
- return subprocess.run([self.flatpak_command] + args, env=env, **kwargs)
-
def check_flatpak_available(self) -> bool:
- """Check if flatpak command is available and store the working command"""
- self.log.info(f"PATH: {os.environ.get('PATH', 'Not set')}")
- self.log.info(f"HOME: {os.environ.get('HOME', 'Not set')}")
- self.log.info(f"USER: {os.environ.get('USER', 'Not set')}")
-
- flatpak_paths = [
- "flatpak",
- "/usr/bin/flatpak",
- "/var/lib/flatpak/exports/bin/flatpak",
- "/home/deck/.local/bin/flatpak"
- ]
-
- for flatpak_path in flatpak_paths:
- try:
- result = subprocess.run([flatpak_path, "--version"],
- capture_output=True, check=True, text=True,
- env=self._get_clean_env())
- self.log.info(f"Flatpak found at {flatpak_path}: {result.stdout.strip()}")
- self.flatpak_command = flatpak_path
- return True
- except (subprocess.CalledProcessError, FileNotFoundError):
- self.log.debug(f"Flatpak not found at {flatpak_path}")
- continue
+ env = self._clean_env()
+ self.flatpak_command = shutil.which("flatpak", path=env["PATH"])
+ return self.flatpak_command is not None
- self.log.error("Flatpak command not found in any known locations")
- self.flatpak_command = None
- return False
-
- def get_extension_status(self) -> FlatpakExtensionStatus:
- """Check if lsfg-vk Flatpak extensions are installed"""
+ def _run_flatpak_command(self, args, **kwargs):
+ if self.flatpak_command is None and not self.check_flatpak_available():
+ raise FileNotFoundError("Flatpak command not available")
+ env = self._clean_env()
+ command = [self.flatpak_command, *args]
try:
- if not self.check_flatpak_available():
- error_msg = "Flatpak is not available on this system"
- if self.flatpak_command is None:
- error_msg += ". Command not found in PATH or common install locations."
- self.log.error(error_msg)
- return self._error_response(FlatpakExtensionStatus,
- error_msg,
- installed_23_08=False, installed_24_08=False, installed_25_08=False)
-
+ user = pwd.getpwuid(self.user_home.stat().st_uid)
+ except (KeyError, OSError) as error:
+ raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error
+ if os.geteuid() != user.pw_uid:
+ runuser = shutil.which("runuser", path=env["PATH"])
+ if runuser is None:
+ raise FileNotFoundError("runuser command not available")
+ command = [runuser, "--user", user.pw_name, "--", *command]
+ return subprocess.run(command, env=env, **kwargs)
+
+ @classmethod
+ def _validate_app_id(cls, app_id: str) -> str:
+ if not isinstance(app_id, str) or not cls.APP_ID_PATTERN.fullmatch(app_id):
+ raise ValueError("Invalid Flatpak application ID")
+ return app_id
+
+ @classmethod
+ def _validate_runtime(cls, branch: str) -> str:
+ if branch not in cls.SUPPORTED_RUNTIMES:
+ raise ValueError(
+ f"Unsupported Flatpak runtime branch {branch}; supported branches are "
+ + ", ".join(cls.SUPPORTED_RUNTIMES)
+ )
+ return branch
+
+ @classmethod
+ def runtime_branch_from_metadata(cls, metadata: str) -> str:
+ section = None
+ versions = []
+ for raw_line in metadata.splitlines() if isinstance(metadata, str) else []:
+ line = raw_line.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1].strip()
+ continue
+ if section != cls.RUNTIME_METADATA_SECTION:
+ continue
+ key, separator, value = line.partition("=")
+ if separator and key.strip() == "versions":
+ versions.extend(part.strip() for part in value.split(";"))
+ for value in versions:
+ for branch in cls.SUPPORTED_RUNTIMES:
+ if value == branch or value.startswith(f"{branch}-"):
+ return branch
+ raise ValueError("Could not determine a supported Freedesktop base runtime from Flatpak metadata")
+
+ @classmethod
+ def _extension_ref(cls, branch: str) -> str:
+ return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}"
+
+ def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]:
+ scopes = ("user", "system") if scope is None else (scope,)
+ installed = set()
+ for item in scopes:
result = self._run_flatpak_command(
- ["list", "--runtime"],
- capture_output=True, text=True, check=True
+ ["list", f"--{item}", "--runtime", "--columns=application,arch,branch"],
+ capture_output=True,
+ text=True,
+ check=True,
)
-
- installed_runtimes = result.stdout
-
- base_extension_name = "org.freedesktop.Platform.VulkanLayer.lsfgvk"
- installed_23_08 = False
- installed_24_08 = False
- installed_25_08 = False
-
- for line in installed_runtimes.split('\n'):
- if base_extension_name in line:
- if "23.08" in line:
- installed_23_08 = True
- elif "24.08" in line:
- installed_24_08 = True
- elif "25.08" in line:
- installed_25_08 = True
-
- status_msg = []
- if installed_23_08:
- status_msg.append("23.08 runtime extension installed")
- if installed_24_08:
- status_msg.append("24.08 runtime extension installed")
- if installed_25_08:
- status_msg.append("25.08 runtime extension installed")
-
- if not status_msg:
- status_msg.append("No lsfg-vk runtime extensions installed")
-
- return self._success_response(FlatpakExtensionStatus,
- "; ".join(status_msg),
- installed_23_08=installed_23_08,
- installed_24_08=installed_24_08,
- installed_25_08=installed_25_08)
-
- except subprocess.CalledProcessError as e:
- error_msg = f"Error checking Flatpak extensions: {e.stderr if e.stderr else str(e)}"
- self.log.error(error_msg)
- return self._error_response(FlatpakExtensionStatus, error_msg,
- installed_23_08=False, installed_24_08=False, installed_25_08=False)
-
- def install_extension(self, version: str) -> BaseResponse:
- """Install a specific version of the lsfg-vk Flatpak extension"""
+ for line in result.stdout.splitlines():
+ fields = line.split("\t") if "\t" in line else line.split()
+ if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64":
+ installed.add(fields[2])
+ return installed
+
+ def _user_extension_origin(self, branch: str) -> str:
+ result = self._run_flatpak_command(
+ ["info", "--user", "--show-origin", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ return result.stdout.strip() if result.returncode == 0 else ""
+
+ def _empty_state(self) -> Dict[str, object]:
+ return {
+ "version": self.OWNERSHIP_VERSION,
+ "plugin_owned_branches": [],
+ "prepared_apps": {},
+ }
+
+ def _read_state(self) -> Dict[str, object]:
+ if not self.ownership_path.exists():
+ return self._empty_state()
+ if self.ownership_path.is_symlink() or not self.ownership_path.is_file():
+ raise RuntimeError("Flatpak ownership metadata is not a regular file")
try:
- if version not in ["23.08", "24.08", "25.08"]:
- return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'")
-
- if not self.check_flatpak_available():
- return self._error_response(BaseResponse, "Flatpak is not available on this system")
-
- plugin_dir = Path(__file__).parent.parent.parent
- if version == "23.08":
- filename = FLATPAK_23_08_FILENAME
- elif version == "24.08":
- filename = FLATPAK_24_08_FILENAME
- else:
- filename = FLATPAK_25_08_FILENAME
- flatpak_path = plugin_dir / BIN_DIR / filename
-
- if not flatpak_path.exists():
- return self._error_response(BaseResponse, f"Flatpak file not found: {flatpak_path}")
-
- result = self._run_flatpak_command(
- ["install", "--user", "--noninteractive", str(flatpak_path)],
- capture_output=True, text=True
+ data = json.loads(self.ownership_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise RuntimeError(f"Could not read Flatpak ownership metadata: {error}") from error
+ if data.get("version") != self.OWNERSHIP_VERSION:
+ raise RuntimeError("Unsupported Flatpak ownership metadata version")
+ branches = data.get("plugin_owned_branches")
+ apps = data.get("prepared_apps")
+ if not isinstance(branches, list) or not isinstance(apps, dict):
+ raise RuntimeError("Invalid Flatpak ownership metadata")
+ for branch in branches:
+ self._validate_runtime(branch)
+ for app_id, entry in apps.items():
+ self._validate_app_id(app_id)
+ if not isinstance(entry, dict):
+ raise RuntimeError("Invalid Flatpak app ownership metadata")
+ if type(entry.get("override_existed")) is not bool:
+ raise RuntimeError("Invalid Flatpak app ownership metadata")
+ if not isinstance(entry.get("managed_sha256"), str):
+ raise RuntimeError("Invalid Flatpak app ownership metadata")
+ return data
+
+ def _write_state(self, state: Dict[str, object]) -> None:
+ branches = state.get("plugin_owned_branches", [])
+ apps = state.get("prepared_apps", {})
+ if not branches and not apps:
+ self.ownership_path.unlink(missing_ok=True)
+ if self.backup_dir.exists() and not any(self.backup_dir.iterdir()):
+ self.backup_dir.rmdir()
+ return
+ self._write_file(
+ self.ownership_path,
+ json.dumps(state, indent=2, sort_keys=True) + "\n",
+ )
+
+ def _owned_branches(self, state: Optional[Dict[str, object]] = None) -> Set[str]:
+ current = state if state is not None else self._read_state()
+ return {self._validate_runtime(branch) for branch in current["plugin_owned_branches"]}
+
+ def _override_path(self, app_id: str) -> Path:
+ return self.user_home / ".local/share/flatpak/overrides" / self._validate_app_id(app_id)
+
+ def _backup_path(self, app_id: str) -> Path:
+ return self.backup_dir / f"{self._validate_app_id(app_id)}.ini"
+
+ @staticmethod
+ def _sha256(content: bytes) -> str:
+ return hashlib.sha256(content).hexdigest()
+
+ @staticmethod
+ def _parse_process_start_time(stat_content: str) -> Optional[int]:
+ closing_command = stat_content.rfind(")")
+ if closing_command < 0:
+ return None
+ fields = stat_content[closing_command + 2:].split()
+ if len(fields) <= 19:
+ return None
+ try:
+ return int(fields[19])
+ except (TypeError, ValueError):
+ return None
+
+ @classmethod
+ def _process_start_time(cls, pid: str) -> Optional[int]:
+ if not isinstance(pid, str) or re.fullmatch(r"[0-9]+", pid) is None:
+ return None
+ try:
+ stat_content = (Path("/proc") / pid / "stat").read_text(encoding="utf-8")
+ except OSError:
+ return None
+ return cls._parse_process_start_time(stat_content)
+
+ def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]:
+ path = self._override_path(app_id)
+ if path.is_symlink():
+ raise RuntimeError("Flatpak override path is a symlink")
+ if not path.exists():
+ return False, b""
+ if not path.is_file():
+ raise RuntimeError("Flatpak override path is not a regular file")
+ return True, path.read_bytes()
+
+ def _resolve_runtime(self, app_id: str) -> tuple[str, str]:
+ self._validate_app_id(app_id)
+ if not self.check_flatpak_available():
+ raise FileNotFoundError("Flatpak is not available on this system")
+ result = self._run_flatpak_command(
+ ["info", "--show-runtime", app_id],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or f"Could not inspect Flatpak app {app_id}")
+ runtime = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
+ parts = runtime.split("/")
+ if len(parts) != 3:
+ raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}")
+ if parts[0] == "org.freedesktop.Platform":
+ return runtime, self._validate_runtime(parts[2])
+ if parts[0] not in self.DERIVED_RUNTIME_IDS:
+ raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}")
+ metadata_result = self._run_flatpak_command(
+ ["info", "--show-metadata", runtime],
+ capture_output=True,
+ text=True,
+ )
+ if metadata_result.returncode != 0:
+ raise OSError(metadata_result.stderr.strip() or f"Could not inspect Flatpak runtime {runtime}")
+ return runtime, self.runtime_branch_from_metadata(metadata_result.stdout)
+
+ def _dll_directory(self) -> Path:
+ if self.config_file_path.exists():
+ try:
+ content = self.config_file_path.read_text(encoding="utf-8")
+ match = re.search(
+ r'(?m)^[ \t]*dll[ \t]*=[ \t]*"((?:\\.|[^"\\])*)"',
+ content,
+ )
+ if match:
+ configured_dll = json.loads('"' + match.group(1) + '"')
+ if configured_dll:
+ return Path(configured_dll).parent
+ except Exception:
+ pass
+ return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling"
+
+ def _filesystem_present(self, entries: str, host_path: Path) -> bool:
+ accepted = {str(host_path)}
+ try:
+ accepted.add(f"~/{host_path.relative_to(self.user_home).as_posix()}")
+ except ValueError:
+ pass
+ enabled = False
+ for raw in entries.split(";"):
+ value = raw.strip()
+ if not value:
+ continue
+ denied = value.startswith("!")
+ path = value[1:] if denied else value
+ path = path.split(":", 1)[0]
+ if path in accepted:
+ if denied:
+ return False
+ enabled = True
+ return enabled
+
+ def _app_override_status(self, app_id: str) -> Dict[str, object]:
+ result = self._run_flatpak_command(
+ ["override", "--user", "--show", app_id],
+ capture_output=True,
+ text=True,
+ )
+ output = result.stdout if result.returncode == 0 else ""
+ section = None
+ filesystems = ""
+ unset_environment = set()
+ environment = {}
+ for raw_line in output.splitlines():
+ line = raw_line.strip()
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1]
+ continue
+ key, separator, value = line.partition("=")
+ if not separator:
+ continue
+ if section == "Context" and key == "filesystems":
+ filesystems = value
+ elif section == "Context" and key == "unset-environment":
+ unset_environment.update(item for item in value.split(";") if item)
+ elif section == "Environment":
+ environment[key] = value
+ config_ready = self._filesystem_present(filesystems, self.config_dir)
+ dll_ready = self._filesystem_present(filesystems, self._dll_directory())
+ env_ready = (
+ environment.get("LSFGVK_CONFIG") == str(self.config_file_path)
+ and environment.get("LSFGVK_FLATPAK") == "1"
+ and "DISABLE_LSFGVK" in unset_environment
+ and "DISABLE_LSFG" in unset_environment
+ )
+ return {
+ "filesystem_ready": config_ready and dll_ready,
+ "environment_ready": env_ready,
+ "prepared": config_ready and dll_ready and env_ready,
+ }
+
+ def get_extension_status(self):
+ try:
+ available = self.check_flatpak_available()
+ installed = self._installed_extension_branches() if available else set()
+ return self._success_response(
+ dict,
+ "Flatpak runtime extension status retrieved" if available else "Flatpak is not available",
+ available=available,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=sorted(installed),
+ )
+ except Exception as error:
+ return self._error_response(
+ dict,
+ str(error),
+ available=False,
+ extension_id=self.EXTENSION_ID,
+ supported_branches=list(self.SUPPORTED_RUNTIMES),
+ installed_branches=[],
)
- if result.returncode != 0:
- error_msg = f"Failed to install Flatpak extension: {result.stderr}"
- self.log.error(error_msg)
- return self._error_response(BaseResponse, error_msg)
-
- self.log.info(f"Successfully installed lsfg-vk Flatpak extension {version}")
- return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension installed successfully")
-
- except Exception as e:
- error_msg = f"Error installing Flatpak extension {version}: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(BaseResponse, error_msg)
+ get_flatpak_support_status = get_extension_status
- def uninstall_extension(self, version: str) -> BaseResponse:
- """Uninstall a specific version of the lsfg-vk Flatpak extension"""
+ def install_extension(self, branch: str):
try:
- if version not in ["23.08", "24.08", "25.08"]:
- return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'")
-
+ branch = self._validate_runtime(branch)
if not self.check_flatpak_available():
- return self._error_response(BaseResponse, "Flatpak is not available on this system")
-
- if version == "23.08":
- extension_id = self.extension_id_23_08
- elif version == "24.08":
- extension_id = self.extension_id_24_08
- else:
- extension_id = self.extension_id_25_08
-
- result = self._run_flatpak_command(
- ["uninstall", "--user", "--noninteractive", extension_id],
- capture_output=True, text=True
- )
-
- if result.returncode != 0:
- error_msg = f"Failed to uninstall Flatpak extension: {result.stderr}"
- self.log.error(error_msg)
- return self._error_response(BaseResponse, error_msg)
-
- self.log.info(f"Successfully uninstalled lsfg-vk Flatpak extension {version}")
- return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension uninstalled successfully")
-
- except Exception as e:
- error_msg = f"Error uninstalling Flatpak extension {version}: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(BaseResponse, error_msg)
-
- def get_flatpak_apps(self) -> FlatpakAppInfo:
- """Get list of installed Flatpak apps and their lsfg-vk override status"""
+ raise FileNotFoundError("Flatpak is not available on this system")
+ with self._lock:
+ if branch in self._verified_branches:
+ return self._extension_result(branch, True, False, "is ready")
+ user_installed = self._installed_extension_branches("user")
+ system_installed = self._installed_extension_branches("system")
+ if branch in system_installed and branch not in user_installed:
+ return self._extension_result(branch, True, False, "is ready")
+ if branch in user_installed and self._user_extension_origin(branch) != self.FLATHUB_REMOTE:
+ result = self._run_flatpak_command(
+ ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Could not replace the existing Flatpak extension")
+ result = self._run_flatpak_command(
+ [
+ "install",
+ "--user",
+ "--noninteractive",
+ "--or-update",
+ self.FLATHUB_REMOTE,
+ f"{self.EXTENSION_ID}//{branch}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Flatpak installation failed")
+ if branch not in self._installed_extension_branches("user"):
+ raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards")
+ state = self._read_state()
+ owned = self._owned_branches(state)
+ owned.add(branch)
+ state["plugin_owned_branches"] = sorted(owned)
+ self._write_state(state)
+ self._verified_branches.add(branch)
+ return self._extension_result(branch, True, False, "installed")
+ except Exception as error:
+ return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False)
+
+ def _remove_extension(self, branch: str) -> bool:
+ if branch not in self._installed_extension_branches("user"):
+ return False
+ result = self._run_flatpak_command(
+ ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise OSError(result.stderr.strip() or "Flatpak uninstall failed")
+ return True
+
+ def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str):
+ return self._success_response(
+ dict,
+ f"lsfg-vk {branch} runtime extension {verb}",
+ runtime_branch=branch,
+ installed=installed,
+ enabled=installed,
+ removed=removed,
+ )
+
+ def uninstall_extension(self, branch: str):
try:
+ branch = self._validate_runtime(branch)
if not self.check_flatpak_available():
- error_msg = "Flatpak is not available on this system"
- if self.flatpak_command is None:
- error_msg += ". Command not found in PATH or common install locations."
- return self._error_response(FlatpakAppInfo,
- error_msg,
- apps=[], total_apps=0)
-
+ raise FileNotFoundError("Flatpak is not available on this system")
+ with self._lock:
+ state = self._read_state()
+ owned = self._owned_branches(state)
+ if branch not in owned:
+ installed = branch in self._installed_extension_branches()
+ return self._extension_result(branch, installed, False, "preserved (not plugin-owned)")
+ removed = self._remove_extension(branch)
+ owned.remove(branch)
+ state["plugin_owned_branches"] = sorted(owned)
+ self._write_state(state)
+ installed = branch in self._installed_extension_branches()
+ return self._extension_result(branch, installed, removed, "uninstalled")
+ except Exception as error:
+ return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False)
+
+ def ensure_extension(self, branch: str):
+ return self.install_extension(branch)
+
+ def set_extension_enabled(self, branch: str, enabled: bool):
+ if type(enabled) is not bool:
+ return self._error_response(dict, "enabled must be a boolean", runtime_branch=branch, installed=False, enabled=False)
+ return self.install_extension(branch) if enabled else self.uninstall_extension(branch)
+
+ def get_flatpak_apps(self):
+ try:
+ if not self.check_flatpak_available():
+ raise FileNotFoundError("Flatpak is not available on this system")
+ installed_extensions = self._installed_extension_branches()
+ state = self._read_state()
+ owned_apps = state["prepared_apps"]
result = self._run_flatpak_command(
- ["list", "--app"],
- capture_output=True, text=True, check=True
+ ["list", "--app", "--columns=name,application"],
+ capture_output=True,
+ text=True,
+ check=True,
)
-
apps = []
- for line in result.stdout.strip().split('\n'):
- if not line.strip():
+ for line in result.stdout.splitlines():
+ fields = line.split("\t")
+ if len(fields) < 2:
continue
-
- parts = line.split('\t')
- if len(parts) >= 2:
- app_name = parts[0].strip()
- app_id = parts[1].strip()
-
- # Check override status
- override_status = self._check_app_override_status(app_id)
-
- apps.append({
- "app_id": app_id,
- "app_name": app_name,
- "has_filesystem_override": override_status["filesystem"],
- "has_env_override": override_status["env"]
+ name, app_id = fields[0].strip(), fields[1].strip()
+ if not app_id:
+ continue
+ item = {
+ "app_id": app_id,
+ "app_name": name or app_id,
+ "runtime": None,
+ "runtime_branch": None,
+ "runtime_ready": False,
+ "prepared": False,
+ "owned": app_id in owned_apps,
+ "error": None,
+ }
+ try:
+ runtime, branch = self._resolve_runtime(app_id)
+ status = self._app_override_status(app_id)
+ item.update({
+ "runtime": runtime,
+ "runtime_branch": branch,
+ "runtime_ready": branch in installed_extensions,
+ "prepared": status["prepared"],
})
-
- return self._success_response(FlatpakAppInfo,
- f"Found {len(apps)} Flatpak applications",
- apps=apps, total_apps=len(apps))
-
- except subprocess.CalledProcessError as e:
- error_msg = f"Error getting Flatpak apps: {e.stderr if e.stderr else str(e)}"
- self.log.error(error_msg)
- return self._error_response(FlatpakAppInfo, error_msg, apps=[], total_apps=0)
-
- def _check_app_override_status(self, app_id: str) -> Dict[str, bool]:
- """Check if an app has lsfg-vk overrides set"""
- try:
- result = self._run_flatpak_command(
- ["override", "--user", "--show", app_id],
- capture_output=True, text=True
- )
-
- if result.returncode != 0:
- return {"filesystem": False, "env": False}
-
- output = result.stdout
- home_path = os.path.expanduser("~")
- config_path = f"{home_path}/.config/lsfg-vk"
- dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"
- lsfg_path = f"{home_path}/lsfg"
-
- filesystem_section = ""
- in_context = False
-
- for line in output.split('\n'):
- line = line.strip()
- if line == "[Context]":
- in_context = True
- elif line.startswith("[") and line != "[Context]":
- in_context = False
- elif in_context and line.startswith("filesystems="):
- filesystem_section = line
- break
-
- has_config_fs = config_path in filesystem_section
- has_dll_fs = dll_path in filesystem_section
- has_lsfg_fs = lsfg_path in filesystem_section
-
- filesystem_override = has_config_fs and has_dll_fs and has_lsfg_fs
-
- env_override = False
- in_environment = False
-
- for line in output.split('\n'):
- line = line.strip()
- if line == "[Environment]":
- in_environment = True
- elif line.startswith("[") and line != "[Environment]":
- in_environment = False
- elif in_environment and line.startswith(f"LSFG_CONFIG={config_path}/conf.toml"):
- env_override = True
- break
-
- self.log.debug(f"Override status for {app_id}: filesystem={filesystem_override} ({has_config_fs}/{has_dll_fs}/{has_lsfg_fs}), env={env_override}")
-
- return {"filesystem": filesystem_override, "env": env_override}
-
- except Exception as e:
- self.log.error(f"Error checking override status for {app_id}: {e}")
- return {"filesystem": False, "env": False}
-
- def set_app_override(self, app_id: str) -> FlatpakOverrideResponse:
- """Set lsfg-vk overrides for a Flatpak app"""
+ except Exception as error:
+ item["error"] = str(error)
+ apps.append(item)
+ apps.sort(key=lambda item: str(item["app_name"]).lower())
+ return self._success_response(dict, f"Found {len(apps)} Flatpak applications", apps=apps)
+ except Exception as error:
+ return self._error_response(dict, str(error), apps=[])
+
+ def prepare_app(self, app_id: str):
try:
- if not self.check_flatpak_available():
- return self._error_response(FlatpakOverrideResponse,
- "Flatpak is not available on this system",
- app_id=app_id, operation="set")
-
- home_path = os.path.expanduser("~")
- config_path = f"{home_path}/.config/lsfg-vk"
- dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"
- lsfg_path = f"{home_path}/lsfg"
-
- filesystem_overrides = [
- f"--filesystem={dll_path}",
- f"--filesystem={config_path}:rw",
- f"--filesystem={lsfg_path}:rw"
- ]
-
- for override in filesystem_overrides:
+ app_id = self._validate_app_id(app_id)
+ with self._lock:
+ runtime, branch = self._resolve_runtime(app_id)
+ extension = self.ensure_extension(branch)
+ if not extension.get("success") or not extension.get("installed"):
+ raise RuntimeError(extension.get("error") or f"Could not install Flatpak runtime {branch}")
+ state = self._read_state()
+ apps = state["prepared_apps"]
+ status = self._app_override_status(app_id)
+ if status["prepared"] and app_id not in apps:
+ return self._success_response(
+ dict,
+ "Flatpak application is already prepared outside this plugin",
+ app_id=app_id,
+ runtime=runtime,
+ runtime_branch=branch,
+ prepared=True,
+ owned=False,
+ )
+ if app_id not in apps:
+ existed, original = self._snapshot_override(app_id)
+ backup = self._backup_path(app_id)
+ if existed:
+ self._write_file(backup, original.decode("utf-8"))
+ else:
+ backup.unlink(missing_ok=True)
+ apps[app_id] = {
+ "override_existed": existed,
+ "managed_sha256": "",
+ }
result = self._run_flatpak_command(
- ["override", "--user", override, app_id],
- capture_output=True, text=True
+ [
+ "override",
+ "--user",
+ f"--filesystem={self.config_dir}:ro",
+ f"--filesystem={self._dll_directory()}:ro",
+ f"--env=LSFGVK_CONFIG={self.config_file_path}",
+ "--env=LSFGVK_FLATPAK=1",
+ "--unset-env=DISABLE_LSFGVK",
+ "--unset-env=DISABLE_LSFG",
+ app_id,
+ ],
+ capture_output=True,
+ text=True,
)
if result.returncode != 0:
- error_msg = f"Failed to set filesystem override {override}: {result.stderr}"
- return self._error_response(FlatpakOverrideResponse, error_msg,
- app_id=app_id, operation="set")
-
- result = self._run_flatpak_command(
- ["override", "--user", f"--env=LSFG_CONFIG={config_path}/conf.toml", app_id],
- capture_output=True, text=True
- )
-
- if result.returncode != 0:
- error_msg = f"Failed to set environment override: {result.stderr}"
- return self._error_response(FlatpakOverrideResponse, error_msg,
- app_id=app_id, operation="set")
-
- self.log.info(f"Successfully set lsfg-vk overrides for {app_id}")
- return self._success_response(FlatpakOverrideResponse,
- f"lsfg-vk overrides set for {app_id}",
- app_id=app_id, operation="set")
-
- except Exception as e:
- error_msg = f"Error setting overrides for {app_id}: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(FlatpakOverrideResponse, error_msg,
- app_id=app_id, operation="set")
+ raise OSError(result.stderr.strip() or f"Could not prepare Flatpak app {app_id}")
+ status = self._app_override_status(app_id)
+ if not status["prepared"]:
+ raise RuntimeError(f"Flatpak preparation did not become visible for {app_id}")
+ existed, managed = self._snapshot_override(app_id)
+ if not existed:
+ raise RuntimeError(f"Flatpak override for {app_id} was not created")
+ apps[app_id]["managed_sha256"] = self._sha256(managed)
+ self._write_state(state)
+ return self._success_response(
+ dict,
+ "Flatpak application prepared for lsfg-vk",
+ app_id=app_id,
+ runtime=runtime,
+ runtime_branch=branch,
+ prepared=True,
+ owned=True,
+ )
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=False)
- def remove_app_override(self, app_id: str) -> FlatpakOverrideResponse:
- """Remove lsfg-vk overrides for a Flatpak app"""
+ def remove_app_override(self, app_id: str):
try:
- if not self.check_flatpak_available():
- return self._error_response(FlatpakOverrideResponse,
- "Flatpak is not available on this system",
- app_id=app_id, operation="remove")
-
- home_path = os.path.expanduser("~")
- config_path = f"{home_path}/.config/lsfg-vk"
- dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"
- lsfg_path = f"{home_path}/lsfg"
-
- reset_result = self._run_flatpak_command(
- ["override", "--user", "--reset", app_id],
- capture_output=True, text=True
- )
-
- if reset_result.returncode == 0:
- self.log.info(f"Successfully reset all overrides for {app_id}")
- return self._success_response(FlatpakOverrideResponse,
- f"All overrides reset for {app_id}",
- app_id=app_id, operation="remove")
-
- self.log.debug(f"Reset failed, trying individual removal: {reset_result.stderr}")
-
- filesystem_overrides = [
- f"--nofilesystem={dll_path}",
- f"--nofilesystem={config_path}",
- f"--nofilesystem={lsfg_path}"
- ]
-
- removal_errors = []
-
- # Remove filesystem overrides
- for override in filesystem_overrides:
- result = self._run_flatpak_command(
- ["override", "--user", override, app_id],
- capture_output=True, text=True
+ app_id = self._validate_app_id(app_id)
+ with self._lock:
+ state = self._read_state()
+ apps = state["prepared_apps"]
+ entry = apps.get(app_id)
+ if entry is None:
+ return self._success_response(
+ dict,
+ "Flatpak application is not plugin-owned; existing overrides were preserved",
+ app_id=app_id,
+ prepared=self._app_override_status(app_id)["prepared"],
+ owned=False,
+ )
+ existed, current = self._snapshot_override(app_id)
+ current_hash = self._sha256(current) if existed else self._sha256(b"")
+ if current_hash != entry["managed_sha256"]:
+ raise RuntimeError(
+ "Flatpak override changed after preparation; refusing to overwrite unrelated settings"
+ )
+ override_path = self._override_path(app_id)
+ backup_path = self._backup_path(app_id)
+ if entry["override_existed"]:
+ if not backup_path.is_file() or backup_path.is_symlink():
+ raise RuntimeError("Flatpak override backup is unavailable")
+ self._write_file(override_path, backup_path.read_text(encoding="utf-8"))
+ else:
+ override_path.unlink(missing_ok=True)
+ backup_path.unlink(missing_ok=True)
+ apps.pop(app_id, None)
+ self._write_state(state)
+ return self._success_response(
+ dict,
+ "Plugin-owned Flatpak preparation removed",
+ app_id=app_id,
+ prepared=False,
+ owned=False,
)
- if result.returncode != 0:
- removal_errors.append(f"{override}: {result.stderr}")
+ except Exception as error:
+ return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=True)
- result = self._run_flatpak_command(
- ["override", "--user", "--unset-env=LSFG_CONFIG", app_id],
- capture_output=True, text=True
- )
-
- if result.returncode != 0:
- removal_errors.append(f"unset-env: {result.stderr}")
-
- if removal_errors:
- self.log.warning(f"Some override removals had issues for {app_id}: {'; '.join(removal_errors)}")
-
- self.log.info(f"Completed override removal for {app_id}")
- return self._success_response(FlatpakOverrideResponse,
- f"lsfg-vk overrides removed for {app_id}",
- app_id=app_id, operation="remove")
-
- except Exception as e:
- error_msg = f"Error removing overrides for {app_id}: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(FlatpakOverrideResponse, error_msg,
- app_id=app_id, operation="remove") \ No newline at end of file
+ def remove_plugin_owned_environment(self):
+ try:
+ with self._lock:
+ state = self._read_state()
+ failures = []
+ removed_apps = []
+ for app_id in list(state["prepared_apps"]):
+ result = self.remove_app_override(app_id)
+ if result.get("success"):
+ removed_apps.append(app_id)
+ else:
+ failures.append(f"{app_id}: {result.get('error')}")
+ if failures:
+ return self._error_response(
+ dict,
+ "; ".join(failures),
+ removed_apps=removed_apps,
+ removed_branches=[],
+ )
+ state = self._read_state()
+ removed_branches = []
+ for branch in sorted(self._owned_branches(state)):
+ result = self.uninstall_extension(branch)
+ if result.get("success"):
+ removed_branches.append(branch)
+ else:
+ failures.append(f"{branch}: {result.get('error')}")
+ if failures:
+ return self._error_response(
+ dict,
+ "; ".join(failures),
+ removed_apps=removed_apps,
+ removed_branches=removed_branches,
+ )
+ return self._success_response(
+ dict,
+ "Plugin-owned Flatpak state removed",
+ removed_apps=removed_apps,
+ removed_branches=removed_branches,
+ )
+ except Exception as error:
+ return self._error_response(dict, str(error), removed_apps=[], removed_branches=[])
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index ce47268..e7366ee 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -1,443 +1,256 @@
-"""
-Installation service for lsfg-vk.
-"""
-
import os
-import platform
import shutil
-import traceback
-import zipfile
+import tarfile
import tempfile
-import json
+import traceback
from pathlib import Path
-from typing import Dict, Any
+from typing import Dict
from .base_service import BaseService
+from .config_schema import ConfigurationManager, ProfileData
from .constants import (
- LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME, BIN_DIR,
- SO_EXT, JSON_EXT, ARM_LIB_FILENAME, ARMADA_DEVICE_ENV
+ ARCHIVE_FILENAME,
+ BIN_DIR,
+ CLI_FILENAME,
+ JSON_FILENAME,
+ JSON_X86_FILENAME,
+ LEGACY_JSON_FILENAME,
+ LEGACY_LIB_FILENAME,
+ LIB_FILENAME,
+ LIB_X86_FILENAME,
+ LOCAL_SHARE,
+ UI_DESKTOP_FILENAME,
+ UI_FILENAME,
+ UI_ICON_FILENAME,
)
-from .config_schema import ConfigurationManager
-from .types import InstallationResponse, UninstallationResponse, InstallationCheckResponse
+from .runtime_service import RuntimeService
+from .steam_service import SteamService
+from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse
class InstallationService(BaseService):
- """Service for handling lsfg-vk installation and uninstallation"""
-
- def __init__(self, logger=None):
+ def __init__(
+ self,
+ logger=None,
+ runtime_service: RuntimeService = None,
+ steam_service: SteamService = None,
+ ):
super().__init__(logger)
-
+ self.runtime_service = runtime_service or RuntimeService(logger=self.log)
+ self.steam_service = steam_service or SteamService(logger=self.log)
self.lib_file = self.local_lib_dir / LIB_FILENAME
+ self.lib_x86_file = self.local_lib_dir / LIB_X86_FILENAME
self.json_file = self.local_share_dir / JSON_FILENAME
-
+ self.json_x86_file = self.local_share_dir / JSON_X86_FILENAME
+ self.cli_file = self.local_bin_dir / CLI_FILENAME
+ self.legacy_lib_file = self.local_lib_dir / LEGACY_LIB_FILENAME
+ self.legacy_json_file = self.local_share_dir / LEGACY_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:
- plugin_dir = Path(__file__).parent.parent.parent
- zip_path = plugin_dir / BIN_DIR / ZIP_FILENAME
-
- if not zip_path.exists():
- error_msg = f"{ZIP_FILENAME} not found at {zip_path}"
- self.log.error(error_msg)
- return self._error_response(InstallationResponse, error_msg, message="")
-
+ archive_path = Path(__file__).parent.parent.parent / BIN_DIR / ARCHIVE_FILENAME
+ if not archive_path.exists():
+ raise FileNotFoundError(f"{ARCHIVE_FILENAME} not found at {archive_path}")
self._ensure_directories()
-
- self._extract_and_install_files(zip_path)
-
- # If on ARM, overwrite the .so with the ARM version
- if self._is_arm_architecture():
- self.log.info("Detected ARM architecture, using ARM binary")
- arm_so_path = plugin_dir / BIN_DIR / ARM_LIB_FILENAME
- self._copy_plugin_file(arm_so_path, self.lib_file)
- self.log.info(f"Overwrote with ARM binary: {self.lib_file}")
+ profile_data = self._prepare_config()
+ self._install_archive(archive_path)
+ content = ConfigurationManager.generate_toml_content_multi_profile(profile_data)
+ self.runtime_service.validate_config_content(content)
+ self._write_file(self.config_file_path, content, 0o644)
+ self._remove_legacy_layer_files()
+ return self._success_response(InstallationResponse, "lsfg-vk 2.0.0 installed successfully")
+ except Exception as error:
+ self.log.error(f"Error installing lsfg-vk: {error}")
+ return self._error_response(InstallationResponse, str(error))
- self._create_config_file()
-
- self._create_lsfg_launch_script()
-
- self.log.info("lsfg-vk installed successfully")
- return self._success_response(InstallationResponse, "lsfg-vk installed successfully")
-
- except (OSError, zipfile.BadZipFile, shutil.Error) as e:
- error_msg = f"Error installing lsfg-vk: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(InstallationResponse, str(e), message="")
- except Exception as e:
- error_msg = f"Unexpected error installing lsfg-vk: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(InstallationResponse, str(e), message="")
-
- def _is_arm_architecture(self) -> bool:
- """Check if running on ARM architecture
+ def _payload_destinations(self) -> Dict[str, tuple[Path, int]]:
+ return {
+ f"bin/{CLI_FILENAME}": (self.cli_file, 0o755),
+ f"bin/{UI_FILENAME}": (self.local_bin_dir / UI_FILENAME, 0o755),
+ f"lib/{LIB_FILENAME}": (self.lib_file, 0o644),
+ f"lib/{LIB_X86_FILENAME}": (self.lib_x86_file, 0o644),
+ f"share/vulkan/implicit_layer.d/{JSON_FILENAME}": (self.json_file, 0o644),
+ f"share/vulkan/implicit_layer.d/{JSON_X86_FILENAME}": (self.json_x86_file, 0o644),
+ f"share/applications/{UI_DESKTOP_FILENAME}": (
+ self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME,
+ 0o644,
+ ),
+ f"share/icons/hicolor/256x256/apps/{UI_ICON_FILENAME}": (
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME,
+ 0o644,
+ ),
+ }
- Returns:
- True if running on ARM (aarch64), False otherwise
- """
- if platform.machine().lower() in ('aarch64', 'arm64'):
- return True
+ def _install_archive(self, archive_path: Path) -> None:
+ destinations = self._payload_destinations()
+ found = set()
+ with tarfile.open(archive_path, "r:*") as archive:
+ members = {
+ member.name.removeprefix("./"): member
+ for member in archive.getmembers()
+ if member.isfile()
+ }
+ for source_path, (destination, mode) in destinations.items():
+ member = members.get(source_path)
+ if member is None:
+ continue
+ source = archive.extractfile(member)
+ if source is None:
+ continue
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ temporary_path = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="wb",
+ dir=destination.parent,
+ prefix=f".{destination.name}.",
+ delete=False,
+ ) as temporary_file:
+ temporary_path = Path(temporary_file.name)
+ with source:
+ shutil.copyfileobj(source, temporary_file)
+ temporary_file.flush()
+ os.fsync(temporary_file.fileno())
+ temporary_path.chmod(mode)
+ os.replace(temporary_path, destination)
+ found.add(source_path)
+ except Exception:
+ if temporary_path is not None:
+ temporary_path.unlink(missing_ok=True)
+ raise
+ missing = sorted(set(destinations) - found)
+ if missing:
+ raise OSError("Archive is missing required files: " + ", ".join(missing))
- # Decky runs through FEX on Armada, so Python reports x86_64 even
- # though the host is AArch64. Armada exposes this native helper only
- # on its ARM image, including inside Decky's FEX rootfs.
- if ARMADA_DEVICE_ENV.is_file():
- self.log.info("Detected native AArch64 Armada host through device-env")
- return True
+ def _default_config(self) -> ProfileData:
+ defaults = ConfigurationManager.get_defaults()
+ return ProfileData(
+ profiles={},
+ global_config={"dll": defaults["dll"], "no_fp16": defaults["no_fp16"]},
+ )
- # Fall back to the native PID 1 ELF header. e_machine 183 is AArch64.
+ def _prepare_config(self) -> ProfileData:
try:
- with Path('/proc/1/exe').open('rb') as host_executable:
- elf_header = host_executable.read(20)
- if elf_header[:4] == b'\x7fELF' and elf_header[5] in (1, 2):
- byte_order = 'little' if elf_header[5] == 1 else 'big'
- if int.from_bytes(elf_header[18:20], byte_order) == 183:
- self.log.info("Detected native AArch64 host through PID 1")
- return True
- except OSError as e:
- self.log.debug(f"Could not inspect native host architecture: {e}")
+ profile_data = (
+ ConfigurationManager.parse_toml_content_multi_profile(
+ self.config_file_path.read_text(encoding="utf-8")
+ )
+ if self.config_file_path.exists()
+ else self._default_config()
+ )
+ except ValueError:
+ profile_data = self._default_config()
+ self._resolve_dll_path(profile_data)
+ defaults = ConfigurationManager.get_defaults()
+ for name, profile in profile_data["profiles"].items():
+ profile_data["profiles"][name] = ConfigurationManager.validate_config(
+ {**defaults, **profile, **profile_data["global_config"]}
+ )
+ return profile_data
+ def _resolve_dll_path(self, profile_data: ProfileData) -> bool:
+ current_path = str(profile_data["global_config"].get("dll") or "")
+ if current_path and Path(current_path).is_file():
+ return False
+ dll_path = self.steam_service.find_lsfg_vk_dll()
+ if dll_path and current_path != dll_path:
+ profile_data["global_config"]["dll"] = dll_path
+ return True
return False
- @staticmethod
- def _copy_plugin_file(src_file: Path, dst_file: Path) -> None:
- """Copy plugin content without preserving FEX-incompatible metadata."""
- shutil.copyfile(src_file, dst_file)
- dst_file.chmod(0o644)
+ def _remove_legacy_layer_files(self) -> None:
+ for path in (self.legacy_lib_file, self.legacy_json_file):
+ self._remove_if_exists(path)
- 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
-
- # Special handling for JSON files - need to modify library_path
- if file_path.suffix == JSON_EXT and file == JSON_FILENAME:
- self._copy_and_fix_json_file(src_file, dst_file)
- else:
- self._copy_plugin_file(src_file, dst_file)
-
- self.log.info(f"Copied {file} to {dst_file}")
-
- def _copy_and_fix_json_file(self, src_file: Path, dst_file: Path) -> None:
- """Copy JSON file and fix the library_path to use relative path
-
- Args:
- src_file: Source JSON file path
- dst_file: Destination JSON file path
- """
- try:
- # Read the JSON file
- with open(src_file, 'r') as f:
- json_data = json.load(f)
-
- # Fix the library_path from "liblsfg-vk.so" to "../../../lib/liblsfg-vk.so"
- if 'layer' in json_data and 'library_path' in json_data['layer']:
- current_path = json_data['layer']['library_path']
- if current_path == "liblsfg-vk.so":
- json_data['layer']['library_path'] = "../../../lib/liblsfg-vk.so"
- self.log.info(f"Fixed library_path from '{current_path}' to '../../../lib/liblsfg-vk.so'")
-
- # Write the modified JSON file
- with open(dst_file, 'w') as f:
- json.dump(json_data, f, indent=2)
-
- except (json.JSONDecodeError, KeyError, OSError) as e:
- self.log.error(f"Error fixing JSON file {src_file}: {e}")
- # Fallback to simple copy if JSON modification fails
- self._copy_plugin_file(src_file, dst_file)
-
- def _create_config_file(self) -> None:
- """Create or update the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path
-
- If a config file already exists, preserve existing profiles and only update global settings like DLL path.
- """
- # Import here to avoid circular imports
- from .dll_detection import DllDetectionService
-
- # Try to detect DLL path
- dll_service = DllDetectionService(self.log)
-
- # Check if config file already exists
- if self.config_file_path.exists():
+ def _prune_empty_directories(self) -> None:
+ # Only prune directories created by this plugin. Never remove a
+ # non-empty directory because the user's other tools may use it.
+ candidates = (
+ self.config_dir,
+ self.local_bin_dir,
+ self.local_lib_dir,
+ self.local_share_dir,
+ self.user_home / LOCAL_SHARE / "applications",
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps",
+ )
+ for directory in candidates:
try:
- # Read existing config to preserve user profiles
- content = self.config_file_path.read_text(encoding='utf-8')
- existing_profile_data = ConfigurationManager.parse_toml_content_multi_profile(content)
- self.log.info(f"Found existing config file, preserving user profiles")
-
- # Create merged profile data that preserves user settings but adds any new fields
- merged_profile_data = self._merge_config_with_defaults(existing_profile_data, dll_service)
-
- # Generate TOML content with merged profiles
- toml_content = ConfigurationManager.generate_toml_content_multi_profile(merged_profile_data)
-
- except Exception as e:
- self.log.warning(f"Failed to parse existing config file: {str(e)}, creating new one")
- # Fall back to creating a new config file
- config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- toml_content = ConfigurationManager.generate_toml_content(config)
- else:
- # No existing config file, create a new one with defaults
- config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- toml_content = ConfigurationManager.generate_toml_content(config)
- self.log.info(f"Creating new config file")
-
- # Write config file
- self._write_file(self.config_file_path, toml_content, 0o644)
- self.log.info(f"Created config file at {self.config_file_path}")
-
- # Log detected DLL path if found - USE GENERATED CONSTANTS
- from .config_schema_generated import DLL
- try:
- # Try to parse the written content to get the DLL path
- final_content = self.config_file_path.read_text(encoding='utf-8')
- final_config = ConfigurationManager.parse_toml_content(final_content)
- if final_config.get(DLL):
- self.log.info(f"Configured DLL path: {final_config[DLL]}")
- except (OSError, IOError, ValueError, KeyError) as e:
- # Don't fail installation if we can't log the DLL path
- self.log.debug(f"Could not log DLL path: {e}")
-
- def _create_lsfg_launch_script(self) -> None:
- """Create the ~/lsfg launch script for easier game setup"""
- # Use the default configuration for the initial script
- from .config_schema import ConfigurationManager
- default_config = ConfigurationManager.get_defaults()
-
- # Create configuration service to generate the script
- from .configuration import ConfigurationService
- config_service = ConfigurationService(logger=self.log)
- config_service.user_home = self.user_home
- config_service.lsfg_script_path = self.lsfg_launch_script_path
-
- # Generate script content with default configuration
- script_content = config_service._generate_script_content(default_config)
-
- # Write the script file
- self._write_file(self.lsfg_launch_script_path, script_content, 0o755)
- self.log.info(f"Created lsfg launch script at {self.lsfg_launch_script_path}")
-
- def get_launch_script_path(self) -> str:
- """Get the path to the lsfg launch script
-
- Returns:
- String path to the launch script file
- """
- return str(self.lsfg_launch_script_path)
+ if directory.is_dir() and not directory.is_symlink():
+ directory.rmdir()
+ self.log.info(f"Removed empty directory {directory}")
+ except OSError:
+ continue
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()
- config_exists = self.config_file_path.exists()
-
- self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, config={config_exists}")
-
+ installation_error = None
+ try:
+ installed = self.runtime_service.is_healthy()
+ except Exception as error:
+ installed = False
+ installation_error = str(error)
+ lossless_scaling = self.runtime_service.check_lossless_scaling()
return {
- "installed": lib_exists and json_exists,
- "lib_exists": lib_exists,
- "json_exists": json_exists,
- "script_exists": config_exists, # Keep script_exists for backward compatibility
- "lib_path": str(self.lib_file),
- "json_path": str(self.json_file),
- "script_path": str(self.config_file_path), # Keep script_path for backward compatibility
- "error": None
+ "installed": installed,
+ "lossless_scaling_installed": bool(lossless_scaling["installed"]),
+ "lossless_scaling_status": str(lossless_scaling["status"]),
+ "error": installation_error,
}
-
- except Exception as e:
- error_msg = f"Error checking lsfg-vk installation: {str(e)}"
- self.log.error(error_msg)
+ except Exception as error:
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.config_file_path),
- "error": str(e)
+ "lossless_scaling_installed": False,
+ "lossless_scaling_status": str(error),
+ "error": str(error),
}
-
+
def uninstall(self) -> UninstallationResponse:
- """Uninstall lsfg-vk by removing the installed files
-
- Note: The config file (conf.toml) is preserved to maintain user's custom profiles
-
- Returns:
- UninstallationResponse with success status and removed files list
- """
- try:
- removed_files = []
- # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles
- files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path]
-
- for file_path in files_to_remove:
- if self._remove_if_exists(file_path):
- removed_files.append(str(file_path))
-
- # Also try to remove the old script file if it exists (for backward compatibility)
- if self._remove_if_exists(self.lsfg_script_path):
- removed_files.append(str(self.lsfg_script_path))
-
- # Don't remove config directory since we're preserving the config file
-
- if not removed_files:
- return self._success_response(UninstallationResponse,
- "No lsfg-vk files found to remove",
- removed_files=None)
-
- self.log.info("lsfg-vk uninstalled successfully")
- return self._success_response(UninstallationResponse,
- f"lsfg-vk uninstalled successfully. Removed {len(removed_files)} files.",
- removed_files=removed_files)
-
- except OSError as e:
- error_msg = f"Error uninstalling lsfg-vk: {str(e)}"
- self.log.error(error_msg)
- return self._error_response(UninstallationResponse, str(e),
- message="", removed_files=None)
-
- def cleanup_on_uninstall(self) -> None:
- """Clean up lsfg-vk files when the plugin is uninstalled
-
- Note: The config file (conf.toml) is preserved to maintain user's custom profiles
- """
try:
- self.log.info("Checking for lsfg-vk files to clean up:")
- self.log.info(f" Library file: {self.lib_file}")
- self.log.info(f" JSON file: {self.json_file}")
- self.log.info(f" Config file: {self.config_file_path} (preserved)")
- self.log.info(f" Launch script: {self.lsfg_launch_script_path}")
- self.log.info(f" Old script file: {self.lsfg_script_path}")
-
- removed_files = []
- # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles
- files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path, self.lsfg_script_path]
-
- for file_path in files_to_remove:
- try:
- 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}")
-
- # Don't remove config directory since we're preserving the config file
-
- if removed_files:
- self.log.info(f"Cleaned up {len(removed_files)} lsfg-vk files during plugin uninstall: {removed_files}")
- 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)}")
- self.log.error(f"Traceback: {traceback.format_exc()}")
+ removed = [
+ str(path)
+ for path in (
+ self.lib_file,
+ self.lib_x86_file,
+ self.json_file,
+ self.json_x86_file,
+ self.cli_file,
+ self.local_bin_dir / UI_FILENAME,
+ self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME,
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME,
+ self.legacy_lib_file,
+ self.legacy_json_file,
+ self.legacy_script_path,
+ self.config_file_path,
+ )
+ if self._remove_if_exists(path)
+ ]
+ self._prune_empty_directories()
+ if not removed:
+ return self._success_response(
+ UninstallationResponse,
+ "No lsfg-vk files found to remove",
+ removed_files=None,
+ )
+ return self._success_response(
+ UninstallationResponse,
+ f"lsfg-vk uninstalled successfully. Removed {len(removed)} files.",
+ removed_files=removed,
+ )
+ except Exception as error:
+ return self._error_response(
+ UninstallationResponse,
+ str(error),
+ removed_files=None,
+ )
- def _merge_config_with_defaults(self, existing_profile_data, dll_service):
- """Merge existing user config with current schema defaults
-
- This ensures that:
- 1. User's custom profiles and values are preserved
- 2. Any new fields added to the schema get their default values
- 3. Global settings like DLL path are updated as needed
-
- Args:
- existing_profile_data: The user's existing ProfileData
- dll_service: DLL detection service for updating DLL path
-
- Returns:
- ProfileData with merged configuration
- """
- from .config_schema import ProfileData
-
- # Get current schema defaults
- default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- default_global_config = {
- "dll": default_config.get("dll", ""),
- "no_fp16": False
- }
-
- # Start with existing data
- merged_data: ProfileData = {
- "current_profile": existing_profile_data.get("current_profile", "decky-lsfg-vk"),
- "global_config": existing_profile_data.get("global_config", {}).copy(),
- "profiles": {}
- }
-
- # Merge global config: preserve user values, add missing fields, update DLL
- for key, default_value in default_global_config.items():
- if key not in merged_data["global_config"]:
- merged_data["global_config"][key] = default_value
- self.log.info(f"Added missing global field '{key}' with default value: {default_value}")
-
- # Update DLL path if detected
- dll_result = dll_service.check_lossless_scaling_dll()
- if dll_result.get("detected") and dll_result.get("path"):
- old_dll = merged_data["global_config"].get("dll")
- merged_data["global_config"]["dll"] = dll_result["path"]
- if old_dll != dll_result["path"]:
- self.log.info(f"Updated DLL path from '{old_dll}' to: {dll_result['path']}")
-
- # Merge each profile: preserve user values, add missing fields
- existing_profiles = existing_profile_data.get("profiles", {})
-
- for profile_name, existing_profile_config in existing_profiles.items():
- merged_profile_config = existing_profile_config.copy()
-
- # Add any missing fields from current schema with default values
- added_fields = []
- for key, default_value in default_config.items():
- if key not in merged_profile_config and key not in ["dll", "no_fp16"]: # Skip global fields
- merged_profile_config[key] = default_value
- added_fields.append(key)
-
- if added_fields:
- self.log.info(f"Profile '{profile_name}': Added missing fields {added_fields}")
-
- merged_data["profiles"][profile_name] = merged_profile_config
-
- # If no profiles exist, create the default one
- if not merged_data["profiles"]:
- merged_data["profiles"]["decky-lsfg-vk"] = {
- k: v for k, v in default_config.items()
- if k not in ["dll", "no_fp16"] # Exclude global fields
- }
- merged_data["current_profile"] = "decky-lsfg-vk"
- self.log.info("No existing profiles found, created default profile")
-
- return merged_data
+ def cleanup_on_uninstall(self) -> bool:
+ try:
+ result = self.uninstall()
+ if not result.get("success"):
+ self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {result.get('error')}")
+ return False
+ return True
+ except Exception as error:
+ self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {error}")
+ self.log.error(traceback.format_exc())
+ return False
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index cb59b4f..c63e3a9 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -1,495 +1,222 @@
-"""
-Main plugin class for the lsfg-vk Decky Loader plugin.
-
-This plugin provides services for installing and managing the lsfg-vk
-Vulkan layer for frame generation on Steam Deck.
-"""
-
import os
-import subprocess
-import hashlib
-from typing import Dict, Any
-from pathlib import Path
+from typing import Any, Dict
import decky
-from .installation import InstallationService
-from .dll_detection import DllDetectionService
from .configuration import ConfigurationService
-from .config_schema import ConfigurationManager
+from .flatpak_profile_service import FlatpakProfileService
from .flatpak_service import FlatpakService
+from .installation import InstallationService
+from .runtime_service import RuntimeService
+from .steam_service import SteamService
+from .wrapper_service import WrapperService
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"""
- self.installation_service = InstallationService()
- self.dll_detection_service = DllDetectionService()
- self.configuration_service = ConfigurationService()
+ self.runtime_service = RuntimeService()
+ self.steam_service = SteamService()
+ self.installation_service = InstallationService(
+ runtime_service=self.runtime_service,
+ steam_service=self.steam_service,
+ )
+ self.configuration_service = ConfigurationService(runtime_service=self.runtime_service)
self.flatpak_service = FlatpakService()
+ self.flatpak_profile_service = FlatpakProfileService(
+ self.flatpak_service,
+ self.configuration_service,
+ )
+ self.wrapper_service = WrapperService()
- 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
- """
+ async def install_lsfg_vk(self):
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
- """
+ async def check_lsfg_vk_installed(self):
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()
-
- 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()
-
- async def get_dll_stats(self) -> Dict[str, Any]:
- """Get detailed statistics about the detected DLL
-
- Returns:
- Dict containing DLL path, SHA256 hash, and other stats
- """
- try:
- dll_result = self.dll_detection_service.check_lossless_scaling_dll()
-
- if not dll_result.get("detected") or not dll_result.get("path"):
- return {
- "success": False,
- "error": "DLL not detected",
- "dll_path": None,
- "dll_sha256": None
- }
-
- dll_path = dll_result["path"]
- if dll_path is None:
- return {
- "success": False,
- "error": "DLL path is None",
- "dll_path": None,
- "dll_sha256": None
- }
-
- dll_path_obj = Path(dll_path)
-
- sha256_hash = hashlib.sha256()
- try:
- with open(dll_path_obj, "rb") as f:
- for chunk in iter(lambda: f.read(4096), b""):
- sha256_hash.update(chunk)
- dll_sha256 = sha256_hash.hexdigest()
- except Exception as e:
- return {
- "success": False,
- "error": f"Failed to calculate SHA256: {str(e)}",
- "dll_path": dll_path,
- "dll_sha256": None
- }
-
- return {
- "success": True,
- "dll_path": dll_path,
- "dll_sha256": dll_sha256,
- "dll_source": dll_result.get("source"),
- "error": None
- }
-
- except Exception as e:
+ def _cleanup_runtime_state(self, preserve_wrapper: bool = False):
+ flatpak = self.flatpak_service.remove_plugin_owned_environment()
+ if not flatpak.get("success"):
+ return flatpak.get("error") or "Could not clean up Flatpak support"
+ profiles = self.configuration_service.reset_all_flatpak_configs()
+ if not profiles.get("success"):
+ return profiles.get("error") or "Could not remove Flatpak profiles"
+ wrapper = self.wrapper_service.neutralize() if preserve_wrapper else self.wrapper_service.purge()
+ if not wrapper.get("success"):
+ return wrapper.get("error") or "Could not remove workaround state"
+ return None
+
+ async def uninstall_lsfg_vk(self):
+ error = self._cleanup_runtime_state()
+ if error:
return {
"success": False,
- "error": f"Failed to get DLL stats: {str(e)}",
- "dll_path": None,
- "dll_sha256": None
+ "message": "",
+ "error": error,
+ "removed_files": None,
}
+ return self.installation_service.uninstall()
- 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 get_config_schema(self) -> Dict[str, Any]:
- """Get configuration schema information for frontend
-
- Returns:
- Dict with field names, types, defaults, and profile information
- """
- try:
- profiles_response = self.configuration_service.get_profiles()
-
- schema_data = {
- "field_names": ConfigurationManager.get_field_names(),
- "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()},
- "defaults": ConfigurationManager.get_defaults()
- }
-
- if profiles_response.get("success"):
- schema_data["profiles"] = profiles_response.get("profiles", [])
- schema_data["current_profile"] = profiles_response.get("current_profile")
- else:
- schema_data["profiles"] = ["decky-lsfg-vk"]
- schema_data["current_profile"] = "decky-lsfg-vk"
-
- return schema_data
-
- except (ValueError, KeyError, AttributeError) as e:
- self.configuration_service.log.warning(f"Failed to get full schema, using fallback: {e}")
- return {
- "field_names": ConfigurationManager.get_field_names(),
- "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()},
- "defaults": ConfigurationManager.get_defaults(),
- "profiles": ["decky-lsfg-vk"],
- "current_profile": "decky-lsfg-vk"
- }
+ async def get_game_configs(self):
+ return self.configuration_service.get_game_configs()
- 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:
- config: Configuration data dictionary containing all settings
-
- Returns:
- ConfigurationResponse dict with success status
- """
- validated_config = ConfigurationManager.validate_config(config)
-
- return self.configuration_service.update_config_from_dict(validated_config)
-
- async def get_profiles(self) -> Dict[str, Any]:
- """Get list of all profiles and current profile
-
- Returns:
- ProfilesResponse dict with profile list and current profile
- """
- return self.configuration_service.get_profiles()
-
- async def create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]:
- """Create a new profile
-
- Args:
- profile_name: Name for the new profile
- source_profile: Optional source profile to copy from (default: current profile)
-
- Returns:
- ProfileResponse dict with success status
- """
- return self.configuration_service.create_profile(profile_name, source_profile)
-
- async def delete_profile(self, profile_name: str) -> Dict[str, Any]:
- """Delete a profile
-
- Args:
- profile_name: Name of the profile to delete
-
- Returns:
- ProfileResponse dict with success status
- """
- return self.configuration_service.delete_profile(profile_name)
-
- async def rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]:
- """Rename a profile
-
- Args:
- old_name: Current profile name
- new_name: New profile name
-
- Returns:
- ProfileResponse dict with success status
- """
- return self.configuration_service.rename_profile(old_name, new_name)
-
- async def set_current_profile(self, profile_name: str) -> Dict[str, Any]:
- """Set the current active profile
-
- Args:
- profile_name: Name of the profile to set as current
-
- Returns:
- ProfileResponse dict with success status
- """
- return self.configuration_service.set_current_profile(profile_name)
-
- async def update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
- """Update configuration for a specific profile
-
- Args:
- profile_name: Name of the profile to update
- config: Configuration data dictionary containing settings
-
- Returns:
- ConfigurationResponse dict with success status
- """
- validated_config = ConfigurationManager.validate_config(config)
-
- return self.configuration_service.update_profile_config(profile_name, validated_config)
-
- async def get_launch_option(self) -> Dict[str, Any]:
- """Get the launch option that users need to set for their games
-
- Returns:
- Dict containing the launch option string and instructions
- """
- return {
- "launch_option": "~/lsfg %command%",
- "instructions": "Add this to your game's launch options in Steam Properties",
- "explanation": "The lsfg script is created during installation and sets up the environment for the plugin"
- }
+ async def update_global_config(self, config: Dict[str, Any]):
+ return self.configuration_service.update_global_config(config)
+
+ async def get_installed_games(self):
+ return self.steam_service.get_installed_games()
+
+ async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]):
+ return self.configuration_service.update_game_config(appid, game_name, config)
+
+ async def reset_game_config(self, appid: str):
+ return self.configuration_service.reset_game_config(appid)
+
+ async def reset_game_configs(self, appids):
+ return self.configuration_service.reset_game_configs(appids)
+
+ async def reset_all_game_configs(self):
+ return self.configuration_service.reset_all_game_configs()
+
+ async def get_workaround_state(self, appid: str):
+ return self.wrapper_service.get(appid)
+
+ async def get_workaround_apps(self):
+ return self.wrapper_service.list_apps()
- async def get_config_file_content(self) -> Dict[str, Any]:
- """Get the current config file content
-
- Returns:
- Dict containing the config file content or error message
- """
+ async def set_workaround_state(
+ self,
+ appid: str,
+ state: Dict[str, Any],
+ command_token_added: bool = False,
+ non_steam: bool = False,
+ ):
+ return self.wrapper_service.set(appid, state, command_token_added, non_steam)
+
+ async def remove_workaround_state(self, appid: str):
+ return self.wrapper_service.remove(appid)
+
+ async def get_config_file_content(self):
+ path = self.configuration_service.config_file_path
try:
- config_path = self.configuration_service.config_file_path
- if not config_path.exists():
+ if not path.exists():
return {
"success": False,
"content": None,
- "path": str(config_path),
- "error": "Config file does not exist"
+ "path": str(path),
+ "error": "Config file does not exist",
}
-
- content = config_path.read_text(encoding='utf-8')
return {
"success": True,
- "content": content,
- "path": str(config_path),
- "error": None
+ "content": path.read_text(encoding="utf-8"),
+ "path": str(path),
+ "error": None,
}
- except Exception as e:
+ except Exception as error:
return {
"success": False,
"content": None,
- "path": str(config_path) if 'config_path' in locals() else "unknown",
- "error": f"Error reading config file: {str(e)}"
+ "path": str(path),
+ "error": f"Error reading config file: {error}",
}
- async def get_launch_script_content(self) -> Dict[str, Any]:
- """Get the content of the launch script file
-
- Returns:
- FileContentResponse dict with file content or error information
- """
- try:
- script_path = self.installation_service.get_launch_script_path()
-
- if not os.path.exists(script_path):
- return {
- "success": False,
- "error": f"Launch script not found at {script_path}",
- "path": str(script_path)
- }
-
- with open(script_path, 'r') as file:
- content = file.read()
-
- return {
- "success": True,
- "content": content,
- "path": str(script_path)
- }
-
- except Exception as e:
- decky.logger.error(f"Error reading launch script: {e}")
- return {
- "success": False,
- "error": str(e)
- }
-
- async def check_fgmod_directory(self) -> Dict[str, Any]:
- """Check if the fgmod directory exists in the home directory
-
- Returns:
- Dict with exists status and directory path
- """
- try:
- home_path = Path(decky.DECKY_USER_HOME)
- fgmod_path = home_path / "fgmod"
-
- exists = fgmod_path.exists() and fgmod_path.is_dir()
-
- return {
- "success": True,
- "exists": exists,
- "path": str(fgmod_path)
- }
-
- except Exception as e:
- decky.logger.error(f"Error checking fgmod directory: {e}")
- return {
- "success": False,
+ async def get_debug_file_contents(self):
+ files = (
+ ("config", "LSFG-VK configuration", self.configuration_service.config_file_path),
+ ("workarounds", "Per-app workarounds", self.wrapper_service.sidecar_path),
+ ("wrapper", "Generated launch wrapper", self.wrapper_service.wrapper_path),
+ ("flatpak", "Flatpak ownership state", self.flatpak_service.ownership_path),
+ )
+ contents = []
+ for file_id, label, path in files:
+ item = {
+ "id": file_id,
+ "label": label,
+ "path": str(path),
"exists": False,
- "error": str(e)
+ "content": None,
+ "error": None,
}
+ try:
+ if path.is_symlink():
+ item["error"] = "Path is a symlink; refusing to read it"
+ elif not path.exists():
+ item["error"] = "File does not exist"
+ elif not path.is_file():
+ item["error"] = "Path is not a regular file"
+ else:
+ item["exists"] = True
+ item["content"] = path.read_text(encoding="utf-8")
+ except Exception as error:
+ item["error"] = f"Error reading file: {error}"
+ contents.append(item)
+ return {
+ "success": True,
+ "message": "Debug file contents retrieved",
+ "error": None,
+ "files": contents,
+ }
+
+ async def get_lossless_scaling_branch_status(self):
+ return self.steam_service.get_branch_status()
+
+ async def get_flatpak_apps(self):
+ return self.flatpak_profile_service.get_apps()
+
+ async def enable_flatpak_app(self, flatpak_app_id: str):
+ return self.flatpak_profile_service.enable_app(flatpak_app_id)
+
+ async def update_flatpak_config(self, flatpak_app_id: str, config: Dict[str, Any]):
+ return self.flatpak_profile_service.update_config(flatpak_app_id, config)
+
+ async def get_flatpak_workaround_state(self, flatpak_app_id: str):
+ return self.flatpak_profile_service.get_workaround_state(flatpak_app_id)
+
+ async def set_flatpak_workaround_state(self, flatpak_app_id: str, state: Dict[str, Any]):
+ return self.flatpak_profile_service.set_workaround_state(flatpak_app_id, state)
+
+ async def remove_flatpak_app(self, flatpak_app_id: str):
+ return self.flatpak_profile_service.remove_app(flatpak_app_id)
+
+ async def get_running_flatpak_apps(self):
+ return self.flatpak_profile_service.get_running_apps()
- async def check_flatpak_extension_status(self) -> Dict[str, Any]:
- """Check status of lsfg-vk Flatpak runtime extensions
-
- Returns:
- FlatpakExtensionStatus dict with installation status for both runtime versions
- """
- return self.flatpak_service.get_extension_status()
-
- async def install_flatpak_extension(self, version: str) -> Dict[str, Any]:
- """Install lsfg-vk Flatpak runtime extension
-
- Args:
- version: Runtime version to install ("23.08" or "24.08")
-
- Returns:
- BaseResponse dict with success status and message/error
- """
- return self.flatpak_service.install_extension(version)
-
- async def uninstall_flatpak_extension(self, version: str) -> Dict[str, Any]:
- """Uninstall lsfg-vk Flatpak runtime extension
-
- Args:
- version: Runtime version to uninstall ("23.08" or "24.08")
-
- Returns:
- BaseResponse dict with success status and message/error
- """
- return self.flatpak_service.uninstall_extension(version)
-
- async def get_flatpak_apps(self) -> Dict[str, Any]:
- """Get list of installed Flatpak apps and their lsfg-vk override status
-
- Returns:
- FlatpakAppInfo dict with apps list and override status
- """
- return self.flatpak_service.get_flatpak_apps()
-
- async def set_flatpak_app_override(self, app_id: str) -> Dict[str, Any]:
- """Set lsfg-vk overrides for a Flatpak app
-
- Args:
- app_id: Flatpak application ID
-
- Returns:
- FlatpakOverrideResponse dict with operation result
- """
- return self.flatpak_service.set_app_override(app_id)
-
- async def remove_flatpak_app_override(self, app_id: str) -> Dict[str, Any]:
- """Remove lsfg-vk overrides for a Flatpak app
-
- Args:
- app_id: Flatpak application ID
-
- Returns:
- FlatpakOverrideResponse dict with operation result
- """
- return self.flatpak_service.remove_app_override(app_id)
-
async def _main(self):
- """
- Main entry point for the plugin.
-
- This method is called by Decky Loader when the plugin is loaded.
- Any initialization code should go here.
- """
+ repair = self.wrapper_service.repair()
+ if not repair.get("success"):
+ decky.logger.error(f"Could not repair lsfg workaround wrapper: {repair.get('error')}")
decky.logger.info("decky-lsfg-vk plugin loaded")
async def _unload(self):
- """
- Cleanup tasks when the plugin is unloaded.
-
- This method is called by Decky Loader when the plugin is being unloaded.
- Any cleanup code should go here.
- """
decky.logger.info("decky-lsfg-vk plugin unloaded")
async def _uninstall(self):
- """
- Called when the plugin is uninstalled.
-
- This method is called by Decky Loader when the plugin is being uninstalled.
- Performs cleanup of plugin files and flatpak extensions.
- """
decky.logger.info("decky-lsfg-vk plugin being uninstalled")
-
- # Clean up lsfg-vk files when the plugin is uninstalled
- self.installation_service.cleanup_on_uninstall()
-
- # Also clean up flatpak extensions if they are installed
try:
- decky.logger.info("Checking for flatpak extensions to uninstall")
-
- extension_status = self.flatpak_service.get_extension_status()
-
- if extension_status.get("success"):
- if extension_status.get("installed_23_08"):
- decky.logger.info("Uninstalling lsfg-vk flatpak runtime 23.08")
- result = self.flatpak_service.uninstall_extension("23.08")
- if result.get("success"):
- decky.logger.info("Successfully uninstalled flatpak runtime 23.08")
- else:
- decky.logger.warning(f"Failed to uninstall flatpak runtime 23.08: {result.get('error')}")
-
- if extension_status.get("installed_24_08"):
- decky.logger.info("Uninstalling lsfg-vk flatpak runtime 24.08")
- result = self.flatpak_service.uninstall_extension("24.08")
- if result.get("success"):
- decky.logger.info("Successfully uninstalled flatpak runtime 24.08")
- else:
- decky.logger.warning(f"Failed to uninstall flatpak runtime 24.08: {result.get('error')}")
-
- decky.logger.info("Flatpak extension cleanup completed")
- else:
- decky.logger.info(f"Could not check flatpak status for cleanup: {extension_status.get('error')}")
-
- except Exception as e:
- decky.logger.error(f"Error during flatpak cleanup: {e}")
-
+ error = self._cleanup_runtime_state(preserve_wrapper=True)
+ if error:
+ decky.logger.warning(f"Preserving lsfg-vk files because uninstall cleanup failed: {error}")
+ return
+ except Exception as error:
+ decky.logger.error(f"Error during lsfg-vk cleanup: {error}")
+ return
+ self.installation_service.cleanup_on_uninstall()
decky.logger.info("decky-lsfg-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.
- """
decky.logger.info("Running decky-lsfg-vk plugin migrations")
-
- decky.migrate_logs(os.path.join(decky.DECKY_USER_HOME,
- ".config", "decky-lossless-scaling-vk", "lossless-scaling-vk.log"))
-
+ decky.migrate_logs(os.path.join(
+ decky.DECKY_USER_HOME,
+ ".config",
+ "decky-lossless-scaling-vk",
+ "lossless-scaling-vk.log",
+ ))
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"))
-
+ os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk"),
+ )
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"))
-
+ os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk"),
+ )
decky.logger.info("decky-lsfg-vk plugin migrations completed")
diff --git a/py_modules/lsfg_vk/runtime_service.py b/py_modules/lsfg_vk/runtime_service.py
new file mode 100644
index 0000000..8785522
--- /dev/null
+++ b/py_modules/lsfg_vk/runtime_service.py
@@ -0,0 +1,120 @@
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Any, Sequence
+
+from .base_service import BaseService
+from .constants import CLI_FILENAME
+
+
+class RuntimeService(BaseService):
+ COMMAND_TIMEOUT_SECONDS = 10
+ MAX_OUTPUT_LENGTH = 12_000
+ DLL_MISSING_MARKER = "! The DLL file does not exist:"
+ DLL_NONE_MARKER = "DLL override: (none)"
+
+ def __init__(self, logger=None):
+ super().__init__(logger)
+ self.cli_path = self.local_bin_dir / CLI_FILENAME
+
+ def _environment(self) -> dict[str, str]:
+ environment = os.environ.copy()
+ environment.update(
+ HOME=str(self.user_home),
+ XDG_CONFIG_HOME=str(self.user_home / ".config"),
+ )
+ for name in ("LSFGVK_CONFIG", "LSFGVK_ENV"):
+ environment.pop(name, None)
+ return environment
+
+ @classmethod
+ def _output(cls, stdout: Any, stderr: Any) -> str:
+ values = []
+ for value in (stdout, stderr):
+ if isinstance(value, bytes):
+ value = value.decode("utf-8", errors="replace")
+ if value:
+ values.append(str(value).strip())
+ output = "\n".join(value for value in values if value)
+ return output if len(output) <= cls.MAX_OUTPUT_LENGTH else output[: cls.MAX_OUTPUT_LENGTH]
+
+ def _run(self, arguments: Sequence[str]) -> tuple[int, str]:
+ if not self.cli_path.is_file() or not os.access(self.cli_path, os.X_OK):
+ raise FileNotFoundError(f"{CLI_FILENAME} is not installed at {self.cli_path}")
+ result = subprocess.run(
+ [str(self.cli_path), *arguments],
+ cwd=str(self.user_home),
+ env=self._environment(),
+ capture_output=True,
+ text=True,
+ timeout=self.COMMAND_TIMEOUT_SECONDS,
+ check=False,
+ )
+ return result.returncode, self._output(result.stdout, result.stderr)
+
+ def validate_config_content(self, content: str) -> None:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ temporary_path: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ dir=self.config_dir,
+ prefix=".conf.toml.",
+ suffix=".tmp",
+ delete=False,
+ ) as temporary_file:
+ temporary_path = Path(temporary_file.name)
+ temporary_file.write(content)
+ temporary_file.flush()
+ os.fsync(temporary_file.fileno())
+
+ returncode, output = self._run(("validate", "--config", str(temporary_path)))
+ if returncode != 0:
+ raise ValueError(output or "lsfg-vk rejected the generated configuration")
+ finally:
+ if temporary_path is not None:
+ temporary_path.unlink(missing_ok=True)
+
+ def is_healthy(self) -> bool:
+ returncode, output = self._run(("healthcheck",))
+ return returncode == 0 and "Healthcheck found issues" not in output
+
+ def check_lossless_scaling(self) -> dict[str, Any]:
+ """Report Lossless Scaling state from lsfg-vk's own validator."""
+ if not self.config_file_path.is_file():
+ return {
+ "installed": False,
+ "status": "lsfg-vk configuration is not installed",
+ }
+
+ try:
+ returncode, output = self._run(
+ ("validate", "--config", str(self.config_file_path), "--print")
+ )
+ except Exception as error:
+ return {"installed": False, "status": str(error)}
+
+ if returncode != 0:
+ return {
+ "installed": False,
+ "status": output or "lsfg-vk could not validate its configuration",
+ }
+
+ if self.DLL_MISSING_MARKER in output:
+ return {
+ "installed": False,
+ "status": "Lossless Scaling's lsfg-vk.dll was not found",
+ }
+
+ if self.DLL_NONE_MARKER in output:
+ return {
+ "installed": False,
+ "status": "Lossless Scaling's lsfg-vk.dll is not configured",
+ }
+
+ return {
+ "installed": True,
+ "status": "Lossless Scaling detected by lsfg-vk",
+ }
diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py
new file mode 100644
index 0000000..93f2593
--- /dev/null
+++ b/py_modules/lsfg_vk/steam_service.py
@@ -0,0 +1,290 @@
+import re
+from pathlib import Path
+from typing import Dict, Optional, Tuple
+
+from .base_service import BaseService
+from .constants import (
+ STEAM_LOSSLESS_SCALING_APP_ID,
+ STEAM_LOSSLESS_SCALING_BRANCH,
+)
+
+
+class SteamService(BaseService):
+ DEFAULT_BRANCH = "public"
+ MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf"
+ # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG.
+ GAME_SELECTOR_EXCLUDED_APPIDS = {
+ "858280", # Proton 3.7
+ "961940", # Proton 3.16
+ "1054830", # Proton 4.2
+ "1113280", # Proton 4.11
+ "1245040", # Proton 5.0
+ "1420170", # Proton 5.13
+ "1493710", # Proton Experimental
+ "1580130", # Proton 6.3
+ "1887720", # Proton 7
+ "2180100", # Proton Hotfix
+ "228980", # Steamworks Common Redistributables
+ "2348590", # Proton 8
+ "2805730", # Proton 9
+ "3029110", # Lepton
+ "3127680", # fex
+ "3658110", # Proton 10
+ "4183110", # Steam Linux Runtime 4.0
+ "4185400", # Steam Linux Runtime 4.0 for arm64
+ "4427310", # Proton Experimental (ARM64)
+ "4628710", # Proton 11 / Proton Next
+ "4628740", # Proton 11 (ARM64)
+ "4690330", # Legacy Steam Runtime
+ "993090", # Lossless Scaling
+ "1070560", # Steam Linux Runtime 1.0
+ "1391110", # Steam Linux Runtime 2.0
+ "1628350", # Steam Linux Runtime 3.0
+ }
+
+ def _steam_roots(self):
+ seen = set()
+ for candidate in (
+ self.user_home / ".local/share/Steam",
+ self.user_home / ".steam/steam",
+ self.user_home / ".steam/root",
+ self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
+ ):
+ yield from self._unique_existing_root(candidate, seen)
+
+ def _steam_library_roots(self):
+ seen = set()
+ for root in self._steam_roots():
+ yield from self._unique_existing_root(root, seen)
+ for library_file in (
+ root / "steamapps/libraryfolders.vdf",
+ root / "config/libraryfolders.vdf",
+ ):
+ try:
+ content = library_file.read_text(encoding="utf-8")
+ except OSError:
+ continue
+ for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content):
+ path = raw_path.replace(r'\"', '"').replace(r'\\', '\\')
+ yield from self._unique_existing_root(Path(path), seen)
+
+ @staticmethod
+ def _unique_existing_root(path: Path, seen: set[str]):
+ if not path.exists():
+ return
+ try:
+ resolved = str(path.resolve())
+ except OSError:
+ resolved = str(path)
+ if resolved not in seen:
+ seen.add(resolved)
+ yield path
+
+ @staticmethod
+ def _read_shortcuts(data: bytes) -> Dict[str, object]:
+ def read_string(offset: int) -> Tuple[str, int]:
+ end = data.index(b"\0", offset)
+ return data[offset:end].decode("utf-8", errors="replace"), end + 1
+
+ def read_object(offset: int = 0) -> Tuple[Dict[str, object], int]:
+ values = {}
+ while offset < len(data):
+ value_type, offset = data[offset], offset + 1
+ if value_type == 8:
+ return values, offset
+ key, offset = read_string(offset)
+ if value_type == 0:
+ value, offset = read_object(offset)
+ elif value_type == 1:
+ value, offset = read_string(offset)
+ elif value_type in (2, 7):
+ width = 4 if value_type == 2 else 8
+ if offset + width > len(data):
+ raise ValueError("truncated binary VDF integer")
+ value = int.from_bytes(data[offset:offset + width], "little", signed=True)
+ offset += width
+ else:
+ raise ValueError(f"unsupported binary VDF type {value_type}")
+ values[key] = value
+ raise ValueError("unterminated binary VDF object")
+
+ values, offset = read_object()
+ if offset != len(data):
+ raise ValueError("trailing binary VDF data")
+ return values
+
+ @staticmethod
+ def _shortcut_game(shortcut: object) -> Optional[Dict[str, object]]:
+ if not isinstance(shortcut, dict):
+ return None
+ appid = shortcut.get("appid")
+ name = shortcut.get("AppName") or shortcut.get("appname")
+ if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name:
+ return None
+ game = {
+ "appid": str(appid & 0xFFFFFFFF),
+ "name": name,
+ "nonSteam": True,
+ }
+ executable = shortcut.get("Exe") or shortcut.get("exe")
+ if isinstance(executable, str) and executable.strip().strip('"') in {"flatpak", "/usr/bin/flatpak"}:
+ game["isFlatpakShortcut"] = True
+ return game
+
+ def _shortcut_games(self):
+ games = {}
+ for root in self._steam_roots():
+ for path in sorted((root / "userdata").glob("*/config/shortcuts.vdf")):
+ try:
+ shortcuts = self._read_shortcuts(path.read_bytes()).get("shortcuts", {})
+ except (OSError, ValueError):
+ continue
+ if not isinstance(shortcuts, dict):
+ continue
+ for shortcut in shortcuts.values():
+ game = self._shortcut_game(shortcut)
+ if game and game["appid"] not in self.GAME_SELECTOR_EXCLUDED_APPIDS:
+ games.setdefault(game["appid"], game)
+ return list(games.values())
+
+ def _manifest_path(self) -> Optional[Path]:
+ return next((
+ path
+ for root in self._steam_library_roots()
+ if (path := root / "steamapps" / self.MANIFEST_FILENAME).is_file()
+ ), None)
+
+ @staticmethod
+ def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]:
+ section = re.search(
+ rf'(?m)^(?P<indent>[ \t]*)"{re.escape(section_name)}"[ \t\r\n]*\{{',
+ content,
+ )
+ if section is None:
+ return None
+ depth = 1
+ in_string = False
+ escaped = False
+ for index in range(section.end(), len(content)):
+ character = content[index]
+ if in_string:
+ if escaped:
+ escaped = False
+ elif character == "\\":
+ escaped = True
+ elif character == '"':
+ in_string = False
+ elif character == '"':
+ in_string = True
+ elif character == "{":
+ depth += 1
+ elif character == "}":
+ depth -= 1
+ if depth == 0:
+ return section.end(), index, section.group("indent")
+ return None
+
+ @classmethod
+ def _section_value(cls, content: str, section_name: str, key: str) -> Optional[str]:
+ bounds = cls._section_bounds(content, section_name)
+ if bounds is None:
+ return None
+ start, end, _ = bounds
+ pattern = re.compile(r'(?m)^[ \t]*"(?P<key>[^"]+)"[ \t]+"(?P<value>(?:\\.|[^"\\])*)"')
+ return next((
+ match.group("value")
+ for match in pattern.finditer(content, start, end)
+ if match.group("key") == key
+ ), None)
+
+ @classmethod
+ def _branch_or_default(cls, branch: Optional[str]) -> str:
+ return branch or cls.DEFAULT_BRANCH
+
+ def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]:
+ selected = self._branch_or_default(self._section_value(content, "UserConfig", "BetaKey"))
+ current = self._branch_or_default(
+ self._section_value(content, "MountedConfig", "BetaKey")
+ or self._section_value(content, "UserConfig", "BetaKey")
+ )
+ needs_switch = selected != STEAM_LOSSLESS_SCALING_BRANCH or current != STEAM_LOSSLESS_SCALING_BRANCH
+ return {
+ "installed": True,
+ "manifest_path": str(manifest_path),
+ "selected_branch": selected,
+ "current_branch": current,
+ "target_branch": STEAM_LOSSLESS_SCALING_BRANCH,
+ "needs_switch": needs_switch,
+ "restart_required": selected == STEAM_LOSSLESS_SCALING_BRANCH and current != STEAM_LOSSLESS_SCALING_BRANCH,
+ }
+
+ @staticmethod
+ def _missing_branch_fields() -> Dict[str, object]:
+ return {
+ "installed": False,
+ "manifest_path": None,
+ "selected_branch": None,
+ "current_branch": None,
+ "target_branch": STEAM_LOSSLESS_SCALING_BRANCH,
+ "needs_switch": False,
+ "restart_required": False,
+ }
+
+ def find_lsfg_vk_dll(self) -> Optional[str]:
+ if self.get_branch_status().get("needs_switch"):
+ return None
+ return next((
+ str(path)
+ for root in self._steam_library_roots()
+ if (path := root / "steamapps/common/Lossless Scaling/lsfg-vk.dll").is_file()
+ ), None)
+
+ def get_branch_status(self) -> Dict[str, object]:
+ try:
+ manifest = self._manifest_path()
+ if manifest is None:
+ return self._success_response(
+ dict,
+ "Lossless Scaling is not installed through Steam",
+ **self._missing_branch_fields(),
+ )
+ fields = self._status_fields(manifest, manifest.read_text(encoding="utf-8"))
+ message = (
+ "Lossless Scaling is using the lsfg-vk Steam branch"
+ if not fields["needs_switch"]
+ else "lsfg-vk is selected; restart Steam to finish the branch switch"
+ if fields["restart_required"]
+ else "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas"
+ )
+ return self._success_response(dict, message, **fields)
+ except Exception as error:
+ return self._error_response(dict, str(error), **self._missing_branch_fields())
+
+ def get_installed_games(self) -> Dict[str, object]:
+ try:
+ games: Dict[str, Dict[str, object]] = {}
+ for root in self._steam_library_roots():
+ for manifest in (root / "steamapps").glob("appmanifest_*.acf"):
+ match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name)
+ if not match:
+ continue
+ try:
+ content = manifest.read_text(encoding="utf-8")
+ except OSError:
+ continue
+ appid = match.group(1)
+ if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS:
+ continue
+ games[appid] = {
+ "appid": appid,
+ "name": self._section_value(content, "AppState", "name") or f"App {appid}",
+ "nonSteam": False,
+ }
+ for game in self._shortcut_games():
+ games.setdefault(str(game["appid"]), game)
+ return self._success_response(
+ dict,
+ games=sorted(games.values(), key=lambda game: str(game["name"]).lower()),
+ )
+ except Exception as error:
+ return self._error_response(dict, str(error), games=[])
diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py
index 7b7ca2b..ce541ec 100644
--- a/py_modules/lsfg_vk/types.py
+++ b/py_modules/lsfg_vk/types.py
@@ -1,83 +1,31 @@
-"""
-Type definitions for the lsfg-vk plugin responses.
-"""
+from typing import List, Optional, TypedDict
-from typing import TypedDict, Optional, List, Dict, Any
-from .config_schema import ConfigurationData
-
-class BaseResponse(TypedDict):
- """Base response structure"""
+class InstallationResponse(TypedDict):
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
+class UninstallationResponse(InstallationResponse):
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 ConfigurationResponse(BaseResponse):
- """Response for configuration operations"""
- config: Optional[ConfigurationData]
- message: Optional[str]
- error: Optional[str]
-
-
-class ProfileConfig(TypedDict):
- """Configuration for a single profile"""
- exe: str
- config: ConfigurationData
-
-
-class ProfilesResponse(BaseResponse):
- """Response for profile operations"""
- profiles: Optional[List[str]]
- current_profile: Optional[str]
- message: Optional[str]
+ lossless_scaling_installed: bool
+ lossless_scaling_status: str
error: Optional[str]
-class ProfileResponse(BaseResponse):
- """Response for single profile operations"""
- profile_name: Optional[str]
- message: Optional[str]
+class SteamBranchStatusResponse(TypedDict):
+ success: bool
+ message: str
error: Optional[str]
+ installed: bool
+ manifest_path: Optional[str]
+ selected_branch: Optional[str]
+ current_branch: Optional[str]
+ target_branch: str
+ needs_switch: bool
+ restart_required: bool
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
new file mode 100644
index 0000000..30c2323
--- /dev/null
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -0,0 +1,464 @@
+from __future__ import annotations
+
+import json
+import re
+import shlex
+import threading
+from typing import Any, Dict, Optional, Tuple
+
+from .base_service import BaseService
+from .constants import WRAPPER_FILENAME
+
+
+class WrapperService(BaseService):
+ LEGACY_FORMAT_VERSION = 1
+ FORMAT_VERSION = 2
+ LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1"
+ MARKER = "# lsfg-vk-wrapper-format: 2"
+ WRAPPER_TOKEN = "~/.lsfg"
+ STATE_FIELDS = (
+ "dxvkFrameRate",
+ "disableGamescopeWsi",
+ "disableHdr",
+ "disableSteamdeckMode",
+ "disableVkbasalt",
+ "enableZink",
+ )
+ BOOLEAN_FIELDS = STATE_FIELDS[1:]
+ MANAGED_ENV_KEYS = (
+ "ENABLE_GAMESCOPE_WSI",
+ "DISABLE_GAMESCOPE_WSI",
+ "DXVK_HDR",
+ "SteamDeck",
+ "DISABLE_LSFGVK",
+ "DISABLE_LSFG",
+ "DISABLE_VKBASALT",
+ "ENABLE_VKBASALT",
+ "MESA_LOADER_DRIVER_OVERRIDE",
+ "__GLX_VENDOR_LIBRARY_NAME",
+ "GALLIUM_DRIVER",
+ "DXVK_FRAME_RATE",
+ )
+
+ def __init__(self, logger=None):
+ super().__init__(logger)
+ self.sidecar_path = self.config_dir / "workarounds.json"
+ self.wrapper_path = self.user_home / WRAPPER_FILENAME
+ self._lock = threading.RLock()
+
+ @classmethod
+ def default_state(cls) -> Dict[str, Any]:
+ return {
+ "dxvkFrameRate": 0,
+ "disableGamescopeWsi": True,
+ "disableHdr": True,
+ "disableSteamdeckMode": False,
+ "disableVkbasalt": False,
+ "enableZink": False,
+ }
+
+ @staticmethod
+ def _valid_appid(appid: Any) -> str:
+ value = str(appid)
+ if not re.fullmatch(r"[1-9][0-9]*", value):
+ raise ValueError("Invalid Steam App ID")
+ return value
+
+ @classmethod
+ def _validate_state(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise ValueError("Workaround state must be an object")
+ missing = [field for field in cls.STATE_FIELDS if field not in raw]
+ if missing:
+ raise ValueError("Workaround state is missing: " + ", ".join(missing))
+ state = {field: raw[field] for field in cls.STATE_FIELDS}
+ frame_rate = state["dxvkFrameRate"]
+ if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60:
+ raise ValueError("Base FPS Cap must be an integer from 0 to 60")
+ for field in cls.BOOLEAN_FIELDS:
+ if type(state[field]) is not bool:
+ raise ValueError(f"{field} must be a boolean")
+ return state
+
+ @classmethod
+ def _validate_entry(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise ValueError("Workaround AppID entry must be an object")
+ entry = {
+ "state": cls._validate_state(raw.get("state")),
+ "command_token_added": raw.get("command_token_added", False),
+ "non_steam": raw.get("non_steam", False),
+ }
+ if type(entry["command_token_added"]) is not bool:
+ raise ValueError("command_token_added must be a boolean")
+ if type(entry["non_steam"]) is not bool:
+ raise ValueError("non_steam must be a boolean")
+ return entry
+
+ @classmethod
+ def _validate_document(cls, raw: Any) -> Dict[str, Any]:
+ if not isinstance(raw, dict) or raw.get("version") not in (
+ cls.LEGACY_FORMAT_VERSION,
+ cls.FORMAT_VERSION,
+ ):
+ raise ValueError("Unsupported lsfg-vk workaround state version")
+ apps = raw.get("apps")
+ if not isinstance(apps, dict):
+ raise ValueError("Workaround state apps must be an object")
+ validated_apps: Dict[str, Any] = {}
+ for appid, entry in apps.items():
+ normalized = cls._valid_appid(appid)
+ if normalized != str(appid):
+ raise ValueError("Workaround AppIDs must not contain leading zeroes")
+ validated_apps[normalized] = cls._validate_entry(entry)
+ return {"version": cls.FORMAT_VERSION, "apps": validated_apps}
+
+ def _empty_document(self) -> Dict[str, Any]:
+ return {"version": self.FORMAT_VERSION, "apps": {}}
+
+ def _read_document(self) -> Tuple[Dict[str, Any], bool, Optional[str]]:
+ if not self.sidecar_path.exists():
+ return self._empty_document(), False, None
+ if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file():
+ raise RuntimeError("Workaround state path is not a regular file")
+ try:
+ content = self.sidecar_path.read_text(encoding="utf-8")
+ raw = json.loads(content)
+ except (OSError, json.JSONDecodeError) as error:
+ raise RuntimeError(f"Could not read workaround state: {error}") from error
+ return self._validate_document(raw), True, content
+
+ def _wrapper_marker(self) -> bool:
+ if self.wrapper_path.is_symlink() or not self.wrapper_path.exists():
+ return False
+ if not self.wrapper_path.is_file():
+ raise RuntimeError("lsfg wrapper path is not a regular file")
+ try:
+ prefix = "\n".join(self.wrapper_path.read_text(encoding="utf-8").splitlines()[:8])
+ except OSError as error:
+ raise RuntimeError(f"Could not read lsfg wrapper: {error}") from error
+ return self.MARKER in prefix or self.LEGACY_MARKER in prefix
+
+ def _assert_wrapper_owned_or_absent(self) -> bool:
+ if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink():
+ return False
+ if self.wrapper_path.is_symlink() or not self._wrapper_marker():
+ raise RuntimeError(f"Refusing to replace unowned wrapper at {self.wrapper_path}")
+ return True
+
+ @staticmethod
+ def _shell(value: str) -> str:
+ return shlex.quote(value)
+
+ def _state_lines(self, state: Dict[str, Any]) -> list[str]:
+ lines = [" unset " + " ".join(self.MANAGED_ENV_KEYS)]
+ lines.extend([
+ ' SteamAppId="$appid"',
+ " export SteamAppId",
+ f" LSFGVK_CONFIG={self._shell(str(self.config_file_path))}",
+ " export LSFGVK_CONFIG",
+ ])
+ if state["disableGamescopeWsi"]:
+ lines.extend([" ENABLE_GAMESCOPE_WSI=0", " export ENABLE_GAMESCOPE_WSI"])
+ if state["disableHdr"]:
+ lines.extend([" DXVK_HDR=0", " export DXVK_HDR"])
+ if state["disableSteamdeckMode"]:
+ lines.extend([" SteamDeck=0", " export SteamDeck"])
+ if state["disableVkbasalt"]:
+ lines.extend([" DISABLE_VKBASALT=1", " export DISABLE_VKBASALT"])
+ if state["enableZink"]:
+ lines.extend([
+ " __GLX_VENDOR_LIBRARY_NAME=mesa",
+ " export __GLX_VENDOR_LIBRARY_NAME",
+ " MESA_LOADER_DRIVER_OVERRIDE=zink",
+ " export MESA_LOADER_DRIVER_OVERRIDE",
+ " GALLIUM_DRIVER=zink",
+ " export GALLIUM_DRIVER",
+ ])
+ frame_rate = state["dxvkFrameRate"]
+ if frame_rate > 0:
+ lines.extend([
+ ' if [ -n "${DXVK_CONFIG+x}" ]; then',
+ ' if [ -n "${DXVK_CONFIG}" ]; then',
+ f' DXVK_CONFIG="${{DXVK_CONFIG}}; dxvk.maxFrameRate = {frame_rate}"',
+ " else",
+ f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"',
+ " fi",
+ " else",
+ f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"',
+ " fi",
+ " export DXVK_CONFIG",
+ ])
+ return lines
+
+ def _render_wrapper(self, document: Dict[str, Any]) -> str:
+ lines = [
+ "#!/bin/sh",
+ self.MARKER,
+ "",
+ "appid=",
+ 'case "${SteamAppId-}" in',
+ " ''|*[!0-9]*) ;;",
+ ' *) appid="${SteamAppId}" ;;',
+ "esac",
+ 'if [ -z "$appid" ]; then',
+ ' case "${SteamGameId-}" in',
+ " ''|*[!0-9]*) ;;",
+ ' *) appid="${SteamGameId}" ;;',
+ " esac",
+ "fi",
+ 'if [ -z "$appid" ]; then',
+ ' case "${STEAM_COMPAT_APP_ID-}" in',
+ " ''|*[!0-9]*) ;;",
+ ' *) appid="${STEAM_COMPAT_APP_ID}" ;;',
+ " esac",
+ "fi",
+ 'case "$appid" in',
+ ]
+ for appid in sorted(document["apps"], key=lambda value: int(value)):
+ lines.append(f" {appid})")
+ lines.extend(self._state_lines(document["apps"][appid]["state"]))
+ lines.append(" ;;")
+ lines.extend([
+ "esac",
+ 'exec "$@"',
+ "",
+ ])
+ return "\n".join(lines)
+
+ def _write_document(self, document: Dict[str, Any]) -> None:
+ self._write_file(
+ self.sidecar_path,
+ json.dumps(document, indent=2, sort_keys=True) + "\n",
+ 0o644,
+ )
+
+ def _write_pair(self, document: Dict[str, Any]) -> None:
+ old_sidecar_exists = self.sidecar_path.exists()
+ old_sidecar = self.sidecar_path.read_text(encoding="utf-8") if old_sidecar_exists else None
+ old_wrapper_exists = self.wrapper_path.exists() or self.wrapper_path.is_symlink()
+ old_wrapper = self.wrapper_path.read_text(encoding="utf-8") if old_wrapper_exists and not self.wrapper_path.is_symlink() else None
+ try:
+ self._write_document(document)
+ self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755)
+ except Exception:
+ try:
+ if old_sidecar_exists and old_sidecar is not None:
+ self._write_file(self.sidecar_path, old_sidecar, 0o644)
+ elif self.sidecar_path.exists():
+ self.sidecar_path.unlink()
+ if old_wrapper_exists and old_wrapper is not None:
+ self._write_file(self.wrapper_path, old_wrapper, 0o755)
+ elif not old_wrapper_exists and self.wrapper_path.exists():
+ self.wrapper_path.unlink()
+ except Exception as rollback_error:
+ self.log.error(f"Could not roll back workaround wrapper update: {rollback_error}")
+ raise
+
+ def _response(self, document: Dict[str, Any], appid: str = "") -> Dict[str, Any]:
+ entry = document["apps"].get(appid)
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "appid": appid or None,
+ "state": dict(entry["state"]) if entry else None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": self._wrapper_marker() if document["apps"] else False,
+ "command_token_added": entry.get("command_token_added", False) if entry else False,
+ "non_steam": entry.get("non_steam", False) if entry else False,
+ }
+
+ def get(self, appid: str) -> Dict[str, Any]:
+ try:
+ normalized = self._valid_appid(appid)
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ return self._response(document, normalized)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "appid": str(appid),
+ "state": None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ "non_steam": False,
+ }
+
+ def set(
+ self,
+ appid: str,
+ state: Dict[str, Any],
+ command_token_added: bool = False,
+ non_steam: bool = False,
+ ) -> Dict[str, Any]:
+ try:
+ normalized = self._valid_appid(appid)
+ validated_state = self._validate_state(state)
+ if type(command_token_added) is not bool:
+ raise ValueError("command_token_added must be a boolean")
+ if type(non_steam) is not bool:
+ raise ValueError("non_steam must be a boolean")
+ with self._lock:
+ self._assert_wrapper_owned_or_absent()
+ document, _, _ = self._read_document()
+ document["apps"][normalized] = {
+ "state": validated_state,
+ "command_token_added": command_token_added,
+ "non_steam": non_steam,
+ }
+ self._write_pair(document)
+ return self._response(document, normalized)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "appid": str(appid),
+ "state": None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ "non_steam": False,
+ }
+
+ def remove(self, appid: str) -> Dict[str, Any]:
+ try:
+ normalized = self._valid_appid(appid)
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ if normalized not in document["apps"]:
+ return self._response(document, normalized)
+ document["apps"].pop(normalized, None)
+ self._write_pair(document)
+ return self._response(document, normalized)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "appid": str(appid),
+ "state": None,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ "non_steam": False,
+ }
+
+ def repair(self) -> Dict[str, Any]:
+ try:
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ if not document["apps"]:
+ return self._response(document)
+ self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755)
+ return self._response(document)
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "wrapper_path": self.WRAPPER_TOKEN,
+ "wrapper_owned": False,
+ }
+
+ def list_apps(self) -> Dict[str, Any]:
+ try:
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ apps = [
+ {
+ "appid": appid,
+ "non_steam": entry.get("non_steam", False),
+ "command_token_added": entry.get("command_token_added", False),
+ }
+ for appid, entry in document["apps"].items()
+ ]
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "apps": apps,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "apps": [],
+ "wrapper_path": self.WRAPPER_TOKEN,
+ }
+
+ def purge(self) -> Dict[str, Any]:
+ """Remove the plugin-owned wrapper and its sidecar during uninstall.
+
+ This is deliberately separate from ``remove``: normal profile removal
+ leaves a safe passthrough wrapper for the remaining profiles, while an
+ uninstall should remove the wrapper entirely. Both files are
+ validated before anything is removed so a user's replacement wrapper
+ or damaged state is left untouched.
+ """
+ removed = []
+ try:
+ with self._lock:
+ _document, sidecar_exists, _ = self._read_document()
+ wrapper_owned = self._assert_wrapper_owned_or_absent()
+ if wrapper_owned:
+ self.wrapper_path.unlink()
+ removed.append(str(self.wrapper_path))
+ if sidecar_exists:
+ self.sidecar_path.unlink()
+ removed.append(str(self.sidecar_path))
+ return {
+ "success": True,
+ "message": "Removed lsfg-vk workaround state",
+ "error": None,
+ "removed_files": removed,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "removed_files": removed or None,
+ }
+
+ def neutralize(self) -> Dict[str, Any]:
+ """Leave an owned passthrough wrapper for Decky-level uninstall.
+
+ Decky can remove this plugin without giving the frontend a chance to
+ clean Steam launch options first. Keeping a dependency-free wrapper
+ prevents those options from turning into a broken executable path.
+ """
+ try:
+ with self._lock:
+ _document, sidecar_exists, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ self._write_file(
+ self.wrapper_path,
+ "#!/bin/sh\n"
+ f"{self.MARKER}\n"
+ "# Safe passthrough retained for existing Steam launch options.\n"
+ "exec \"$@\"\n",
+ 0o755,
+ )
+ if sidecar_exists:
+ self.sidecar_path.unlink()
+ return {
+ "success": True,
+ "message": "Replaced lsfg-vk wrapper with a safe passthrough",
+ "error": None,
+ "removed_files": [str(self.sidecar_path)] if sidecar_exists else [],
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "removed_files": None,
+ }