diff options
24 files changed, 503 insertions, 2408 deletions
@@ -33,18 +33,18 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer -4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, FP16 acceleration, performance mode, and launch workarounds +4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings 5. **Apply launch option** to games you want to use frame generation with: - Add `~/lsfg %command%` to your game's launch options in Steam Properties - Or use the "Launch Option Clipboard" button in the plugin to copy the command -6. **Launch your game** - frame generation will activate automatically using your plugin configuration +6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile ## Configuration Options -The plugin provides several configuration options to optimize frame generation for your games: +The plugin edits upstream lsfg-vk v2 profiles directly. Unassigned games leave the layer unloaded. ### Core Settings -- **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation +- **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) - **Performance Mode**: Uses a lighter processing model - recommended for most games - **FP16 Acceleration**: Use half-precision acceleration when supported @@ -79,8 +79,9 @@ The plugin: - **Flow Scale**: Adjust motion estimation quality vs performance - **Performance Mode**: Use lighter processing for better performance - **FP16 Acceleration**: Use half-precision acceleration when supported - - **Experimental Features**: Override present mode and set FPS limits -- **Hot-reloading**: Multiplier, flow scale, and performance mode changes apply without restarting games +- **Present Mode**: Override FIFO/VSync behavior +- **Swapchain Image Count**: Preserve the application's swapchain image count +- **Hot-reloading**: Upstream reloads settings for the active profile; profile assignment itself applies on the next launch - Easy uninstallation that removes all installed files when no longer needed ## Credits @@ -1,11 +1,8 @@ default: - echo "Available recipes: build, test, clean, generate-schema" - -generate-schema: - python3 scripts/generate_ts_schema.py + echo "Available recipes: build, test, clean" build: - python3 scripts/generate_ts_schema.py && sudo rm -rf node_modules && .vscode/build.sh + .vscode/build.sh test: scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop @@ -18,4 +15,4 @@ cef: clean: rm -rf node_modules dist - sudo rm -rf /tmp/decky
\ No newline at end of file + sudo rm -rf /tmp/decky diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index e39be54..4ae1f4c 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,49 +1,37 @@ +"""Small adapter for the upstream lsfg-vk v2 configuration format.""" + import json -import re -import shlex import sys import tomllib -from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, TypedDict, Union, cast +from typing import Any, Dict, TypedDict, cast sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_defaults -from .config_schema_generated import ConfigurationData, get_script_parsing_logic - - -@dataclass -class ConfigField: - name: str - field_type: ConfigFieldType - default: Union[bool, int, float, str] - description: str - -CONFIG_SCHEMA: Dict[str, ConfigField] = { - name: ConfigField( - name=definition["name"], - field_type=ConfigFieldType(definition["fieldType"]), - default=definition["default"], - description=definition["description"], - ) - for name, definition in CONFIG_SCHEMA_DEF.items() -} - -SCRIPT_ONLY_FIELDS = { - name - for name, definition in CONFIG_SCHEMA_DEF.items() - if definition["location"] == "script" -} -DEFAULT_PROFILE_NAME = "decky-lsfg-vk" +DEFAULT_PROFILE_NAME = "default" +ConfigurationData = Dict[str, Any] class ProfileData(TypedDict): + # Internal compatibility field; the public API no longer exposes a + # currently selected profile. current_profile: str 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": 1.0, + "performance_mode": False, + "override_present_mode": True, + "preserve_swapchain_image_count": False, +} +GLOBAL_DEFAULTS: Dict[str, Any] = {"dll": "", "no_fp16": False} + + def _toml_value(value: Any) -> str: if isinstance(value, bool): return str(value).lower() @@ -54,41 +42,52 @@ def _toml_value(value: Any) -> str: return str(value) +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: @staticmethod - def get_defaults() -> ConfigurationData: - return cast(ConfigurationData, dict(get_defaults())) + def get_defaults() -> Dict[str, Any]: + return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS} @staticmethod def get_field_names() -> list[str]: - return list(CONFIG_SCHEMA) + return list(ConfigurationManager.get_defaults()) @staticmethod - def get_field_types() -> Dict[str, ConfigFieldType]: - return {name: field.field_type for name, field in CONFIG_SCHEMA.items()} + def get_field_types() -> Dict[str, str]: + return { + "dll": "string", "no_fp16": "boolean", "active_in": "array", + "pacing_mode": "string", "multiplier": "integer", "flow_scale": "float", + "performance_mode": "boolean", "override_present_mode": "boolean", + "preserve_swapchain_image_count": "boolean", + } @staticmethod - def validate_config(config: Dict[str, Any]) -> ConfigurationData: - validated: Dict[str, Any] = {} - for name, field in CONFIG_SCHEMA.items(): - value = config.get(name, field.default) - if field.field_type == ConfigFieldType.BOOLEAN: - value = value.lower() in {"true", "1", "yes", "on"} if isinstance(value, str) else bool(value) - elif field.field_type == ConfigFieldType.INTEGER: - value = int(value) - elif field.field_type == ConfigFieldType.FLOAT: - value = float(value) - else: - value = str(value) - validated[name] = value - - if validated["multiplier"] < 1: + 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.get("active_in")) + result["pacing_mode"] = str(result.get("pacing_mode", "vsync")).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") - if not 0.25 <= validated["flow_scale"] <= 1.0: + 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") - if validated["experimental_present_mode"] not in {"fifo", "mailbox"}: - raise ValueError("experimental_present_mode must be fifo or mailbox") - return cast(ConfigurationData, validated) + for name in ("no_fp16", "performance_mode", "override_present_mode", "preserve_swapchain_image_count"): + result[name] = bool(result[name]) + result["dll"] = str(result.get("dll") or "") + return result @staticmethod def _migrate_dll_path(value: Any) -> str: @@ -102,246 +101,81 @@ class ConfigurationManager: @staticmethod def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]: - config: Dict[str, Any] = dict(ConfigurationManager.get_defaults()) - for field in ("multiplier", "flow_scale", "performance_mode"): - if field in profile: - config[field] = profile[field] - config["experimental_present_mode"] = "fifo" if bool(profile.get("override_present_mode", True)) else "mailbox" - config["dll"] = global_config.get("dll", "") - config["no_fp16"] = global_config.get("no_fp16", False) - for field in ("active_in", "pacing", "preserve_swapchain_image_count"): - if field in profile: - config[field] = profile[field] - return {**config, **ConfigurationManager.validate_config(config)} - - @staticmethod - def generate_toml_content(config: ConfigurationData) -> str: - profile_data: ProfileData = { + raw = dict(profile) + if "pacing_mode" not in raw and "pacing" in raw: + raw["pacing_mode"] = raw["pacing"] + if "override_present_mode" not in raw and "experimental_present_mode" in raw: + raw["override_present_mode"] = raw["experimental_present_mode"] == "fifo" + raw["dll"] = global_config.get("dll", "") + raw["no_fp16"] = global_config.get("no_fp16", False) + return ConfigurationManager.validate_config(raw) + + @staticmethod + def generate_toml_content(config: Dict[str, Any]) -> str: + data: ProfileData = { "current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False), - }, + "global_config": {"dll": config.get("dll", ""), "no_fp16": config.get("no_fp16", False)}, } - return ConfigurationManager.generate_toml_content_multi_profile(profile_data) + return ConfigurationManager.generate_toml_content_multi_profile(data) @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: - global_config = profile_data["global_config"] + global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})} lines = ["version = 2", "", "[global]"] - dll = ConfigurationManager._migrate_dll_path(global_config.get("dll", "")) + dll = ConfigurationManager._migrate_dll_path(global_config.get("dll")) if dll: lines.append(f"dll = {_toml_value(dll)}") lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}") - if global_config.get("log_level"): - lines.append(f"log_level = {_toml_value(global_config['log_level'])}") - if global_config.get("log_file"): - lines.append(f"log_file = {_toml_value(global_config['log_file'])}") - - profiles = sorted( - profile_data["profiles"].items(), - key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0]), - ) - for profile_name, raw_config in profiles: - config = ConfigurationManager.validate_config(raw_config) - lines.extend(["", "[[profile]]", f"name = {_toml_value(profile_name)}"]) - active_in = raw_config.get("active_in") - if active_in not in (None, "", []): - lines.append(f"active_in = {_toml_value(active_in)}") - lines.extend( - [ - f"multiplier = {config['multiplier']}", - f"flow_scale = {config['flow_scale']}", - f"performance_mode = {_toml_value(config['performance_mode'])}", - f"pacing = {_toml_value(raw_config.get('pacing', 'vsync'))}", - f"override_present_mode = {_toml_value(config['experimental_present_mode'] == 'fifo')}", - f"preserve_swapchain_image_count = {_toml_value(bool(raw_config.get('preserve_swapchain_image_count', False)))}", - ] - ) + profiles = sorted(profile_data["profiles"].items(), key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0])) + 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" @staticmethod - def _profile_data_from_v1(data: Dict[str, Any]) -> ProfileData: - old_global = dict(data.get("global", {})) - global_config: Dict[str, Any] = { - "dll": ConfigurationManager._migrate_dll_path(old_global.get("dll", "")), - "no_fp16": bool(old_global.get("no_fp16", False)), - } - profiles: Dict[str, Dict[str, Any]] = {} - for game in data.get("game", []): - profile_name = str(game.get("exe", DEFAULT_PROFILE_NAME)) - config: Dict[str, Any] = dict(ConfigurationManager.get_defaults()) - for field in ("multiplier", "flow_scale", "performance_mode", "experimental_present_mode"): - if field in game: - config[field] = game[field] - config["dll"] = global_config["dll"] - config["no_fp16"] = global_config["no_fp16"] - profiles[profile_name] = dict(ConfigurationManager.validate_config(config)) - - if not profiles: - profiles[DEFAULT_PROFILE_NAME] = dict(ConfigurationManager.get_defaults()) - - current_profile = str(old_global.get("current_profile", DEFAULT_PROFILE_NAME)) - if current_profile not in profiles: - current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config, - ) - - @staticmethod - def is_legacy_v1(content: str) -> bool: - try: - return tomllib.loads(content).get("version") == 1 - except tomllib.TOMLDecodeError: - return False - - @staticmethod def parse_toml_content_multi_profile(content: str) -> ProfileData: data = tomllib.loads(content) version = data.get("version") - if version == 1: - return ConfigurationManager._profile_data_from_v1(data) - if version != 2: + if version not in (1, 2): raise ValueError("unsupported lsfg-vk configuration version") - raw_global = dict(data.get("global", {})) - global_config: Dict[str, Any] = { + global_config = { "dll": ConfigurationManager._migrate_dll_path(raw_global.get("dll", "")), "no_fp16": not bool(raw_global.get("allow_fp16", True)), } - for field in ("log_level", "log_file"): - if field in raw_global: - global_config[field] = raw_global[field] - profiles: Dict[str, Dict[str, Any]] = {} - for profile in data.get("profile", []): - profile_name = str(profile.get("name", DEFAULT_PROFILE_NAME)) - profiles[profile_name] = ConfigurationManager._config_from_profile(profile, global_config) - + source_profiles = data.get("game", []) if version == 1 else data.get("profile", []) + for profile in source_profiles: + name = str(profile.get("exe" if version == 1 else "name", DEFAULT_PROFILE_NAME)) + profiles[name] = ConfigurationManager._config_from_profile(profile, global_config) if not profiles: - default = dict(ConfigurationManager.get_defaults()) - default["dll"] = global_config["dll"] - default["no_fp16"] = global_config["no_fp16"] - profiles[DEFAULT_PROFILE_NAME] = default - - current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config, - ) - - @staticmethod - def parse_toml_content(content: str) -> ConfigurationData: - profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - return cast(ConfigurationData, profile_data["profiles"][profile_data["current_profile"]]) - - @staticmethod - def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]: - return get_script_parsing_logic()(script_content.splitlines()) - - @staticmethod - def parse_profile_selection(script_content: str) -> str | None: - selected = None - for line in script_content.splitlines(): - try: - tokens = shlex.split(line) - except ValueError: - continue - if len(tokens) != 2 or tokens[0] != "export" or "=" not in tokens[1]: - continue - key, value = tokens[1].split("=", 1) - if key in {"LSFGVK_PROFILE", "LSFG_PROCESS"} and value: - selected = value - return selected - - @staticmethod - def merge_config_with_script( - toml_config: Dict[str, Any], - script_values: Dict[str, Union[bool, int, str]], - ) -> Dict[str, Any]: - merged = dict(toml_config) - for field in SCRIPT_ONLY_FIELDS: - if field in script_values: - merged[field] = script_values[field] - return merged + profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.validate_config(global_config) + elif DEFAULT_PROFILE_NAME not in profiles: + source = profiles.get("decky-lsfg-vk", next(iter(profiles.values()))) + profiles[DEFAULT_PROFILE_NAME] = {**source, "active_in": []} + if profiles.get("decky-lsfg-vk", {}).get("active_in", []) == []: + profiles.pop("decky-lsfg-vk", None) + return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": profiles, "global_config": global_config} @staticmethod - def normalize_profile_name(profile_name: str) -> str: - return re.sub(r"\s+", "-", profile_name.strip()).strip("-") - - @staticmethod - def validate_profile_name(profile_name: str) -> bool: - normalized = ConfigurationManager.normalize_profile_name(profile_name) - invalid = '\t\n\r\'"\\/$|&;()<>{}[]' + "`" + '*?' - return ( - bool(normalized) - and not any(character in invalid for character in normalized) - and normalized.lower() not in {"global", "profile"} - ) - - @staticmethod - def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData: - if not ConfigurationManager.validate_profile_name(profile_name): - raise ValueError(f"Invalid profile name: {profile_name}") - normalized = ConfigurationManager.normalize_profile_name(profile_name) - if normalized in profile_data["profiles"]: - raise ValueError(f"Profile '{normalized}' already exists") - source = source_profile if source_profile in profile_data["profiles"] else profile_data["current_profile"] - profiles = dict(profile_data["profiles"]) - profiles[normalized] = dict(profiles[source]) - return ProfileData( - current_profile=profile_data["current_profile"], - profiles=profiles, - global_config=dict(profile_data["global_config"]), - ) - - @staticmethod - def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - if profile_name == DEFAULT_PROFILE_NAME: - raise ValueError("Cannot delete the default profile") - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - profiles = dict(profile_data["profiles"]) - del profiles[profile_name] - current_profile = profile_data["current_profile"] - if current_profile == profile_name: - current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=dict(profile_data["global_config"]), - ) - - @staticmethod - def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData: - if old_name == DEFAULT_PROFILE_NAME: - raise ValueError("Cannot rename the default profile") - if old_name not in profile_data["profiles"] or not ConfigurationManager.validate_profile_name(new_name): - raise ValueError("Invalid profile rename") - normalized = ConfigurationManager.normalize_profile_name(new_name) - if normalized in profile_data["profiles"]: - raise ValueError(f"Profile '{normalized}' already exists") - profiles = { - normalized if name == old_name else name: value - for name, value in profile_data["profiles"].items() - } - current_profile = normalized if profile_data["current_profile"] == old_name else profile_data["current_profile"] - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=dict(profile_data["global_config"]), - ) + def is_legacy_v1(content: str) -> bool: + try: + return tomllib.loads(content).get("version") == 1 + except tomllib.TOMLDecodeError: + return False @staticmethod - def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - return ProfileData( - current_profile=profile_name, - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]), - ) + def parse_toml_content(content: str) -> Dict[str, Any]: + data = ConfigurationManager.parse_toml_content_multi_profile(content) + return cast(Dict[str, Any], data["profiles"][DEFAULT_PROFILE_NAME]) 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 913609b..0000000 --- a/py_modules/lsfg_vk/config_schema_generated.py +++ /dev/null @@ -1,123 +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" -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 - 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', '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 12710cc..2626a66 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -1,229 +1,134 @@ +import re import shlex +from typing import Any, Dict from .base_service import BaseService from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData -from .config_schema_generated import ConfigurationData, get_script_generation_logic from .runtime_service import RuntimeService -from .types import ConfigurationResponse, ProfileResponse, ProfilesResponse class ConfigurationService(BaseService): + """Controller-facing adapter over upstream lsfg-vk profiles.""" + def __init__(self, logger=None, runtime_service: RuntimeService = None): super().__init__(logger) self.runtime_service = runtime_service or RuntimeService(logger=self.log) - def get_config(self) -> ConfigurationResponse: + def _default_data(self) -> ProfileData: + defaults = ConfigurationManager.validate_config({}) + return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: defaults}, "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 _game_profile_name(appid: str) -> str: + if not re.fullmatch(r"[0-9]+", str(appid)): + raise ValueError("appid must be numeric") + return f"game-{appid}" + + @staticmethod + def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: + return ConfigurationManager.validate_config(config) + + def get_config(self) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - current_profile = profile_data["current_profile"] - config = profile_data["profiles"].get(current_profile, dict(ConfigurationManager.get_defaults())) - return self._success_response(ConfigurationResponse, config=config) + data = self._get_profile_data() + return self._success_response(dict, config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: self.log.error(f"Error reading lsfg config: {error}") - return self._error_response(ConfigurationResponse, str(error), config=None) + return self._error_response(dict, str(error), config=None) - def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse: + def get_game_configs(self) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - return self.update_profile_config(profile_data["current_profile"], config) + 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 str(active_in[0]).isdigit(): + continue + games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)}) + return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=games) except Exception as error: - self.log.error(f"Error updating lsfg config: {error}") - return self._error_response(ConfigurationResponse, str(error), config=None) + self.log.error(f"Error reading game configs: {error}") + return self._error_response(dict, str(error), default=None, games=[]) - def update_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse: + def get_game_config(self, appid: str) -> Dict[str, Any]: try: - profile_data: ProfileData = { - "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False), - }, - } - return self.update_lsfg_script_from_profile_data(profile_data) + data = self._get_profile_data() + name = self._game_profile_name(appid) + profile = data["profiles"].get(name) + if profile is None: + profile = next((value for value in data["profiles"].values() if str(appid) in value.get("active_in", [])), None) + return self._success_response(dict, appid=str(appid), exists=profile is not None, config=self._public_config(profile or data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: - return self._error_response(ConfigurationResponse, str(error), config=None) - - def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: - current_profile = profile_data["current_profile"] - config = dict(profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults())) - config["dll"] = profile_data["global_config"].get("dll", config.get("dll", "")) - config["no_fp16"] = profile_data["global_config"].get("no_fp16", config.get("no_fp16", False)) - - lines = ["#!/bin/bash"] - lines.extend(get_script_generation_logic()(config)) - lines.extend( - [ - f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}", - f"export LSFGVK_PROFILE={shlex.quote(current_profile)}", - 'exec "$@"', - ] - ) - return "\n".join(lines) + "\n" - - def _generate_script_content(self, config: ConfigurationData) -> str: - profile_data: ProfileData = { - "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: dict(config)}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False), - }, - } - return self._generate_script_content_for_profile(profile_data) + return self._error_response(dict, str(error), appid=str(appid), exists=False, config=None) - def _get_profile_data(self) -> ProfileData: - if not self.config_file_path.exists(): - default = ConfigurationManager.get_defaults() - return ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: dict(default)}, - global_config={ - "dll": default.get("dll", ""), - "no_fp16": default.get("no_fp16", False), - }, - ) - - profile_data = ConfigurationManager.parse_toml_content_multi_profile( - self.config_file_path.read_text(encoding="utf-8") - ) - if self.lsfg_script_path.exists(): - script_content = self.lsfg_script_path.read_text(encoding="utf-8") - selected = ConfigurationManager.parse_profile_selection(script_content) - if selected in profile_data["profiles"]: - profile_data["current_profile"] = selected - current_profile = profile_data["current_profile"] - profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( - profile_data["profiles"][current_profile], - ConfigurationManager.parse_script_content(script_content), - ) - return profile_data - - def _save_profile_data(self, profile_data: ProfileData) -> None: - 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, - ) - - def get_profiles(self) -> ProfilesResponse: - try: - profile_data = self._get_profile_data() - return self._success_response( - ProfilesResponse, - "Profiles retrieved successfully", - profiles=list(profile_data["profiles"]), - current_profile=profile_data["current_profile"], - ) - except Exception as error: - return self._error_response( - ProfilesResponse, - str(error), - profiles=None, - current_profile=None, - ) - - def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse: + def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile) - self._save_profile_data(new_profile_data) - normalized = ConfigurationManager.normalize_profile_name(profile_name) - return self._success_response( - ProfileResponse, - f"Profile '{normalized}' created successfully", - profile_name=normalized, - ) + data = self._get_profile_data() + name = self._game_profile_name(appid) + validated = self._public_config(config) + validated["active_in"] = [str(appid)] + 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(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), appid=str(appid), config=None) - def delete_profile(self, profile_name: str) -> ProfileResponse: + def reset_game_config(self, appid: str) -> Dict[str, Any]: try: - profile_data = ConfigurationManager.delete_profile(self._get_profile_data(), profile_name) - self._save_profile_data(profile_data) - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - return self._success_response( - ProfileResponse, - f"Profile '{profile_name}' deleted successfully", - profile_name=profile_name, - ) + data = self._get_profile_data() + name = self._game_profile_name(appid) + data["profiles"].pop(name, None) + for profile_name, profile in list(data["profiles"].items()): + if profile_name != DEFAULT_PROFILE_NAME and str(appid) in profile.get("active_in", []): + data["profiles"].pop(profile_name) + self._save_profile_data(data) + return self._success_response(dict, appid=str(appid), config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME])) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), appid=str(appid), config=None) - def rename_profile(self, old_name: str, new_name: str) -> ProfileResponse: + def reset_all_game_configs(self) -> Dict[str, Any]: try: - profile_data = ConfigurationManager.rename_profile(self._get_profile_data(), old_name, new_name) - self._save_profile_data(profile_data) - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - normalized = ConfigurationManager.normalize_profile_name(new_name) - return self._success_response( - ProfileResponse, - f"Profile renamed to '{normalized}' successfully", - profile_name=normalized, - ) + data = self._get_profile_data() + data["profiles"] = {DEFAULT_PROFILE_NAME: data["profiles"][DEFAULT_PROFILE_NAME]} + self._save_profile_data(data) + return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=[]) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), default=None, games=[]) - def set_current_profile(self, profile_name: str) -> ProfileResponse: + def update_config_from_dict(self, config: Dict[str, Any]) -> Dict[str, Any]: try: - profile_data = ConfigurationManager.set_current_profile(self._get_profile_data(), profile_name) - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - raise OSError(script_result["error"]) - return self._success_response( - ProfileResponse, - f"Current profile set to '{profile_name}' successfully", - profile_name=profile_name, - ) + data = self._get_profile_data() + validated = self._public_config(config) + validated["active_in"] = [] + data["profiles"][DEFAULT_PROFILE_NAME] = validated + data["global_config"] = {"dll": validated.get("dll", ""), "no_fp16": validated.get("no_fp16", False)} + self._save_profile_data(data) + return self._success_response(dict, config=validated) except Exception as error: - return self._error_response(ProfileResponse, str(error), profile_name=None) + return self._error_response(dict, str(error), config=None) - def update_profile_config(self, profile_name: str, config: ConfigurationData) -> ConfigurationResponse: - try: - profile_data = self._get_profile_data() - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - - validated = ConfigurationManager.validate_config(config) - profile_data["profiles"][profile_name] = { - **profile_data["profiles"][profile_name], - **validated, - } - profile_data["global_config"]["dll"] = validated.get("dll", "") - profile_data["global_config"]["no_fp16"] = validated.get("no_fp16", False) - 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"]: - raise OSError(script_result["error"]) - - return self._success_response( - ConfigurationResponse, - f"Profile '{profile_name}' configuration updated successfully", - config=validated, - ) - except Exception as error: - return self._error_response(ConfigurationResponse, str(error), config=None) + def update_lsfg_script(self, config: Dict[str, Any]) -> Dict[str, Any]: + return self.update_config_from_dict(config) + + def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: + return "#!/bin/bash\n" f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}\n" 'exec "$@"\n' + + def _generate_script_content(self, config: Dict[str, Any]) -> str: + return self._generate_script_content_for_profile(self._default_data()) - def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> ConfigurationResponse: + def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> Dict[str, Any]: try: - script_content = self._generate_script_content_for_profile(profile_data) - self._write_file(self.lsfg_script_path, script_content, 0o755) - current_config = profile_data["profiles"].get( - profile_data["current_profile"], - dict(ConfigurationManager.get_defaults()), - ) - return self._success_response( - ConfigurationResponse, - "Launch script updated successfully", - config=current_config, - ) + self._write_file(self.lsfg_script_path, self._generate_script_content_for_profile(profile_data), 0o755) + return self._success_response(dict) except Exception as error: - return self._error_response(ConfigurationResponse, str(error), config=None) + return self._error_response(dict, str(error)) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index e979ae5..f7bfdaf 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -24,13 +24,20 @@ from .constants import ( UI_ICON_FILENAME, ) from .runtime_service import RuntimeService +from .steam_service import SteamService from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse class InstallationService(BaseService): - def __init__(self, logger=None, runtime_service: RuntimeService = 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 @@ -143,33 +150,25 @@ class InstallationService(BaseService): }, ) + self._resolve_dll_path(profile_data) defaults = dict(ConfigurationManager.get_defaults()) for profile_name, raw_profile in list(profile_data["profiles"].items()): - validated = ConfigurationManager.validate_config({**defaults, **raw_profile}) - profile_data["profiles"][profile_name] = {**raw_profile, **validated} - - if self.lsfg_script_path.exists(): - script_content = self.lsfg_script_path.read_text(encoding="utf-8") - selected = ConfigurationManager.parse_profile_selection(script_content) - if selected in profile_data["profiles"]: - profile_data["current_profile"] = selected - current_profile = profile_data["current_profile"] - profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( - profile_data["profiles"][current_profile], - ConfigurationManager.parse_script_content(script_content), + profile_data["profiles"][profile_name] = ConfigurationManager.validate_config( + {**defaults, **raw_profile, **profile_data["global_config"]} ) + profile_data["current_profile"] = DEFAULT_PROFILE_NAME + return profile_data - if profile_data["current_profile"] not in profile_data["profiles"]: - profile_data["current_profile"] = ( - DEFAULT_PROFILE_NAME - if DEFAULT_PROFILE_NAME in profile_data["profiles"] - else next(iter(profile_data["profiles"])) - ) + 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 - for profile in profile_data["profiles"].values(): - profile["dll"] = profile_data["global_config"].get("dll", "") - profile["no_fp16"] = profile_data["global_config"].get("no_fp16", False) - return profile_data + 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 def _create_lsfg_launch_script(self, profile_data: ProfileData) -> None: from .configuration import ConfigurationService @@ -202,6 +201,13 @@ class InstallationService(BaseService): if legacy_layer or legacy_config: return True try: + if self.config_file_path.exists(): + data = ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + configured = str(data["global_config"].get("dll") or "") + if (not configured or not Path(configured).is_file()) and self.steam_service.find_lsfg_vk_dll(): + return True return not self.runtime_service.is_healthy() except Exception: return True diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index f764086..3b9a97f 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -31,10 +31,13 @@ class Plugin: def __init__(self): """Initialize the plugin with all necessary services""" self.runtime_service = RuntimeService() - self.installation_service = InstallationService(runtime_service=self.runtime_service) + 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.steam_service = SteamService() async def install_lsfg_vk(self) -> Dict[str, Any]: """Install the bundled lsfg-vk runtime to ~/.local @@ -74,33 +77,11 @@ class Plugin: 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" - } + return { + "field_names": ConfigurationManager.get_field_names(), + "field_types": ConfigurationManager.get_field_types(), + "defaults": ConfigurationManager.get_defaults(), + } 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) @@ -111,19 +92,35 @@ class Plugin: Returns: ConfigurationResponse dict with success status """ - validated_config = ConfigurationManager.validate_config(config) - - return self.configuration_service.update_config_from_dict(validated_config) + return self.configuration_service.update_config_from_dict(config) + + async def get_game_configs(self) -> Dict[str, Any]: + return self.configuration_service.get_game_configs() + + async def get_installed_games(self) -> Dict[str, Any]: + return self.steam_service.get_installed_games() - async def get_profiles(self) -> Dict[str, Any]: + async def get_game_config(self, appid: str) -> Dict[str, Any]: + return self.configuration_service.get_game_config(appid) + + async def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]: + return self.configuration_service.update_game_config(appid, config) + + async def reset_game_config(self, appid: str) -> Dict[str, Any]: + return self.configuration_service.reset_game_config(appid) + + async def reset_all_game_configs(self) -> Dict[str, Any]: + return self.configuration_service.reset_all_game_configs() + + async def _legacy_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() + return self.configuration_service.get_game_configs() - async def create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]: + async def _legacy_create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]: """Create a new profile Args: @@ -133,9 +130,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.create_profile(profile_name, source_profile) + return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - async def delete_profile(self, profile_name: str) -> Dict[str, Any]: + async def _legacy_delete_profile(self, profile_name: str) -> Dict[str, Any]: """Delete a profile Args: @@ -144,9 +141,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.delete_profile(profile_name) + return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - async def rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]: + async def _legacy_rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]: """Rename a profile Args: @@ -156,9 +153,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.rename_profile(old_name, new_name) + return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"} - async def set_current_profile(self, profile_name: str) -> Dict[str, Any]: + async def _legacy_set_current_profile(self, profile_name: str) -> Dict[str, Any]: """Set the current active profile Args: @@ -167,9 +164,9 @@ class Plugin: Returns: ProfileResponse dict with success status """ - return self.configuration_service.set_current_profile(profile_name) + return {"success": False, "error": "There is no globally selected profile"} - async def update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]: + async def _legacy_update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]: """Update configuration for a specific profile Args: @@ -179,9 +176,7 @@ class Plugin: Returns: ConfigurationResponse dict with success status """ - validated_config = ConfigurationManager.validate_config(config) - - return self.configuration_service.update_profile_config(profile_name, validated_config) + return {"success": False, "error": "Use update_game_config with a Steam AppID"} async def get_launch_option(self) -> Dict[str, Any]: """Get the launch option that users need to set for their games @@ -192,7 +187,7 @@ class Plugin: 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" + "explanation": "The lsfg script points games at the upstream configuration; profiles are selected by Steam AppID" } async def get_config_file_content(self) -> Dict[str, Any]: diff --git a/py_modules/lsfg_vk/runtime_service.py b/py_modules/lsfg_vk/runtime_service.py index a61220e..8785522 100644 --- a/py_modules/lsfg_vk/runtime_service.py +++ b/py_modules/lsfg_vk/runtime_service.py @@ -24,7 +24,7 @@ class RuntimeService(BaseService): HOME=str(self.user_home), XDG_CONFIG_HOME=str(self.user_home / ".config"), ) - for name in ("LSFGVK_CONFIG", "LSFGVK_PROFILE", "LSFGVK_ENV"): + for name in ("LSFGVK_CONFIG", "LSFGVK_ENV"): environment.pop(name, None) return environment diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 867a135..58e0a20 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -128,6 +128,16 @@ class SteamService(BaseService): ), } + def find_lsfg_vk_dll(self) -> Optional[str]: + """Find the branch-specific upstream DLL in any Steam library.""" + if self.get_branch_status().get("needs_switch"): + return None + for library_root in self._steam_library_roots(): + dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll" + if dll_path.is_file(): + return str(dll_path) + return None + def get_branch_status(self) -> Dict[str, object]: try: manifest_path = self._manifest_path() @@ -165,3 +175,23 @@ class SteamService(BaseService): needs_switch=False, restart_required=False, ) + + def get_installed_games(self) -> Dict[str, object]: + """Return installed Steam app IDs and names for the Game Mode selector.""" + try: + games = {} + for library_root in self._steam_library_roots(): + for manifest in (library_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) + name = self._section_value(content, "AppState", "name") or f"App {appid}" + games[appid] = name + return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].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 2c56a15..ef6bab7 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -68,15 +68,16 @@ class ProfileConfig(TypedDict): class ProfilesResponse(BaseResponse): - """Response for profile operations""" - profiles: Optional[List[str]] - current_profile: Optional[str] + """Response for per-game upstream profiles""" + default: Optional[ConfigurationData] + games: Optional[List[Dict[str, Any]]] message: Optional[str] error: Optional[str] class ProfileResponse(BaseResponse): - """Response for single profile operations""" - profile_name: Optional[str] + """Response for a per-game upstream profile""" + appid: Optional[str] + config: Optional[ConfigurationData] message: Optional[str] error: Optional[str] diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py deleted file mode 100644 index d134337..0000000 --- a/scripts/generate_python_boilerplate.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate Python boilerplate from shared_config.py - -This script generates repetitive Python code patterns from the canonical schema, -reducing manual maintenance when adding/removing configuration fields. -""" - -import sys -from pathlib import Path - -# Add project root to path to import shared_config -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - - -def get_python_type(field_type: ConfigFieldType) -> str: - """Convert ConfigFieldType to Python type annotation""" - type_map = { - ConfigFieldType.BOOLEAN: "bool", - ConfigFieldType.INTEGER: "int", - ConfigFieldType.FLOAT: "float", - ConfigFieldType.STRING: "str" - } - return type_map.get(field_type, "Any") - - -def get_env_var_name(field_name: str) -> str: - """Convert field name to environment variable name""" - env_map = { - "dxvk_frame_rate": "DXVK_FRAME_RATE", - "enable_wow64": "PROTON_USE_WOW64", - "disable_steamdeck_mode": "SteamDeck", - "mangohud_workaround": "MANGOHUD", - "disable_vkbasalt": "DISABLE_VKBASALT", - "force_enable_vkbasalt": "ENABLE_VKBASALT", - "enable_wsi": "ENABLE_GAMESCOPE_WSI", - "enable_zink": "ZINK_ENABLE" - } - return env_map.get(field_name, field_name.upper()) - - -def generate_typed_dict() -> str: - """Generate ConfigurationData TypedDict""" - lines = [ - "class ConfigurationData(TypedDict):", - " \"\"\"Type-safe configuration data structure - AUTO-GENERATED\"\"\"" - ] - - for field_name, field_def in CONFIG_SCHEMA_DEF.items(): - python_type = get_python_type(ConfigFieldType(field_def["fieldType"])) - lines.append(f" {field_name}: {python_type}") - - return "\n".join(lines) - - -def generate_script_parsing() -> str: - """Generate script content parsing logic""" - lines = [] - - script_fields = [ - (field_name, field_def) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" - ] - - for field_name, field_def in script_fields: - env_var = get_env_var_name(field_name) - field_type = ConfigFieldType(field_def["fieldType"]) - - if field_type == ConfigFieldType.BOOLEAN: - if field_name == "disable_steamdeck_mode": - # Special case: SteamDeck=0 means disable_steamdeck_mode=True - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value == "0"') - elif field_name == "enable_wsi": - # Special case: ENABLE_GAMESCOPE_WSI=0 or DXVK_HDR=0 means enable_wsi=False - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value != "0"') - lines.append(f' elif key == "DXVK_HDR":') - lines.append(f' script_values["{field_name}"] = value != "0"') - elif field_name == "enable_zink": - # Special case: Zink uses multiple environment variables - lines.append(f' elif key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa":') - lines.append(f' script_values["{field_name}"] = True') - lines.append(f' elif key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink":') - lines.append(f' script_values["{field_name}"] = True') - lines.append(f' elif key == "GALLIUM_DRIVER" and value == "zink":') - lines.append(f' script_values["{field_name}"] = True') - else: - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value == "1"') - elif field_type == ConfigFieldType.INTEGER: - lines.append(f' elif key == "{env_var}":') - lines.append(' try:') - lines.append(f' script_values["{field_name}"] = int(value)') - lines.append(' except ValueError:') - lines.append(' pass') - elif field_type == ConfigFieldType.FLOAT: - lines.append(f' elif key == "{env_var}":') - lines.append(' try:') - lines.append(f' script_values["{field_name}"] = float(value)') - lines.append(' except ValueError:') - lines.append(' pass') - elif field_type == ConfigFieldType.STRING: - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value') - - return "\n".join(lines) - - -def generate_script_generation() -> str: - """Generate script content generation logic""" - lines = [] - - script_fields = [ - (field_name, field_def) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" - ] - - for field_name, field_def in script_fields: - env_var = get_env_var_name(field_name) - field_type = ConfigFieldType(field_def["fieldType"]) - - if field_type == ConfigFieldType.BOOLEAN: - if field_name == "disable_steamdeck_mode": - # Special case: disable_steamdeck_mode=True should export SteamDeck=0 - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=0")') - elif field_name == "enable_wsi": - # Special case: enable_wsi=False should export ENABLE_GAMESCOPE_WSI=0 and DXVK_HDR=0 - lines.append(f' if not config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=0")') - lines.append(f' lines.append("export DXVK_HDR=0")') - elif field_name == "enable_zink": - # Special case: enable_zink=True should export multiple Zink environment variables - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa")') - lines.append(f' lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink")') - lines.append(f' lines.append("export GALLIUM_DRIVER=zink")') - else: - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=1")') - elif field_type in [ConfigFieldType.INTEGER, ConfigFieldType.FLOAT]: - default = field_def["default"] - if field_name == "dxvk_frame_rate": - # Special handling for DXVK_FRAME_RATE (only export if > 0) - lines.append(f' {field_name} = config.get("{field_name}", {default})') - lines.append(f' if {field_name} > 0:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - else: - lines.append(f' {field_name} = config.get("{field_name}", {default})') - lines.append(f' if {field_name} != {default}:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - elif field_type == ConfigFieldType.STRING: - lines.append(f' {field_name} = config.get("{field_name}", "")') - lines.append(f' if {field_name}:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - - return "\n".join(lines) - - -def generate_complete_schema_file() -> str: - """Generate complete config_schema_generated.py file""" - - # Generate field name constants - field_constants = [] - for field_name in CONFIG_SCHEMA_DEF.keys(): - const_name = field_name.upper() - field_constants.append(f'{const_name} = "{field_name}"') - - lines = [ - '"""', - 'Auto-generated configuration schema components from shared_config.py', - 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build', - '"""', - '', - 'from typing import TypedDict, Dict, Any, Union', - 'from enum import Enum', - 'import sys', - 'from pathlib import Path', - '', - '# Import shared configuration constants', - 'sys.path.insert(0, str(Path(__file__).parent.parent.parent))', - 'from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType', - '', - '# Field name constants for type-safe access', - ] + field_constants + [ - '', - '', - generate_typed_dict(), - '', - '', - 'def get_script_parsing_logic():', - ' """Return the script parsing logic as a callable"""', - ' def parse_script_values(lines):', - ' script_values = {}', - ' for line in lines:', - ' line = line.strip()', - ' if not line or line.startswith("#") or not line.startswith("export "):', - ' continue', - ' if "=" in line:', - ' export_line = line[len("export "):]', - ' key, value = export_line.split("=", 1)', - ' key = key.strip()', - ' value = value.strip()', - '', - ' # Auto-generated parsing logic:', - f'{generate_script_parsing().replace(" elif", " if")}', - '', - ' return script_values', - ' return parse_script_values', - '', - '', - 'def get_script_generation_logic():', - ' """Return the script generation logic as a callable"""', - ' def generate_script_lines(config):', - ' lines = []', - f'{generate_script_generation()}', - ' return lines', - ' return generate_script_lines', - '', - '', - f'ALL_FIELDS = {list(CONFIG_SCHEMA_DEF.keys())}', - '' - ] - - return '\n'.join(lines) - - -def main(): - """Generate complete Python configuration files""" - try: - # Create generated files in py_modules/lsfg_vk/ - target_dir = project_root / "py_modules" / "lsfg_vk" - - # Generate the complete schema file - schema_content = generate_complete_schema_file() - schema_file = target_dir / "config_schema_generated.py" - schema_file.write_text(schema_content) - print(f"Generated {schema_file.relative_to(project_root)}") - - except Exception as e: - print(f"Error generating Python files: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py deleted file mode 100644 index 27969f1..0000000 --- a/scripts/generate_ts_schema.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate TypeScript schema from Python shared_config.py - -This script reads the canonical schema from shared_config.py and generates -the corresponding TypeScript files, ensuring single source of truth. -""" - -import sys -from pathlib import Path - -# Add project root to path to import shared_config -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - - -def generate_typescript_schema(): - """Generate generatedConfigSchema.ts from Python schema""" - - # Generate field name constants - field_constants = [] - for field_name in CONFIG_SCHEMA_DEF.keys(): - const_name = field_name.upper() - field_constants.append(f'export const {const_name} = "{field_name}" as const;') - - # Generate enum - enum_lines = [ - "// src/config/generatedConfigSchema.ts", - "// Configuration field type enum - matches Python", - "export enum ConfigFieldType {", - " BOOLEAN = \"boolean\",", - " INTEGER = \"integer\",", - " FLOAT = \"float\",", - " STRING = \"string\"", - "}", - "", - "// Field name constants for type-safe access", - ] + field_constants + [ - "", - "// Configuration field definition", - "export interface ConfigField {", - " name: string;", - " fieldType: ConfigFieldType;", - " default: boolean | number | string;", - " description: string;", - "}", - "", - "// Configuration schema - auto-generated from Python", - "export const CONFIG_SCHEMA: Record<string, ConfigField> = {" - ] - - # Generate schema entries - schema_entries = [] - interface_fields = [] - defaults_fields = [] - field_types = [] - - for field_name, field_def in CONFIG_SCHEMA_DEF.items(): - # Schema entry - default_value = field_def["default"] - if isinstance(default_value, str): - default_str = f'"{default_value}"' - elif isinstance(default_value, bool): - default_str = "true" if default_value else "false" - else: - default_str = str(default_value) - - schema_entries.append(f' {field_name}: {{') - schema_entries.append(f' name: "{field_def["name"]}",') - schema_entries.append(f' fieldType: ConfigFieldType.{field_def["fieldType"].upper()},') - schema_entries.append(f' default: {default_str},') - schema_entries.append(f' description: "{field_def["description"]}"') - schema_entries.append(' },') - - # Interface field - if field_def["fieldType"] == ConfigFieldType.BOOLEAN: - ts_type = "boolean" - elif field_def["fieldType"] == ConfigFieldType.INTEGER: - ts_type = "number" - elif field_def["fieldType"] == ConfigFieldType.FLOAT: - ts_type = "number" - elif field_def["fieldType"] == ConfigFieldType.STRING: - ts_type = "string" - else: - ts_type = "any" - - interface_fields.append(f' {field_name}: {ts_type};') - defaults_fields.append(f' {field_name}: {default_str},') - field_types.append(f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},') - - # Complete the file - all_lines = enum_lines + schema_entries + [ - "};", - "", - "// Type-safe configuration data structure", - "export interface ConfigurationData {", - ] + interface_fields + [ - "}", - "", - "// Helper functions", - "export function getFieldNames(): string[] {", - " return Object.keys(CONFIG_SCHEMA);", - "}", - "", - "export function getDefaults(): ConfigurationData {", - " return {", - ] + defaults_fields + [ - " };", - "}", - "", - "export function getFieldTypes(): Record<string, ConfigFieldType> {", - " return {", - ] + field_types + [ - " };", - "}", - "", - "" - ] - - return "\n".join(all_lines) - - -def main(): - """Main function to generate TypeScript schema and Python boilerplate""" - try: - # Generate the TypeScript content - ts_content = generate_typescript_schema() - - # Write to the target file - target_file = project_root / "src" / "config" / "generatedConfigSchema.ts" - target_file.write_text(ts_content) - - print(f"Generated {target_file} from shared_config.py") - print(f" Fields: {len(CONFIG_SCHEMA_DEF)}") - - # Also generate Python boilerplate - print("\nGenerating Python boilerplate...") - from pathlib import Path - import subprocess - - boilerplate_script = project_root / "scripts" / "generate_python_boilerplate.py" - result = subprocess.run([sys.executable, str(boilerplate_script)], - capture_output=True, text=True) - - if result.returncode == 0: - print(result.stdout) - else: - print(f"Warning: Python boilerplate generation had issues:\n{result.stderr}") - - except Exception as e: - print(f"Error generating schema: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/shared_config.py b/shared_config.py deleted file mode 100644 index bb7eea6..0000000 --- a/shared_config.py +++ /dev/null @@ -1,123 +0,0 @@ -from enum import Enum -from typing import Dict, Union - - -class ConfigFieldType(str, Enum): - BOOLEAN = "boolean" - INTEGER = "integer" - FLOAT = "float" - STRING = "string" - - -CONFIG_SCHEMA_DEF = { - "dll": { - "name": "dll", - "fieldType": ConfigFieldType.STRING, - "default": "", - "description": "override the lsfg-vk.dll path", - "location": "toml", - }, - "no_fp16": { - "name": "no_fp16", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable FP16 acceleration", - "location": "toml", - }, - "multiplier": { - "name": "multiplier", - "fieldType": ConfigFieldType.INTEGER, - "default": 1, - "description": "frame generation multiplier", - "location": "toml", - }, - "flow_scale": { - "name": "flow_scale", - "fieldType": ConfigFieldType.FLOAT, - "default": 1.0, - "description": "motion estimation resolution scale", - "location": "toml", - }, - "performance_mode": { - "name": "performance_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "use the lighter frame generation model", - "location": "toml", - }, - "experimental_present_mode": { - "name": "experimental_present_mode", - "fieldType": ConfigFieldType.STRING, - "default": "fifo", - "description": "control the v2 present mode override", - "location": "toml", - }, - "dxvk_frame_rate": { - "name": "dxvk_frame_rate", - "fieldType": ConfigFieldType.INTEGER, - "default": 0, - "description": "base framerate cap for DirectX games before frame multiplier", - "location": "script", - }, - "enable_wow64": { - "name": "enable_wow64", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable PROTON_USE_WOW64=1 for 32-bit games", - "location": "script", - }, - "disable_steamdeck_mode": { - "name": "disable_steamdeck_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable Steam Deck mode", - "location": "script", - }, - "mangohud_workaround": { - "name": "mangohud_workaround", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable a transparent MangoHud overlay workaround", - "location": "script", - }, - "disable_vkbasalt": { - "name": "disable_vkbasalt", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable vkBasalt for games where it conflicts with lsfg-vk", - "location": "script", - }, - "force_enable_vkbasalt": { - "name": "force_enable_vkbasalt", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "force-enable vkBasalt", - "location": "script", - }, - "enable_wsi": { - "name": "enable_wsi", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable the Gamescope WSI layer", - "location": "script", - }, - "enable_zink": { - "name": "enable_zink", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable Zink for OpenGL games", - "location": "script", - }, -} - - -def get_field_names() -> list[str]: - return list(CONFIG_SCHEMA_DEF) - - -def get_defaults() -> Dict[str, Union[bool, int, float, str]]: - return {name: definition["default"] for name, definition in CONFIG_SCHEMA_DEF.items()} - - -def get_field_types() -> Dict[str, str]: - return {name: definition["fieldType"].value for name, definition in CONFIG_SCHEMA_DEF.items()} diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index c0d6528..b38fa3b 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -44,12 +44,31 @@ export interface ConfigUpdateResult { error?: string; } +export interface GameConfigEntry { + appid: string; + profile: string; + config: LsfgConfig; +} +export interface InstalledGame { appid: string; name: string; } +export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } + +export interface GameConfigsResult { + success: boolean; + default?: LsfgConfig; + games?: GameConfigEntry[]; + error?: string; +} + +export interface GameConfigResult extends ConfigUpdateResult { + appid?: string; + exists?: boolean; + config?: LsfgConfig; +} + export interface ConfigSchemaResult { field_names: string[]; field_types: Record<string, string>; defaults: ConfigurationData; - profiles?: string[]; - current_profile?: string; } export interface LaunchOptionResult { @@ -105,22 +124,6 @@ export interface FlatpakOperationResult { operation?: string; } -// Profile management interfaces -export interface ProfilesResult { - success: boolean; - profiles?: string[]; - current_profile?: string; - message?: string; - error?: string; -} - -export interface ProfileResult { - success: boolean; - profile_name?: string; - message?: string; - error?: string; -} - // API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); @@ -146,17 +149,14 @@ export const updateLsfgConfig = callable< [ConfigurationData], ConfigUpdateResult >("update_lsfg_config"); +export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); +export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); +export const getGameConfig = callable<[string], GameConfigResult>("get_game_config"); +export const updateGameConfig = callable<[string, LsfgConfig], GameConfigResult>("update_game_config"); +export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); +export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); // Legacy helper function for backward compatibility export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise<ConfigUpdateResult> => { return updateLsfgConfig(config); }; - -// Self-updater API functions -// Profile management API functions -export const getProfiles = callable<[], ProfilesResult>("get_profiles"); -export const createProfile = callable<[string, string?], ProfileResult>("create_profile"); -export const deleteProfile = callable<[string], ProfileResult>("delete_profile"); -export const renameProfile = callable<[string, string], ProfileResult>("rename_profile"); -export const setCurrentProfile = callable<[string], ProfileResult>("set_current_profile"); -export const updateProfileConfig = callable<[string, ConfigurationData], ConfigUpdateResult>("update_profile_config"); diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index e83bd58..c2bba7e 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,293 +1,28 @@ -import { PanelSectionRow, ToggleField, SliderField, ButtonItem } from "@decky/ui"; -import { useState, useEffect } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { - FLOW_SCALE, NO_FP16, PERFORMANCE_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, - MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK -} from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; } -const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; -const CONFIG_COLLAPSED_KEY = "lsfg-config-collapsed"; - -export function ConfigurationSection({ - config, - onConfigChange -}: ConfigurationSectionProps) { - // Initialize with localStorage value, fallback to true if not found - const [configCollapsed, setConfigCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(CONFIG_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - const [workaroundsCollapsed, setWorkaroundsCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : true; - } catch { - return true; - } - }); - - // Persist workarounds collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(CONFIG_COLLAPSED_KEY, JSON.stringify(configCollapsed)); - } catch (error) { - console.warn("Failed to save config collapse state:", error); - } - }, [configCollapsed]); - - useEffect(() => { - try { - localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(workaroundsCollapsed)); - } catch (error) { - console.warn("Failed to save workarounds collapse state:", error); - } - }, [workaroundsCollapsed]); - - return ( - <> - <style> - {` - .LSFG_ConfigCollapseButton_Container > div > div > div > button, - .LSFG_ConfigCollapseButton_Container > div > div > div > div > button, - .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button { - height: 10px !important; - } - .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button { - height: 10px !important; - } - `} - </style> - - {/* Config Section */} - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONFIG_SECTION_TITLE', 'Config')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_ConfigCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={configCollapsed ? "standard" : "none"} - onClick={() => setConfigCollapsed(!configCollapsed)} - > - {configCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!configCollapsed && ( - <> - <PanelSectionRow> - <SliderField - label={`${t('CONFIG_FLOW_SCALE', 'Flow Scale')} (${Math.round(config.flow_scale * 100)}%)`} - description={t('CONFIG_FLOW_SCALE_DESC', 'Lowers internal motion estimation resolution, improving performance slightly')} - value={config.flow_scale} - min={0.25} - max={1.0} - step={0.01} - onChange={(value) => onConfigChange(FLOW_SCALE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label="FP16 Acceleration" - description="Use FP16 shaders when supported" - checked={!config.no_fp16} - onChange={(value) => onConfigChange(NO_FP16, !value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <SliderField - label={`${t('CONFIG_BASE_FPS_CAP', 'Base FPS Cap')}${config.dxvk_frame_rate > 0 ? ` (${config.dxvk_frame_rate} FPS)` : ` (${t('CONFIG_BASE_FPS_CAP_OFF', 'Off')})`}`} - description={t('CONFIG_BASE_FPS_CAP_DESC', 'Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)')} - value={config.dxvk_frame_rate} - min={0} - max={60} - step={1} - onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={`${t('CONFIG_PRESENT_MODE', 'Present Mode Override')} (${(config.experimental_present_mode || "fifo") === "fifo" ? t('CONFIG_PRESENT_MODE_FIFO', 'FIFO - VSync') : 'App Default'})`} - description={t('CONFIG_PRESENT_MODE_DESC', 'Force FIFO/VSync for v2 frame pacing, or leave the game present mode unchanged')} - checked={(config.experimental_present_mode || "fifo") === "fifo"} - onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_PERFORMANCE_MODE', 'Performance Mode')} - description={t('CONFIG_PERFORMANCE_MODE_DESC', 'Uses a lighter model for FG (Recommended for most games)')} - checked={config.performance_mode} - onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} - /> - </PanelSectionRow> - - </> - )} - - {/* Workarounds Section */} - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONFIG_WORKAROUNDS_TITLE', 'Workarounds')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_WorkaroundsCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={workaroundsCollapsed ? "standard" : "none"} - onClick={() => setWorkaroundsCollapsed(!workaroundsCollapsed)} - > - {workaroundsCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!workaroundsCollapsed && ( - <> - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_WSI', 'Enable WSI')} - description={t('CONFIG_ENABLE_WSI_DESC', 'Re-Enable Gamescope WSI Layer. Requires game restart to apply.')} - checked={config.enable_wsi} - onChange={(value) => onConfigChange(ENABLE_WSI, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_WOW64', 'Enable WOW64 for 32-bit games')} - description={t('CONFIG_ENABLE_WOW64_DESC', 'Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)')} - checked={config.enable_wow64} - onChange={(value) => onConfigChange('enable_wow64', value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_DISABLE_STEAMDECK_MODE', 'Disable Steam Deck Mode')} - description={t('CONFIG_DISABLE_STEAMDECK_MODE_DESC', 'Disables Steam Deck mode (Unlocks hidden settings in some games)')} - checked={config.disable_steamdeck_mode} - onChange={(value) => onConfigChange(DISABLE_STEAMDECK_MODE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_MANGOHUD_WORKAROUND', 'MangoHud Workaround')} - description={t('CONFIG_MANGOHUD_WORKAROUND_DESC', 'Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode')} - checked={config.mangohud_workaround} - onChange={(value) => onConfigChange(MANGOHUD_WORKAROUND, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_DISABLE_VKBASALT', 'Disable vkBasalt')} - description={t('CONFIG_DISABLE_VKBASALT_DESC', 'Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)')} - checked={config.disable_vkbasalt} - disabled={config.force_enable_vkbasalt} - onChange={(value) => { - if (value && config.force_enable_vkbasalt) { - // Turn off force enable when enabling disable - onConfigChange(FORCE_ENABLE_VKBASALT, false); - } - onConfigChange(DISABLE_VKBASALT, value); - }} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_FORCE_ENABLE_VKBASALT', 'Force Enable vkBasalt')} - description={t('CONFIG_FORCE_ENABLE_VKBASALT_DESC', 'Force vkBasalt to engage to fix framepacing issues in gamemode')} - checked={config.force_enable_vkbasalt} - disabled={config.disable_vkbasalt} - onChange={(value) => { - if (value && config.disable_vkbasalt) { - // Turn off disable when enabling force enable - onConfigChange(DISABLE_VKBASALT, false); - } - onConfigChange(FORCE_ENABLE_VKBASALT, value); - }} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_ZINK', 'Enable Zink for OpenGL Games')} - description={t('CONFIG_ENABLE_ZINK_DESC', 'Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)')} - checked={config.enable_zink} - onChange={(value) => onConfigChange(ENABLE_ZINK, value)} - /> - </PanelSectionRow> - </> - )} - </> - ); +export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) { + return <> + <PanelSectionRow> + <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} description="Motion estimation resolution scale" value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="FP16 Acceleration" description="Use FP16 shaders when supported" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Performance Mode" description="Use the lighter frame generation model" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Present Mode Override" description="Force FIFO/VSync pacing" checked={config.override_present_mode} onChange={(value) => onConfigChange(OVERRIDE_PRESENT_MODE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Preserve Swapchain Image Count" description="Do not change the application's swapchain image count" checked={config.preserve_swapchain_image_count} onChange={(value) => onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} /> + </PanelSectionRow> + </>; } diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 01f94c9..bdb3a04 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,12 +1,12 @@ import { useEffect } from "react"; import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; -import { useInstallationStatus, useLsfgConfig } from "../hooks/useLsfgHooks"; -import { useProfileManagement } from "../hooks/useProfileManagement"; +import { useInstallationStatus } from "../hooks/useLsfgHooks"; +import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallationActions } from "../hooks/useInstallationActions"; import { StatusDisplay } from "./StatusDisplay"; import { InstallationButton } from "./InstallationButton"; import { ConfigurationSection } from "./ConfigurationSection"; -import { ProfileManagement } from "./ProfileManagement"; +import { GameConfigurationSelector } from "./GameConfigurationSelector"; import { UsageInstructions } from "./UsageInstructions"; import { SmartClipboardButton } from "./SmartClipboardButton"; import { FgmodClipboardButton } from "./FgmodClipboardButton"; @@ -28,40 +28,20 @@ export function Content() { checkInstallation } = useInstallationStatus(); - const { - config, - loadLsfgConfig, - updateField - } = useLsfgConfig(); - - const { - currentProfile, - updateProfileConfig, - loadProfiles - } = useProfileManagement(); + const { config, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload } = useGameConfiguration(); const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); useEffect(() => { - if (isInstalled) { - loadLsfgConfig(); - } - }, [isInstalled, loadLsfgConfig]); - - const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string) => { - if (currentProfile) { - const newConfig = { ...config, [fieldName]: value }; - const result = await updateProfileConfig(currentProfile, newConfig); - if (result.success) { - await loadLsfgConfig(); - } - } else { - await updateField(fieldName, value); - } + if (isInstalled) void reload(); + }, [isInstalled, reload]); + + const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => { + await save({ ...config, [fieldName]: value }); }; const onInstall = () => { - handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig, checkInstallation); + handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); }; const onUninstall = () => { @@ -124,13 +104,7 @@ export function Content() { )} {isInstalled && ( - <ProfileManagement - currentProfile={currentProfile} - onProfileChange={async () => { - await loadProfiles(); - await loadLsfgConfig(); - }} - /> + <GameConfigurationSelector targets={targets} runningGame={runningGame} selectedAppId={selectedAppId} onSelect={setSelectedAppId} onReset={resetSelected} onResetAll={resetAll} /> )} {isInstalled && ( diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 206643e..5069c9a 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,11 +1,11 @@ import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import t from "../i18n/i18n"; interface FpsMultiplierControlProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; } export function FpsMultiplierControl({ @@ -50,7 +50,7 @@ export function FpsMultiplierControl({ textAlign: "center" }} > - {config.multiplier < 2 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} + {config.multiplier === 1 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} </div> <DialogButton style={{ diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx new file mode 100644 index 0000000..8a0df6c --- /dev/null +++ b/src/components/GameConfigurationSelector.tsx @@ -0,0 +1,29 @@ +import { Dropdown, DropdownOption, PanelSectionRow, ButtonItem } from "@decky/ui"; +import { GameTarget } from "../hooks/useGameConfiguration"; + +interface Props { + targets: GameTarget[]; + runningGame: GameTarget | null; + selectedAppId: string; + onSelect: (appid: string) => void; + onReset: () => Promise<void>; + onResetAll: () => Promise<void>; +} + +export function GameConfigurationSelector({ targets, runningGame, selectedAppId, onSelect, onReset, onResetAll }: Props) { + const options: DropdownOption[] = [ + { data: "", label: runningGame ? `Default (editing template) · ${runningGame.name}` : "Default" }, + ...targets.map((target) => ({ data: target.appid, label: `${target.name} · ${target.appid}` })), + ]; + return <> + <PanelSectionRow> + <Dropdown rgOptions={options} selectedOption={selectedAppId} onChange={(option) => onSelect(String(option.data))} /> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem layout="below" onClick={() => void onReset()} disabled={!selectedAppId}>Reset selected game</ButtonItem> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem layout="below" onClick={() => void onResetAll()} disabled={!targets.some((target) => target.configured)}>Reset all game profiles</ButtonItem> + </PanelSectionRow> + </>; +} diff --git a/src/components/ProfileManagement.tsx b/src/components/ProfileManagement.tsx deleted file mode 100644 index 626d54c..0000000 --- a/src/components/ProfileManagement.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { useState, useEffect } from "react"; -import { - PanelSectionRow, - Dropdown, - DropdownOption, - showModal, - ConfirmModal, - Field, - DialogButton, - ButtonItem, - ModalRoot, - TextField, - Focusable, - AppOverview, - Router -} from "@decky/ui"; -import { RiArrowDownSFill, RiArrowUpSFill, RiEditLine, RiDeleteBinLine } from "react-icons/ri"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - ProfilesResult, - ProfileResult -} from "../api/lsfgApi"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; -import t from '../i18n/i18n'; - -const PROFILES_COLLAPSED_KEY = 'lsfg-profiles-collapsed'; - -interface TextInputModalProps { - title: string; - description: string; - defaultValue?: string; - okText?: string; - cancelText?: string; - onOK: (value: string) => void; - closeModal?: () => void; -} - -function TextInputModal({ - title, - description, - defaultValue = "", - okText = "OK", - cancelText = "Cancel", - onOK, - closeModal -}: TextInputModalProps) { - const [value, setValue] = useState(defaultValue); - - const handleOK = () => { - if (value.trim()) { - onOK(value); - closeModal?.(); - } - }; - - return ( - <ModalRoot> - <div style={{ padding: "16px", minWidth: "400px" }}> - <h2 style={{ marginBottom: "16px" }}>{title}</h2> - <p style={{ marginBottom: "24px" }}>{description}</p> - - <div style={{ marginBottom: "24px" }}> - <Field - label={t('PROFILE_NAME_LABEL', 'Name')} - childrenLayout="below" - childrenContainerWidth="max" - > - <TextField - value={value} - onChange={(e) => setValue(e?.target?.value || "")} - style={{ width: "100%" }} - /> - </Field> - </div> - - <Focusable - style={{ - display: "flex", - justifyContent: "flex-end", - gap: "8px", - marginTop: "16px" - }} - flow-children="horizontal" - > - <DialogButton onClick={closeModal}> - {cancelText} - </DialogButton> - <DialogButton - onClick={handleOK} - disabled={!value.trim()} - > - {okText} - </DialogButton> - </Focusable> - </div> - </ModalRoot> - ); -} - -interface ProfileManagementProps { - currentProfile?: string; - onProfileChange?: (profileName: string) => void; -} - -export function ProfileManagement({ currentProfile, onProfileChange }: ProfileManagementProps) { - const [profiles, setProfiles] = useState<string[]>([]); - const [selectedProfile, setSelectedProfile] = useState<string>(currentProfile || "decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - const [mainRunningApp, setMainRunningApp] = useState<AppOverview | undefined>(undefined); - - // Initialize with localStorage value, fallback to false (expanded) if not found - const [profilesCollapsed, setProfilesCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(PROFILES_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - // Persist profiles collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(PROFILES_COLLAPSED_KEY, JSON.stringify(profilesCollapsed)); - } catch (error) { - console.warn('Failed to save profiles collapse state:', error); - } - }, [profilesCollapsed]); - - // Load profiles on component mount - useEffect(() => { - loadProfiles(); - }, []); - - // Update selected profile when prop changes - useEffect(() => { - if (currentProfile) { - setSelectedProfile(currentProfile); - } - }, [currentProfile]); - - // Poll for running app every 2 seconds - useEffect(() => { - const checkRunningApp = () => { - setMainRunningApp(Router.MainRunningApp); - }; - - // Check immediately - checkRunningApp(); - - // Set up polling interval - const interval = setInterval(checkRunningApp, 2000); - - // Cleanup interval on unmount - return () => clearInterval(interval); - }, []); - - const loadProfiles = async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setSelectedProfile(result.current_profile); - } - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - } - }; - - const handleProfileChange = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setSelectedProfile(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - onProfileChange?.(profileName); - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleCreateProfile = () => { - showModal( - <TextInputModal - title={t('PROFILE_CREATE_TITLE', 'Create New Profile')} - description={t('PROFILE_CREATE_DESC', "Enter a name for the new profile. The current profile's settings will be copied.")} - okText={t('PROFILE_CREATE_BTN', 'Create')} - cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={(name: string) => { - if (name.trim()) { - createNewProfile(name.trim()); - } - }} - /> - ); - }; - - const createNewProfile = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, selectedProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - // Automatically switch to the newly created profile using the normalized name - await handleProfileChange(actualProfileName); - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDeleteProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_DELETE_TITLE', 'Cannot delete default profile'), t('PROFILE_CANNOT_DELETE_MSG', 'The default profile cannot be deleted')); - return; - } - - showModal( - <ConfirmModal - strTitle={t('PROFILE_DELETE_TITLE', 'Delete Profile')} - strDescription={`${t('PROFILE_DELETE_DESC_PREFIX', 'Are you sure you want to delete the profile')} "${selectedProfile}"${t('PROFILE_DELETE_DESC_SUFFIX', '? This action cannot be undone.')}`} - strOKButtonText={t('PROFILE_DELETE_BTN', 'Delete')} - strCancelButtonText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={() => deleteSelectedProfile()} - /> - ); - }; - - const deleteSelectedProfile = async () => { - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(selectedProfile); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${selectedProfile}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - setSelectedProfile("decky-lsfg-vk"); - onProfileChange?.("decky-lsfg-vk"); - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDropdownChange = (option: DropdownOption) => { - if (option.data === "__NEW_PROFILE__") { - handleCreateProfile(); - } else { - handleProfileChange(option.data); - } - }; - - const handleRenameProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_RENAME_TITLE', 'Cannot rename default profile'), t('PROFILE_CANNOT_RENAME_MSG', 'The default profile cannot be renamed')); - return; - } - - showModal( - <TextInputModal - title={t('PROFILE_RENAME_TITLE', 'Rename Profile')} - description={`${t('PROFILE_RENAME_DESC_PREFIX', 'Enter a new name for the profile')} "${selectedProfile}".`} - defaultValue={selectedProfile} - okText={t('PROFILE_RENAME_BTN', 'Rename')} - cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={(newName: string) => { - if (newName.trim() && newName.trim() !== selectedProfile) { - renameSelectedProfile(newName.trim()); - } - }} - /> - ); - }; - - const renameSelectedProfile = async (newName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(selectedProfile, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - setSelectedProfile(actualNewName); - onProfileChange?.(actualNewName); - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const profileOptions: DropdownOption[] = [ - ...profiles.map((profile: string) => ({ - data: profile, - label: profile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : profile - })), - { - data: "__NEW_PROFILE__", - label: t('PROFILE_NEW', 'New Profile') - } - ]; - - return ( - <> - <style> - {` - .LSFG_ProfilesCollapseButton_Container > div > div > div > button { - height: 10px !important; - } - .LSFG_ProfilesCollapseButton_Container > div > div > div > div > button { - height: 10px !important; - } - `} - </style> - - {/* Display currently running game info - always visible */} - {mainRunningApp && ( - <PanelSectionRow> - <div style={{ - padding: "8px 12px", - backgroundColor: "rgba(0, 255, 0, 0.1)", - borderRadius: "4px", - border: "1px solid rgba(0, 255, 0, 0.3)", - fontSize: "13px" - }}> - <strong>{mainRunningApp.display_name}</strong> running. {t('PROFILE_CLOSE_GAME', 'Close game to change profile.')} - </div> - </PanelSectionRow> - )} - - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('PROFILE_SECTION_TITLE', 'Profile:')} {selectedProfile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : selectedProfile} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_ProfilesCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={profilesCollapsed ? "standard" : "none"} - onClick={() => setProfilesCollapsed(!profilesCollapsed)} - > - {profilesCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!profilesCollapsed && ( - <> - <PanelSectionRow> - <Field - label="" - childrenLayout="below" - childrenContainerWidth="max" - bottomSeparator="none" - > - <Dropdown - rgOptions={profileOptions} - selectedOption={selectedProfile} - onChange={handleDropdownChange} - disabled={isLoading || !!mainRunningApp} - /> - </Field> - </PanelSectionRow> - - <PanelSectionRow> - <Focusable - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - width: "100%", - padding: "0", - margin: "0", - marginTop: "8px" - }} - flow-children="horizontal" - > - <DialogButton - style={{ - height: "40px", - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "10px", - minWidth: "0", - }} - onClick={handleRenameProfile} - disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp} - > - <RiEditLine size={20} /> - </DialogButton> - - <DialogButton - style={{ - height: "40px", - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "10px", - minWidth: "0", - }} - onClick={handleDeleteProfile} - disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp} - > - <RiDeleteBinLine size={20} /> - </DialogButton> - </Focusable> - </PanelSectionRow> - </> - )} - </> - ); -} diff --git a/src/components/index.ts b/src/components/index.ts index bec45ae..4284aee 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,4 +8,4 @@ export { SmartClipboardButton } from "./SmartClipboardButton"; export { FgmodClipboardButton } from "./FgmodClipboardButton"; export { NerdStuffModal } from "./NerdStuffModal"; export { FlatpaksModal } from "./FlatpaksModal"; -export { ProfileManagement } from "./ProfileManagement"; +export { GameConfigurationSelector } from "./GameConfigurationSelector"; diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index befbd8d..bd72e8c 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -6,8 +6,6 @@ export { getFieldNames, getDefaults, getFieldTypes, - DLL, NO_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, ENABLE_WOW64, - DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, - FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK + DLL, NO_FP16, ACTIVE_IN, PACING_MODE, MULTIPLIER, FLOW_SCALE, + PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from './generatedConfigSchema'; diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index edbde88..71459eb 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -1,182 +1,31 @@ -// src/config/generatedConfigSchema.ts -// Configuration field type enum - matches Python -export enum ConfigFieldType { - BOOLEAN = "boolean", - INTEGER = "integer", - FLOAT = "float", - STRING = "string" -} +export enum ConfigFieldType { BOOLEAN = "boolean", INTEGER = "integer", FLOAT = "float", STRING = "string", ARRAY = "array" } -// Field name constants for type-safe access export const DLL = "dll" as const; export const NO_FP16 = "no_fp16" as const; +export const ACTIVE_IN = "active_in" as const; +export const PACING_MODE = "pacing_mode" as const; export const MULTIPLIER = "multiplier" as const; export const FLOW_SCALE = "flow_scale" as const; export const PERFORMANCE_MODE = "performance_mode" as const; -export const EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const; -export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const; -export const ENABLE_WOW64 = "enable_wow64" as const; -export const DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" as const; -export const MANGOHUD_WORKAROUND = "mangohud_workaround" as const; -export const DISABLE_VKBASALT = "disable_vkbasalt" as const; -export const FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt" as const; -export const ENABLE_WSI = "enable_wsi" as const; -export const ENABLE_ZINK = "enable_zink" as const; +export const OVERRIDE_PRESENT_MODE = "override_present_mode" as const; +export const PRESERVE_SWAPCHAIN_IMAGE_COUNT = "preserve_swapchain_image_count" as const; -// Configuration field definition -export interface ConfigField { - name: string; - fieldType: ConfigFieldType; - default: boolean | number | string; - description: string; +export interface ConfigField { name: string; fieldType: ConfigFieldType; default: boolean | number | string | string[]; description: string; } +export interface ConfigurationData { + dll: string; no_fp16: boolean; active_in: string[]; pacing_mode: string; multiplier: number; + flow_scale: number; performance_mode: boolean; override_present_mode: boolean; preserve_swapchain_image_count: boolean; } - -// Configuration schema - auto-generated from Python export const CONFIG_SCHEMA: Record<string, ConfigField> = { - dll: { - name: "dll", - fieldType: ConfigFieldType.STRING, - default: "", - description: "override the lsfg-vk.dll path" - }, - no_fp16: { - name: "no_fp16", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "disable FP16 acceleration" - }, - multiplier: { - name: "multiplier", - fieldType: ConfigFieldType.INTEGER, - default: 1, - description: "frame generation multiplier" - }, - flow_scale: { - name: "flow_scale", - fieldType: ConfigFieldType.FLOAT, - default: 1, - description: "motion estimation resolution scale" - }, - performance_mode: { - name: "performance_mode", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "use the lighter frame generation model" - }, - experimental_present_mode: { - name: "experimental_present_mode", - fieldType: ConfigFieldType.STRING, - default: "fifo", - description: "control the v2 present mode override" - }, - dxvk_frame_rate: { - name: "dxvk_frame_rate", - fieldType: ConfigFieldType.INTEGER, - default: 0, - description: "base framerate cap for DirectX games before frame multiplier" - }, - enable_wow64: { - name: "enable_wow64", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable PROTON_USE_WOW64=1 for 32-bit games" - }, - disable_steamdeck_mode: { - name: "disable_steamdeck_mode", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "disable Steam Deck mode" - }, - mangohud_workaround: { - name: "mangohud_workaround", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable a transparent MangoHud overlay workaround" - }, - disable_vkbasalt: { - name: "disable_vkbasalt", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "disable vkBasalt for games where it conflicts with lsfg-vk" - }, - force_enable_vkbasalt: { - name: "force_enable_vkbasalt", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "force-enable vkBasalt" - }, - enable_wsi: { - name: "enable_wsi", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable the Gamescope WSI layer" - }, - enable_zink: { - name: "enable_zink", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable Zink for OpenGL games" - }, + dll: { name: "dll", fieldType: ConfigFieldType.STRING, default: "", description: "Override the lsfg-vk.dll path" }, + no_fp16: { name: "no_fp16", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Disable FP16 acceleration" }, + active_in: { name: "active_in", fieldType: ConfigFieldType.ARRAY, default: [], description: "Steam AppID or executable identifiers" }, + pacing_mode: { name: "pacing_mode", fieldType: ConfigFieldType.STRING, default: "vsync", description: "Frame pacing mode" }, + multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 2, description: "Frame generation multiplier" }, + flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 1, description: "Motion estimation resolution scale" }, + performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Use the lighter frame generation model" }, + override_present_mode: { name: "override_present_mode", fieldType: ConfigFieldType.BOOLEAN, default: true, description: "Override present mode" }, + preserve_swapchain_image_count: { name: "preserve_swapchain_image_count", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Preserve the swapchain image count" }, }; - -// Type-safe configuration data structure -export interface ConfigurationData { - dll: string; - no_fp16: boolean; - multiplier: number; - flow_scale: number; - performance_mode: boolean; - experimental_present_mode: string; - dxvk_frame_rate: number; - enable_wow64: boolean; - disable_steamdeck_mode: boolean; - mangohud_workaround: boolean; - disable_vkbasalt: boolean; - force_enable_vkbasalt: boolean; - enable_wsi: boolean; - enable_zink: boolean; -} - -// Helper functions -export function getFieldNames(): string[] { - return Object.keys(CONFIG_SCHEMA); -} - -export function getDefaults(): ConfigurationData { - return { - dll: "", - no_fp16: false, - multiplier: 1, - flow_scale: 1, - performance_mode: false, - experimental_present_mode: "fifo", - dxvk_frame_rate: 0, - enable_wow64: false, - disable_steamdeck_mode: false, - mangohud_workaround: false, - disable_vkbasalt: false, - force_enable_vkbasalt: false, - enable_wsi: false, - enable_zink: false, - }; -} - -export function getFieldTypes(): Record<string, ConfigFieldType> { - return { - dll: ConfigFieldType.STRING, - no_fp16: ConfigFieldType.BOOLEAN, - multiplier: ConfigFieldType.INTEGER, - flow_scale: ConfigFieldType.FLOAT, - performance_mode: ConfigFieldType.BOOLEAN, - experimental_present_mode: ConfigFieldType.STRING, - dxvk_frame_rate: ConfigFieldType.INTEGER, - enable_wow64: ConfigFieldType.BOOLEAN, - disable_steamdeck_mode: ConfigFieldType.BOOLEAN, - mangohud_workaround: ConfigFieldType.BOOLEAN, - disable_vkbasalt: ConfigFieldType.BOOLEAN, - force_enable_vkbasalt: ConfigFieldType.BOOLEAN, - enable_wsi: ConfigFieldType.BOOLEAN, - enable_zink: ConfigFieldType.BOOLEAN, - }; -} - +export function getFieldNames(): string[] { return Object.keys(CONFIG_SCHEMA); } +export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 1, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; } +export function getFieldTypes(): Record<string, ConfigFieldType> { return Object.fromEntries(Object.entries(CONFIG_SCHEMA).map(([key, value]) => [key, value.fieldType])); } diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts new file mode 100644 index 0000000..e0ba360 --- /dev/null +++ b/src/hooks/useGameConfiguration.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Router } from "@decky/ui"; +import { getGameConfigs, getInstalledGames, updateGameConfig, updateLsfgConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type InstalledGame } from "../api/lsfgApi"; +import { ConfigurationData, getDefaults } from "../config/configSchema"; + +export interface GameTarget { appid: string; name: string; configured: boolean; } + +export function useGameConfiguration() { + const [defaultConfig, setDefaultConfig] = useState<ConfigurationData>(getDefaults()); + const [games, setGames] = useState<GameConfigEntry[]>([]); + const [installedGames, setInstalledGames] = useState<InstalledGame[]>([]); + const [selectedAppId, setSelectedAppId] = useState(""); + const [runningGame, setRunningGame] = useState<GameTarget | null>(null); + const autoSelected = useRef(false); + + const load = useCallback(async () => { + const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]); + if (result.success) { + setDefaultConfig(result.default || getDefaults()); + setGames(result.games || []); + } + if (installed.success) setInstalledGames(installed.games || []); + }, []); + + useEffect(() => { load(); }, [load]); + useEffect(() => { + const poll = () => { + const app = Router.MainRunningApp as any; + if (app?.appid) setRunningGame({ appid: String(app.appid), name: app.display_name || `App ${app.appid}`, configured: games.some((game) => game.appid === String(app.appid)) }); + else setRunningGame(null); + }; + poll(); + const interval = window.setInterval(poll, 2000); + return () => window.clearInterval(interval); + }, [games]); + useEffect(() => { + if (!autoSelected.current && runningGame) { + autoSelected.current = true; + setSelectedAppId(runningGame.appid); + } + }, [runningGame]); + + const targets = useMemo<GameTarget[]>(() => { + const configured = installedGames.map((game) => ({ appid: game.appid, name: game.name, configured: games.some((item) => item.appid === game.appid) })); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: `App ${game.appid}`, configured: true }); + if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); + return configured; + }, [games, installedGames, runningGame]); + const selected = selectedAppId ? games.find((game) => game.appid === selectedAppId)?.config : defaultConfig; + const config = selected || defaultConfig; + + const save = useCallback(async (next: ConfigurationData) => { + if (!selectedAppId) { + const result = await updateLsfgConfig(next); + if (result.success) setDefaultConfig(next); + return; + } + const result = await updateGameConfig(selectedAppId, next); + if (result.success) await load(); + }, [load, selectedAppId]); + + const resetSelected = useCallback(async () => { + if (selectedAppId) { await resetGameConfig(selectedAppId); setSelectedAppId(""); await load(); } + }, [load, selectedAppId]); + const resetAll = useCallback(async () => { await resetAllGameConfigs(); setSelectedAppId(""); await load(); }, [load]); + + return { config, defaultConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload: load }; +} diff --git a/src/hooks/useProfileManagement.ts b/src/hooks/useProfileManagement.ts deleted file mode 100644 index a5f2a07..0000000 --- a/src/hooks/useProfileManagement.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - updateProfileConfig, - type ProfilesResult, - type ProfileResult, - type ConfigUpdateResult -} from "../api/lsfgApi"; -import { ConfigurationData } from "../config/configSchema"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; - -export function useProfileManagement() { - const [profiles, setProfiles] = useState<string[]>([]); - const [currentProfile, setCurrentProfileState] = useState<string>("decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - - // Load profiles on hook initialization - const loadProfiles = useCallback(async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setCurrentProfileState(result.current_profile); - } - return result; - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - return { success: false, error: String(error) }; - } - }, []); - - // Create a new profile - const handleCreateProfile = useCallback(async (profileName: string, sourceProfile?: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, sourceProfile || currentProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - return result; - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Delete a profile - const handleDeleteProfile = useCallback(async (profileName: string) => { - if (profileName === "decky-lsfg-vk") { - showErrorToast("Cannot delete default profile", "The default profile cannot be deleted"); - return { success: false, error: "Cannot delete default profile" }; - } - - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(profileName); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${profileName}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - if (currentProfile === profileName) { - setCurrentProfileState("decky-lsfg-vk"); - } - return result; - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Rename a profile - const handleRenameProfile = useCallback(async (oldName: string, newName: string) => { - if (oldName === "decky-lsfg-vk") { - showErrorToast("Cannot rename default profile", "The default profile cannot be renamed"); - return { success: false, error: "Cannot rename default profile" }; - } - - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(oldName, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - // Update current profile if it was renamed - if (currentProfile === oldName) { - setCurrentProfileState(actualNewName); - } - return result; - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Set the current active profile - const handleSetCurrentProfile = useCallback(async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setCurrentProfileState(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - return result; - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, []); - - // Update configuration for a specific profile - const handleUpdateProfileConfig = useCallback(async (profileName: string, config: ConfigurationData) => { - setIsLoading(true); - try { - const result: ConfigUpdateResult = await updateProfileConfig(profileName, config); - if (result.success) { - return result; - } else { - console.error("Failed to update profile config:", result.error); - showErrorToast("Failed to update profile config", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error updating profile config:", error); - showErrorToast("Error updating profile config", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile]); - - // Initialize profiles on mount - useEffect(() => { - loadProfiles(); - }, [loadProfiles]); - - return { - profiles, - currentProfile, - isLoading, - loadProfiles, - createProfile: handleCreateProfile, - deleteProfile: handleDeleteProfile, - renameProfile: handleRenameProfile, - setCurrentProfile: handleSetCurrentProfile, - updateProfileConfig: handleUpdateProfileConfig - }; -} |
