diff options
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | package.json | 23 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema.py | 6 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema_generated.py | 8 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 7 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 290 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 27 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 23 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/types.py | 1 | ||||
| -rw-r--r-- | scripts/generate_python_boilerplate.py | 1 | ||||
| -rw-r--r-- | shared_config.py | 7 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 12 | ||||
| -rw-r--r-- | src/components/ConfigurationSection.tsx | 13 | ||||
| -rw-r--r-- | src/components/FlatpaksModal.tsx | 8 | ||||
| -rw-r--r-- | src/config/configSchema.ts | 2 | ||||
| -rw-r--r-- | src/config/generatedConfigSchema.ts | 10 | ||||
| -rw-r--r-- | src/hooks/useInstallationActions.ts | 4 | ||||
| -rw-r--r-- | src/utils/toastUtils.ts | 4 | ||||
| -rw-r--r-- | tests/test_lsfg_v2_migration.py | 304 |
19 files changed, 679 insertions, 80 deletions
@@ -18,7 +18,7 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://github.com/PancakeTAS/lsfg-vk)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. -> The v2 layer payload is currently pinned to the owner-fork prerelease [`v2.0.0-decky.1`](https://github.com/xXJSONDeruloXx/lsfg-vk/releases/tag/v2.0.0-decky.1). +> The v2 native layer and Flatpak runtime payloads are pinned to the owner-fork prerelease [`v2.0.0-decky.2`](https://github.com/xXJSONDeruloXx/lsfg-vk/releases/tag/v2.0.0-decky.2). ## Installation @@ -51,6 +51,7 @@ The plugin provides several configuration options to optimize frame generation f - **Performance Mode**: Uses a lighter processing model - **Allow FP16**: Enable half-precision acceleration; disable it for older NVIDIA GPUs - **Active In**: Optionally match a profile to executable names, Wine executables, or process names +- **Automatic Profile Matching**: Explicitly let lsfg-vk choose an `Active In` profile; otherwise the Decky-selected profile is always forced - **GPU**: Optionally select the GPU by name, vendor/device ID, or PCI bus ID - **Disable Frame Generation**: Disable the layer for the next launch without creating an invalid 1x profile @@ -64,7 +65,7 @@ For per-game feedback and community support, please join the [decky-lsfg-vk Disc - Ensure you've added `~/lsfg %command%` to your game's launch options - Check that the Lossless Scaling DLL was detected correctly in the plugin - Try enabling Performance Mode if you're experiencing crashes -- Ensure the selected profile has no `Active In` value when you want the launcher to force that profile +- Disable **Automatic Profile Matching** to force the profile selected in Decky, even when it has an `Active In` value - Make sure your game is running in fullscreen mode for best results **Performance issues?** @@ -77,6 +78,8 @@ For per-game feedback and community support, please join the [decky-lsfg-vk Disc The plugin: - Downloads checksum-pinned x86_64 or Armada/aarch64 lsfg-vk v2 assets from the owner-fork release to `~/.local/lib/` +- Bundles checksum-pinned lsfg-vk v2 Flatpak extensions for runtimes 23.08, 24.08, and 25.08, and installs the selected local bundle +- Updates already-installed user Flatpak runtimes and migrates the plugin's legacy app overrides when the native layer is updated - Configures the v2 Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` - Migrates the existing `~/.config/lsfg-vk/conf.toml` from v1 to v2 automatically on layer update, retaining a one-time `.v1.bak` backup - Automatically detects your Lossless Scaling DLL installation @@ -84,7 +87,7 @@ The plugin: - **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality vs performance - **Performance Mode**: Use lighter processing for better performance - - **FP16, Active In, and GPU**: Use v2 profile parameters and native matching + - **FP16, Active In, GPU, and explicit automatic matching**: Use v2 profile parameters without ambiguous profile selection - **Launch workarounds**: Preserve existing DXVK, Gamescope, Zink, MangoHud, and vkBasalt launch options - **Hot-reloading**: Multiplier, flow scale, and performance-mode changes are reloaded while games run; other changes may require a swapchain recreation or restart - Easy uninstallation that removes all installed files when no longer needed diff --git a/package.json b/package.json index 75bf2f7..36bf105 100644 --- a/package.json +++ b/package.json @@ -46,13 +46,28 @@ "remote_binary": [ { "name": "lsfg-vk-layer-x86_64.tar.xz", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.1/lsfg-vk-layer-x86_64.tar.xz", - "sha256hash": "b96c60d838cd778797406bafc31e17a3698e8145248113c2bc41151b2823a6a0" + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/lsfg-vk-layer-x86_64.tar.xz", + "sha256hash": "3bf5baaf391c6240de9978161a68aa8c9a54eff4246b79f9ea5b2ad1ec1a53a4" }, { "name": "lsfg-vk-layer-aarch64.tar.xz", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.1/lsfg-vk-layer-aarch64.tar.xz", - "sha256hash": "b1703042f50ff9470aa0b90953077949a9f144dcbd926f80fbb7e386e8393367" + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/lsfg-vk-layer-aarch64.tar.xz", + "sha256hash": "ce08a04dc78ed1b1ca4bd03b9cf6c97ee233b0f5b2c2cc514509fae7e2eec412" + }, + { + "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", + "sha256hash": "7eff81f96b278fde6fe395c88461c55e611547bf5991d179b9145ec6f5a778b1" + }, + { + "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", + "sha256hash": "cbd663b9021c355dddec61ec51fd4388c12705f16637cadd4b45aead0e5dc427" + }, + { + "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.2/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", + "sha256hash": "615e87c03b18368ed1cca97e036f1bf54fff95fc6126aac697bb368f33281cbf" } ], "pnpm": { diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 258b9dd..f8a9fe6 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -259,9 +259,13 @@ class ConfigurationManager: 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"] + new_profile = dict(profile_data["profiles"][source]) + # A cloned profile may inherit Active In values that collide with its source. + # Force the newly selected profile until the user explicitly enables native matching. + new_profile["use_native_matching"] = False return ProfileData( current_profile=profile_data["current_profile"], - profiles={**profile_data["profiles"], normalized: dict(profile_data["profiles"][source])}, + profiles={**profile_data["profiles"], normalized: new_profile}, global_config=dict(profile_data["global_config"]), ) diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py index 4c37c90..9cfda51 100644 --- a/py_modules/lsfg_vk/config_schema_generated.py +++ b/py_modules/lsfg_vk/config_schema_generated.py @@ -20,6 +20,7 @@ FLOW_SCALE = "flow_scale" PERFORMANCE_MODE = "performance_mode" PACING = "pacing" ACTIVE_IN = "active_in" +USE_NATIVE_MATCHING = "use_native_matching" GPU = "gpu" DISABLE_LSFGVK = "disable_lsfgvk" DXVK_FRAME_RATE = "dxvk_frame_rate" @@ -41,6 +42,7 @@ class ConfigurationData(TypedDict): performance_mode: bool pacing: str active_in: str + use_native_matching: bool gpu: str disable_lsfgvk: bool dxvk_frame_rate: int @@ -68,6 +70,8 @@ def get_script_parsing_logic(): value = value.strip() # Auto-generated parsing logic: + if key == "DECKY_LSFGVK_AUTO_PROFILE": + script_values["use_native_matching"] = value == "1" if key == "DISABLE_LSFGVK": script_values["disable_lsfgvk"] = value == "1" if key == "DXVK_FRAME_RATE": @@ -102,6 +106,8 @@ def get_script_generation_logic(): """Return the script generation logic as a callable""" def generate_script_lines(config): lines = [] + if config.get("use_native_matching", False): + lines.append("export DECKY_LSFGVK_AUTO_PROFILE=1") if config.get("disable_lsfgvk", False): lines.append("export DISABLE_LSFGVK=1") dxvk_frame_rate = config.get("dxvk_frame_rate", 0) @@ -127,4 +133,4 @@ def get_script_generation_logic(): return generate_script_lines -ALL_FIELDS = ['dll', 'allow_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'pacing', 'active_in', 'gpu', 'disable_lsfgvk', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] +ALL_FIELDS = ['dll', 'allow_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'pacing', 'active_in', 'use_native_matching', 'gpu', 'disable_lsfgvk', '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 446ac3a..6511745 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -18,10 +18,9 @@ class ConfigurationService(BaseService): @staticmethod def _profile_selection_lines(profile_name: str, config: ConfigurationData) -> list[str]: - """Use native matching when a selected profile declares active_in.""" - active_in = str(config.get("active_in", "")).strip() - if active_in: - return ["# active_in is configured; lsfg-vk will select a matching profile automatically."] + """Force the selected profile unless the user explicitly opts into native matching.""" + if config.get("use_native_matching", False): + return ["# lsfg-vk will select a matching profile from Active In."] return [f"export LSFGVK_PROFILE={shlex.quote(profile_name)}"] def get_config(self) -> ConfigurationResponse: diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 7f7ef07..fe965aa 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -8,9 +8,19 @@ from pathlib import Path from typing import Dict, Any, List, Optional from .base_service import BaseService +from .config_schema import ConfigurationManager +from .constants import ( + BIN_DIR, + FLATPAK_23_08_FILENAME, + FLATPAK_24_08_FILENAME, + FLATPAK_25_08_FILENAME, +) from .types import BaseResponse +SUPPORTED_FLATPAK_VERSIONS = ("23.08", "24.08", "25.08") + + class FlatpakExtensionStatus(BaseResponse): """Response for Flatpak extension status""" def __init__(self, success: bool = False, message: str = "", error: str = "", @@ -66,6 +76,55 @@ class FlatpakService(BaseService): self.log.debug("Could not read configured DLL path for Flatpak override: %s", error) return config_path, dll_directory + def _get_bundled_extension_path(self, version: str) -> Path: + """Return the checksum-pinned Flatpak bundle shipped with this plugin.""" + filenames = { + "23.08": FLATPAK_23_08_FILENAME, + "24.08": FLATPAK_24_08_FILENAME, + "25.08": FLATPAK_25_08_FILENAME, + } + try: + filename = filenames[version] + except KeyError as error: + raise ValueError(f"Unsupported Flatpak runtime version: {version}") from error + + plugin_dir = Path(__file__).resolve().parent.parent.parent + return plugin_dir / BIN_DIR / filename + + def _get_installed_extension_versions(self) -> list[str]: + """Return installed lsfg-vk runtime branches from the user Flatpak installation.""" + result = self._run_flatpak_command( + ["list", "--user", "--runtime"], + capture_output=True, + text=True, + check=True, + ) + installed_runtimes = result.stdout + base_extension_name = "org.freedesktop.Platform.VulkanLayer.lsfgvk" + return [ + version + for version in SUPPORTED_FLATPAK_VERSIONS + if any( + base_extension_name in line and version in line + for line in installed_runtimes.splitlines() + ) + ] + + def _remove_legacy_app_overrides(self, app_id: str) -> list[str]: + """Remove only the v1 overrides previously created by this plugin.""" + legacy_dll_path = self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" + legacy_overrides = [ + ["override", "--user", "--unset-env=LSFG_CONFIG", app_id], + ["override", "--user", f"--nofilesystem={legacy_dll_path}", app_id], + ["override", "--user", f"--nofilesystem={self.lsfg_launch_script_path}", app_id], + ] + errors = [] + for args in legacy_overrides: + result = self._run_flatpak_command(args, capture_output=True, text=True) + if result.returncode != 0: + errors.append(f"{' '.join(args[2:-1])}: {result.stderr}") + return errors + def _get_clean_env(self): """Get a clean environment without PyInstaller's bundled libraries""" env = os.environ.copy() @@ -138,26 +197,10 @@ class FlatpakService(BaseService): error_msg, installed_23_08=False, installed_24_08=False, installed_25_08=False) - result = self._run_flatpak_command( - ["list", "--runtime"], - capture_output=True, text=True, check=True - ) - - installed_runtimes = result.stdout - - base_extension_name = "org.freedesktop.Platform.VulkanLayer.lsfgvk" - installed_23_08 = False - installed_24_08 = False - installed_25_08 = False - - for line in installed_runtimes.split('\n'): - if base_extension_name in line: - if "23.08" in line: - installed_23_08 = True - elif "24.08" in line: - installed_24_08 = True - elif "25.08" in line: - installed_25_08 = True + installed_versions = self._get_installed_extension_versions() + installed_23_08 = "23.08" in installed_versions + installed_24_08 = "24.08" in installed_versions + installed_25_08 = "25.08" in installed_versions status_msg = [] if installed_23_08: @@ -183,22 +226,29 @@ class FlatpakService(BaseService): installed_23_08=False, installed_24_08=False, installed_25_08=False) def install_extension(self, version: str) -> BaseResponse: - """Install a specific version of the lsfg-vk Flatpak extension""" + """Install or update a specific version of the lsfg-vk Flatpak extension.""" try: - if version not in ["23.08", "24.08", "25.08"]: + if version not in SUPPORTED_FLATPAK_VERSIONS: return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'") if not self.check_flatpak_available(): return self._error_response(BaseResponse, "Flatpak is not available on this system") - if version == "23.08": - return self._error_response( - BaseResponse, - "Flathub supplies the lsfg-vk v2 extension for runtimes 24.08 and 25.08; install 23.08 manually.", - ) + bundle_path = self._get_bundled_extension_path(version) + if not bundle_path.is_file(): + error_msg = f"Bundled Flatpak extension not found at {bundle_path}; reinstall the plugin" + self.log.error(error_msg) + return self._error_response(BaseResponse, error_msg) result = self._run_flatpak_command( - ["install", "--user", "--noninteractive", "flathub", f"org.freedesktop.Platform.VulkanLayer.lsfgvk//{version}"], + [ + "install", + "--user", + "--or-update", + "--assumeyes", + "--noninteractive", + str(bundle_path), + ], capture_output=True, text=True ) @@ -207,18 +257,172 @@ class FlatpakService(BaseService): self.log.error(error_msg) return self._error_response(BaseResponse, error_msg) - self.log.info(f"Successfully installed lsfg-vk Flatpak extension {version}") - return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension installed successfully") + self.log.info("Successfully installed bundled lsfg-vk Flatpak extension %s", version) + return self._success_response( + BaseResponse, + f"lsfg-vk {version} runtime extension installed successfully from the bundled asset", + ) except Exception as e: error_msg = f"Error installing Flatpak extension {version}: {str(e)}" self.log.error(error_msg) return self._error_response(BaseResponse, error_msg) + def _get_flatpak_app_ids(self) -> list[str]: + """Return application IDs in the user Flatpak installation.""" + result = self._run_flatpak_command( + ["list", "--app"], + capture_output=True, + text=True, + check=True, + ) + app_ids = [] + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) >= 2 and parts[1].strip(): + app_ids.append(parts[1].strip()) + return app_ids + + def _get_app_override_output(self, app_id: str) -> Optional[str]: + """Return an app's user override output, or None if it cannot be read.""" + result = self._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + return result.stdout + + def _has_legacy_app_override(self, output: str) -> bool: + """Detect only overrides written by the v1 plugin.""" + config_path, _ = self._get_lsfg_paths() + legacy_dll_path = self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" + legacy_config = f"LSFG_CONFIG={config_path}/conf.toml" + return ( + legacy_config in output + or str(legacy_dll_path) in output + or str(self.lsfg_launch_script_path) in output + ) + + def _migrate_legacy_app_overrides(self) -> Dict[str, Any]: + """Upgrade v1 app overrides without changing unrelated Flatpak apps.""" + migrated_apps = [] + failed_apps = [] + try: + for app_id in self._get_flatpak_app_ids(): + output = self._get_app_override_output(app_id) + if output is None or not self._has_legacy_app_override(output): + continue + + result = self.set_app_override(app_id) + if result.get("success"): + migrated_apps.append(app_id) + else: + failed_apps.append({"app_id": app_id, "error": result.get("error", "unknown error")}) + except (subprocess.CalledProcessError, OSError) as error: + stderr = getattr(error, "stderr", None) + return self._error_response( + BaseResponse, + f"Could not inspect Flatpak applications: {stderr or error}", + migrated_apps=migrated_apps, + failed_apps=failed_apps, + ) + + if failed_apps: + return self._error_response( + BaseResponse, + f"Failed to migrate overrides for {len(failed_apps)} Flatpak application(s)", + migrated_apps=migrated_apps, + failed_apps=failed_apps, + ) + return self._success_response( + BaseResponse, + f"Migrated {len(migrated_apps)} legacy Flatpak app override(s)", + migrated_apps=migrated_apps, + failed_apps=failed_apps, + ) + + def update_installed_extensions(self) -> Dict[str, Any]: + """Update existing user Flatpak extensions and migrate their v1 app overrides.""" + empty_result = { + "updated_versions": [], + "failed_versions": [], + "migrated_apps": [], + "failed_apps": [], + } + try: + if not self.check_flatpak_available(): + return self._success_response( + BaseResponse, + "Flatpak is unavailable; skipped runtime and app-override migration", + skipped=True, + **empty_result, + ) + + installed_versions = self._get_installed_extension_versions() + if not installed_versions: + return self._success_response( + BaseResponse, + "No installed lsfg-vk Flatpak runtimes require migration", + skipped=True, + **empty_result, + ) + + updated_versions = [] + failed_versions = [] + for version in installed_versions: + result = self.install_extension(version) + if result.get("success"): + updated_versions.append(version) + else: + failed_versions.append({"version": version, "error": result.get("error", "unknown error")}) + + if failed_versions: + return self._error_response( + BaseResponse, + "Flatpak runtime migration failed; legacy app overrides were left unchanged", + skipped=False, + updated_versions=updated_versions, + failed_versions=failed_versions, + migrated_apps=[], + failed_apps=[], + ) + + override_result = self._migrate_legacy_app_overrides() + response = { + "updated_versions": updated_versions, + "failed_versions": [], + "migrated_apps": override_result.get("migrated_apps", []), + "failed_apps": override_result.get("failed_apps", []), + } + if not override_result.get("success"): + return self._error_response( + BaseResponse, + override_result.get("error", "Flatpak app override migration failed"), + skipped=False, + **response, + ) + return self._success_response( + BaseResponse, + f"Updated {len(updated_versions)} Flatpak runtime(s); " + f"migrated {len(response['migrated_apps'])} app override(s)", + skipped=False, + **response, + ) + except (subprocess.CalledProcessError, OSError) as error: + stderr = getattr(error, "stderr", None) + return self._error_response( + BaseResponse, + f"Could not migrate installed Flatpak runtimes: {stderr or error}", + skipped=False, + **empty_result, + ) + def uninstall_extension(self, version: str) -> BaseResponse: """Uninstall a specific version of the lsfg-vk Flatpak extension""" try: - if version not in ["23.08", "24.08", "25.08"]: + if version not in SUPPORTED_FLATPAK_VERSIONS: return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'") if not self.check_flatpak_available(): @@ -261,7 +465,7 @@ class FlatpakService(BaseService): apps=[], total_apps=0) result = self._run_flatpak_command( - ["list", "--app"], + ["list", "--user", "--app"], capture_output=True, text=True, check=True ) @@ -297,15 +501,9 @@ class FlatpakService(BaseService): def _check_app_override_status(self, app_id: str) -> Dict[str, bool]: """Check if an app has lsfg-vk overrides set""" try: - result = self._run_flatpak_command( - ["override", "--user", "--show", app_id], - capture_output=True, text=True - ) - - if result.returncode != 0: + output = self._get_app_override_output(app_id) + if output is None: return {"filesystem": False, "env": False} - - output = result.stdout config_path, dll_directory = self._get_lsfg_paths() filesystem_section = "" @@ -382,6 +580,14 @@ class FlatpakService(BaseService): return self._error_response(FlatpakOverrideResponse, error_msg, app_id=app_id, operation="set") + legacy_errors = self._remove_legacy_app_overrides(app_id) + if legacy_errors: + self.log.warning( + "Applied v2 overrides for %s but could not fully remove v1 overrides: %s", + app_id, + "; ".join(legacy_errors), + ) + self.log.info(f"Successfully set lsfg-vk overrides for {app_id}") return self._success_response(FlatpakOverrideResponse, f"lsfg-vk overrides set for {app_id}", @@ -427,6 +633,8 @@ class FlatpakService(BaseService): if result.returncode != 0: removal_errors.append(f"unset-env: {result.stderr}") + removal_errors.extend(self._remove_legacy_app_overrides(app_id)) + if removal_errors: self.log.warning(f"Some override removals had issues for {app_id}: {'; '.join(removal_errors)}") @@ -439,4 +647,4 @@ class FlatpakService(BaseService): error_msg = f"Error removing overrides for {app_id}: {str(e)}" self.log.error(error_msg) return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="remove")
\ No newline at end of file + app_id=app_id, operation="remove") diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index cb6b161..7eee848 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -32,6 +32,7 @@ class InstallationService(BaseService): self.json_file = self.local_share_dir / JSON_FILENAME self.legacy_lib_file = self.local_lib_dir / LEGACY_LIB_FILENAME self.legacy_json_file = self.local_share_dir / LEGACY_JSON_FILENAME + self._config_recovery_backup: Path | None = None def install(self) -> InstallationResponse: try: @@ -49,8 +50,11 @@ class InstallationService(BaseService): self._create_lsfg_launch_script(profile_data) self._remove_legacy_layer_files() + message = "lsfg-vk v2 installed successfully" + if self._config_recovery_backup is not None: + message += f"; unsupported config backed up to {self._config_recovery_backup.name}" self.log.info("lsfg-vk v2 installed successfully from %s", archive_name) - return self._success_response(InstallationResponse, "lsfg-vk v2 installed successfully") + return self._success_response(InstallationResponse, message) except (OSError, tarfile.TarError, shutil.Error) as error: self.log.error("Error installing lsfg-vk: %s", error) return self._error_response(InstallationResponse, str(error), message="") @@ -136,13 +140,19 @@ class InstallationService(BaseService): dll_service = DllDetectionService(self.log) profile_data: ProfileData was_legacy = False + self._config_recovery_backup = None if self.config_file_path.exists(): content = self.config_file_path.read_text(encoding="utf-8") was_legacy = ConfigurationManager.is_legacy_v1(content) try: profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) except (ValueError, KeyError, TypeError) as error: - self.log.warning("Could not parse existing config, using defaults: %s", error) + self._config_recovery_backup = self._backup_config(content, "unrecognized") + self.log.warning( + "Could not parse existing config; saved it to %s and using defaults: %s", + self._config_recovery_backup, + error, + ) default = ConfigurationManager.get_defaults_with_dll_detection(dll_service) profile_data = ProfileData( current_profile="decky-lsfg-vk", @@ -172,12 +182,15 @@ class InstallationService(BaseService): ) return profile_data + def _backup_config(self, content: str, label: str) -> Path: + backup_path = self.config_file_path.with_name(f"{self.config_file_path.name}.{label}.bak") + if not backup_path.exists(): + self._write_file(backup_path, content, 0o644) + self.log.info("Backed up %s configuration to %s", label, backup_path) + return backup_path + def _backup_legacy_config(self, content: str) -> None: - backup_path = self.config_file_path.with_name(f"{self.config_file_path.name}.v1.bak") - if backup_path.exists(): - return - self._write_file(backup_path, content, 0o644) - self.log.info("Backed up v1 configuration to %s", backup_path) + self._backup_config(content, "v1") def _read_script_values(self) -> Dict[str, Any]: if not self.lsfg_launch_script_path.exists(): diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index aefec3e..1b99501 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -37,12 +37,23 @@ class Plugin: self.flatpak_service = FlatpakService() async def install_lsfg_vk(self) -> Dict[str, Any]: - """Install lsfg-vk by extracting the zip file to ~/.local + """Install/update the native layer and migrate installed Flatpak integrations. Returns: InstallationResponse dict with success status and message/error """ - return self.installation_service.install() + installation_result = self.installation_service.install() + if not installation_result.get("success"): + return installation_result + + flatpak_result = self.flatpak_service.update_installed_extensions() + installation_result["flatpak_update"] = flatpak_result + if not flatpak_result.get("success") and not flatpak_result.get("skipped"): + installation_result["message"] = ( + f"{installation_result.get('message', 'lsfg-vk updated successfully')}; " + f"Flatpak migration warning: {flatpak_result.get('error', 'unknown error')}" + ) + return installation_result async def check_lsfg_vk_installed(self) -> Dict[str, Any]: """Check if lsfg-vk is already installed @@ -354,15 +365,15 @@ class Plugin: """Check status of lsfg-vk Flatpak runtime extensions Returns: - FlatpakExtensionStatus dict with installation status for both runtime versions + FlatpakExtensionStatus dict with installation status for all supported runtime versions """ return self.flatpak_service.get_extension_status() async def install_flatpak_extension(self, version: str) -> Dict[str, Any]: - """Install lsfg-vk Flatpak runtime extension + """Install or update an lsfg-vk Flatpak runtime extension Args: - version: Runtime version to install ("23.08" or "24.08") + version: Runtime version to install ("23.08", "24.08", or "25.08") Returns: BaseResponse dict with success status and message/error @@ -373,7 +384,7 @@ class Plugin: """Uninstall lsfg-vk Flatpak runtime extension Args: - version: Runtime version to uninstall ("23.08" or "24.08") + version: Runtime version to uninstall ("23.08", "24.08", or "25.08") Returns: BaseResponse dict with success status and message/error diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 6f3cd05..3210d25 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -25,6 +25,7 @@ class InstallationResponse(BaseResponse): """Response for installation operations""" message: str error: Optional[str] + flatpak_update: Optional[Dict[str, Any]] class UninstallationResponse(BaseResponse): diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py index 1445b79..5027023 100644 --- a/scripts/generate_python_boilerplate.py +++ b/scripts/generate_python_boilerplate.py @@ -35,6 +35,7 @@ def get_env_var_name(field_name: str) -> str: "disable_steamdeck_mode": "SteamDeck", "mangohud_workaround": "MANGOHUD", "disable_lsfgvk": "DISABLE_LSFGVK", + "use_native_matching": "DECKY_LSFGVK_AUTO_PROFILE", "disable_vkbasalt": "DISABLE_VKBASALT", "force_enable_vkbasalt": "ENABLE_VKBASALT", "enable_wsi": "ENABLE_GAMESCOPE_WSI", diff --git a/shared_config.py b/shared_config.py index c87d01d..77b4c01 100644 --- a/shared_config.py +++ b/shared_config.py @@ -61,6 +61,13 @@ CONFIG_SCHEMA_DEF = { "description": "optional executable or process names, separated by commas", "location": "profile", }, + "use_native_matching": { + "name": "use_native_matching", + "fieldType": ConfigFieldType.BOOLEAN, + "default": False, + "description": "let lsfg-vk choose a profile from Active In instead of forcing the selected profile", + "location": "script", + }, "gpu": { "name": "gpu", "fieldType": ConfigFieldType.STRING, diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 8378a7f..f112d39 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -7,6 +7,18 @@ export interface InstallationResult { error?: string; message?: string; removed_files?: string[]; + flatpak_update?: FlatpakMigrationResult; +} + +export interface FlatpakMigrationResult { + success: boolean; + message: string; + error?: string; + skipped?: boolean; + updated_versions: string[]; + failed_versions: Array<{ version: string; error: string }>; + migrated_apps: string[]; + failed_apps: Array<{ app_id: string; error: string }>; } export interface InstallationStatus { diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index 7143bb1..538975e 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -4,7 +4,7 @@ import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { ACTIVE_IN, ALLOW_FP16, DISABLE_LSFGVK, DLL, FLOW_SCALE, GPU, - PERFORMANCE_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, + PERFORMANCE_MODE, USE_NATIVE_MATCHING, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK } from "../config/generatedConfigSchema"; @@ -164,13 +164,22 @@ export function ConfigurationSection({ <PanelSectionRow> <TextField label="Active In" - description="Executable/process names separated by commas. When set, lsfg-vk matches profiles automatically." + description="Executable/process names separated by commas." value={config.active_in} onChange={(event) => onConfigChange(ACTIVE_IN, event.currentTarget.value)} /> </PanelSectionRow> <PanelSectionRow> + <ToggleField + label="Use Automatic Profile Matching" + description="Let lsfg-vk choose a matching Active In profile instead of forcing the profile selected in Decky." + checked={config.use_native_matching} + onChange={(value) => onConfigChange(USE_NATIVE_MATCHING, value)} + /> + </PanelSectionRow> + + <PanelSectionRow> <SliderField label={`Base FPS Cap${config.dxvk_frame_rate > 0 ? ` (${config.dxvk_frame_rate} FPS)` : " (Off)"}`} description="Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)" diff --git a/src/components/FlatpaksModal.tsx b/src/components/FlatpaksModal.tsx index 0ab715a..a6a0e85 100644 --- a/src/components/FlatpaksModal.tsx +++ b/src/components/FlatpaksModal.tsx @@ -175,7 +175,7 @@ export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { <PanelSectionRow> <Field label="Runtime 23.08" - description={extensionStatus.installed_23_08 ? "Installed (manual)" : "Manual installation only; use runtime 24.08 or 25.08 when possible"} + description={extensionStatus.installed_23_08 ? "Installed" : "Bundled asset ready to install"} icon={extensionStatus.installed_23_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} > <ButtonItem @@ -194,7 +194,7 @@ export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { action(); } }} - disabled={!extensionStatus.installed_23_08 || operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} + disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} > {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( <Spinner /> @@ -215,7 +215,7 @@ export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { <PanelSectionRow> <Field label="Runtime 24.08" - description={extensionStatus.installed_24_08 ? "Installed" : "Not installed"} + description={extensionStatus.installed_24_08 ? "Installed" : "Bundled asset ready to install"} icon={extensionStatus.installed_24_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} > <ButtonItem @@ -255,7 +255,7 @@ export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { <PanelSectionRow> <Field label="Runtime 25.08" - description={extensionStatus.installed_25_08 ? "Installed" : "Not installed"} + description={extensionStatus.installed_25_08 ? "Installed" : "Bundled asset ready to install"} icon={extensionStatus.installed_25_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} > <ButtonItem diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 9c4437d..7b608c3 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -7,7 +7,7 @@ export { getDefaults, getFieldTypes, DLL, ALLOW_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, PACING, - ACTIVE_IN, GPU, DISABLE_LSFGVK, DXVK_FRAME_RATE, ENABLE_WOW64, + ACTIVE_IN, USE_NATIVE_MATCHING, GPU, DISABLE_LSFGVK, DXVK_FRAME_RATE, ENABLE_WOW64, DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK } from './generatedConfigSchema'; diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 9b7e6d1..5c5df39 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -15,6 +15,7 @@ export const FLOW_SCALE = "flow_scale" as const; export const PERFORMANCE_MODE = "performance_mode" as const; export const PACING = "pacing" as const; export const ACTIVE_IN = "active_in" as const; +export const USE_NATIVE_MATCHING = "use_native_matching" as const; export const GPU = "gpu" as const; export const DISABLE_LSFGVK = "disable_lsfgvk" as const; export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const; @@ -78,6 +79,12 @@ export const CONFIG_SCHEMA: Record<string, ConfigField> = { default: "", description: "optional executable or process names, separated by commas" }, + use_native_matching: { + name: "use_native_matching", + fieldType: ConfigFieldType.BOOLEAN, + default: false, + description: "let lsfg-vk choose a profile from Active In instead of forcing the selected profile" + }, gpu: { name: "gpu", fieldType: ConfigFieldType.STRING, @@ -149,6 +156,7 @@ export interface ConfigurationData { performance_mode: boolean; pacing: string; active_in: string; + use_native_matching: boolean; gpu: string; disable_lsfgvk: boolean; dxvk_frame_rate: number; @@ -175,6 +183,7 @@ export function getDefaults(): ConfigurationData { performance_mode: false, pacing: "none", active_in: "", + use_native_matching: false, gpu: "", disable_lsfgvk: false, dxvk_frame_rate: 0, @@ -197,6 +206,7 @@ export function getFieldTypes(): Record<string, ConfigFieldType> { performance_mode: ConfigFieldType.BOOLEAN, pacing: ConfigFieldType.STRING, active_in: ConfigFieldType.STRING, + use_native_matching: ConfigFieldType.BOOLEAN, gpu: ConfigFieldType.STRING, disable_lsfgvk: ConfigFieldType.BOOLEAN, dxvk_frame_rate: ConfigFieldType.INTEGER, diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts index f184145..8a43257 100644 --- a/src/hooks/useInstallationActions.ts +++ b/src/hooks/useInstallationActions.ts @@ -23,8 +23,8 @@ export function useInstallationActions() { const result = await installLsfgVk(); if (result.success) { setIsInstalled(true); - setInstallationStatus("lsfg-vk installed"); - showInstallSuccessToast(); + setInstallationStatus(result.message || "lsfg-vk installed"); + showInstallSuccessToast(result.message); // Reload lsfg config after installation if (reloadConfig) { diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index dce0a59..198ec0e 100644 --- a/src/utils/toastUtils.ts +++ b/src/utils/toastUtils.ts @@ -75,8 +75,8 @@ export function showErrorToastWithMessage(title: string, error: unknown): void { /** * Show installation success toast */ -export function showInstallSuccessToast(): void { - showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body); +export function showInstallSuccessToast(body?: string): void { + showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, body || ToastMessages.INSTALL_SUCCESS.body); } /** diff --git a/tests/test_lsfg_v2_migration.py b/tests/test_lsfg_v2_migration.py index 4909d8b..ee1fcba 100644 --- a/tests/test_lsfg_v2_migration.py +++ b/tests/test_lsfg_v2_migration.py @@ -1,3 +1,4 @@ +import asyncio import logging import sys import tempfile @@ -73,14 +74,38 @@ class ConfigurationMigrationTests(unittest.TestCase): self.assertIn("LSFGVK_PROFILE=decky-lsfg-vk", script) self.assertNotIn("LSFG_PROCESS", script) - def test_active_in_uses_native_v2_tracking_instead_of_forcing_a_profile(self): + def test_active_in_does_not_implicitly_override_the_selected_profile(self): config = ConfigurationManager.get_defaults() config["active_in"] = "Game.exe, GameThread" self.assertEqual( ConfigurationService._profile_selection_lines("decky-lsfg-vk", config), - ["# active_in is configured; lsfg-vk will select a matching profile automatically."], + ["export LSFGVK_PROFILE=decky-lsfg-vk"], ) + config["use_native_matching"] = True + self.assertEqual( + ConfigurationService._profile_selection_lines("decky-lsfg-vk", config), + ["# lsfg-vk will select a matching profile from Active In."], + ) + self.assertEqual( + ConfigurationManager.parse_script_content("export DECKY_LSFGVK_AUTO_PROFILE=1\n"), + {"use_native_matching": True}, + ) + + def test_cloned_profile_disables_native_matching_until_explicitly_enabled(self): + config = ConfigurationManager.get_defaults() + config["active_in"] = "Game.exe" + config["use_native_matching"] = True + profile_data = { + "current_profile": "decky-lsfg-vk", + "profiles": {"decky-lsfg-vk": config}, + "global_config": {"dll": "", "allow_fp16": True}, + } + + cloned = ConfigurationManager.create_profile(profile_data, "Other") + self.assertEqual(cloned["profiles"]["Other"]["active_in"], "Game.exe") + self.assertFalse(cloned["profiles"]["Other"]["use_native_matching"]) + class InstallerInfrastructureTests(unittest.TestCase): def test_layer_archive_selection_has_no_legacy_arm_override(self): @@ -137,6 +162,36 @@ class InstallerInfrastructureTests(unittest.TestCase): service._create_config_file() self.assertEqual(len(list(config_dir.glob("conf.toml.v1.bak*"))), 1) + def test_unrecognized_configuration_is_backed_up_before_reset(self): + from lsfg_vk.installation import InstallationService + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + config_dir = home / ".config" / "lsfg-vk" + config_dir.mkdir(parents=True) + config_path = config_dir / "conf.toml" + unsupported_config = "version = 3\n" + config_path.write_text(unsupported_config, encoding="utf-8") + + service = InstallationService.__new__(InstallationService) + service.log = mock.Mock() + service.user_home = home + service.config_dir = config_dir + service.config_file_path = config_path + service.lsfg_script_path = home / "lsfg" + service.lsfg_launch_script_path = home / "lsfg" + + with mock.patch("lsfg_vk.dll_detection.DllDetectionService") as detection_service: + detection_service.return_value.check_lossless_scaling_dll.return_value = {"detected": False} + service._create_config_file() + + self.assertEqual( + (config_dir / "conf.toml.unrecognized.bak").read_text(encoding="utf-8"), + unsupported_config, + ) + self.assertIn("version = 2", config_path.read_text(encoding="utf-8")) + self.assertEqual(service._config_recovery_backup, config_dir / "conf.toml.unrecognized.bak") + def test_writes_replace_the_config_atomically(self): service = BaseService.__new__(BaseService) service.log = mock.Mock() @@ -152,5 +207,250 @@ class InstallerInfrastructureTests(unittest.TestCase): replace.assert_called_once() +class FlatpakMigrationTests(unittest.TestCase): + def test_install_uses_the_bundled_flatpak_asset(self): + from lsfg_vk.flatpak_service import FlatpakService + + with tempfile.TemporaryDirectory() as directory: + bundle_path = Path(directory) / "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak" + bundle_path.write_bytes(b"flatpak bundle") + + service = FlatpakService.__new__(FlatpakService) + service.log = mock.Mock() + service.check_flatpak_available = mock.Mock(return_value=True) + service._get_bundled_extension_path = mock.Mock(return_value=bundle_path) + service._run_flatpak_command = mock.Mock( + return_value=types.SimpleNamespace(returncode=0, stdout="", stderr="") + ) + + result = service.install_extension("23.08") + + self.assertTrue(result["success"]) + service._run_flatpak_command.assert_called_once_with( + [ + "install", + "--user", + "--or-update", + "--assumeyes", + "--noninteractive", + str(bundle_path), + ], + capture_output=True, + text=True, + ) + + def test_update_installed_extensions_only_updates_user_runtimes_and_migrates_apps(self): + from lsfg_vk.flatpak_service import FlatpakService + + service = FlatpakService.__new__(FlatpakService) + service.log = mock.Mock() + service.check_flatpak_available = mock.Mock(return_value=True) + service._get_installed_extension_versions = mock.Mock(return_value=["23.08", "25.08"]) + service.install_extension = mock.Mock( + side_effect=[{"success": True}, {"success": True}] + ) + service._migrate_legacy_app_overrides = mock.Mock( + return_value={ + "success": True, + "migrated_apps": ["org.example.Game"], + "failed_apps": [], + } + ) + + result = service.update_installed_extensions() + + self.assertTrue(result["success"]) + self.assertEqual(result["updated_versions"], ["23.08", "25.08"]) + self.assertEqual(result["migrated_apps"], ["org.example.Game"]) + self.assertEqual( + service.install_extension.call_args_list, + [mock.call("23.08"), mock.call("25.08")], + ) + service._migrate_legacy_app_overrides.assert_called_once_with() + + def test_installed_runtime_scan_is_scoped_to_user_installation(self): + from lsfg_vk.flatpak_service import FlatpakService + + service = FlatpakService.__new__(FlatpakService) + service._run_flatpak_command = mock.Mock( + return_value=types.SimpleNamespace( + returncode=0, + stdout=( + "lsfg-vk\torg.freedesktop.Platform.VulkanLayer.lsfgvk\t" + "2.0\t23.08\tx86_64\n" + ), + stderr="", + ) + ) + + self.assertEqual(service._get_installed_extension_versions(), ["23.08"]) + service._run_flatpak_command.assert_called_once_with( + ["list", "--user", "--runtime"], + capture_output=True, + text=True, + check=True, + ) + + def test_failed_runtime_update_leaves_legacy_overrides_untouched(self): + from lsfg_vk.flatpak_service import FlatpakService + + service = FlatpakService.__new__(FlatpakService) + service.log = mock.Mock() + service.check_flatpak_available = mock.Mock(return_value=True) + service._get_installed_extension_versions = mock.Mock(return_value=["24.08"]) + service.install_extension = mock.Mock( + return_value={"success": False, "error": "bundle unavailable"} + ) + service._migrate_legacy_app_overrides = mock.Mock() + + result = service.update_installed_extensions() + + self.assertFalse(result["success"]) + self.assertEqual(result["failed_versions"][0]["version"], "24.08") + service._migrate_legacy_app_overrides.assert_not_called() + + def test_native_install_runs_flatpak_migration_after_success(self): + from lsfg_vk.plugin import Plugin + + plugin = Plugin.__new__(Plugin) + plugin.installation_service = mock.Mock() + plugin.flatpak_service = mock.Mock() + plugin.installation_service.install.return_value = { + "success": True, + "message": "lsfg-vk v2 installed successfully", + "error": None, + } + plugin.flatpak_service.update_installed_extensions.return_value = { + "success": True, + "message": "Updated one Flatpak runtime", + "skipped": False, + "updated_versions": ["23.08"], + "failed_versions": [], + "migrated_apps": [], + "failed_apps": [], + } + + result = asyncio.run(plugin.install_lsfg_vk()) + + self.assertTrue(result["success"]) + self.assertIn("flatpak_update", result) + plugin.flatpak_service.update_installed_extensions.assert_called_once_with() + + def test_legacy_override_detection_does_not_match_v2_environment(self): + from lsfg_vk.flatpak_service import FlatpakService + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + service = FlatpakService.__new__(FlatpakService) + service.user_home = home + service.config_dir = home / ".config/lsfg-vk" + service.config_file_path = service.config_dir / "conf.toml" + service.lsfg_launch_script_path = home / "lsfg" + service.log = mock.Mock() + + self.assertTrue( + service._has_legacy_app_override( + f"LSFG_CONFIG={service.config_file_path}\n" + ) + ) + self.assertFalse( + service._has_legacy_app_override( + f"LSFGVK_CONFIG={service.config_file_path}\n" + ) + ) + + def test_legacy_override_migration_only_touches_plugin_owned_apps(self): + from lsfg_vk.flatpak_service import FlatpakService + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + service = FlatpakService.__new__(FlatpakService) + service.user_home = home + service.config_dir = home / ".config/lsfg-vk" + service.config_file_path = service.config_dir / "conf.toml" + service.lsfg_launch_script_path = home / "lsfg" + service.log = mock.Mock() + service._get_flatpak_app_ids = mock.Mock( + return_value=["org.example.Legacy", "org.example.V2", "org.example.Other"] + ) + service._get_app_override_output = mock.Mock( + side_effect=[ + f"LSFG_CONFIG={service.config_file_path}\n", + f"LSFGVK_CONFIG={service.config_file_path}\n", + "", + ] + ) + service.set_app_override = mock.Mock(return_value={"success": True}) + + result = service._migrate_legacy_app_overrides() + + self.assertTrue(result["success"]) + self.assertEqual(result["migrated_apps"], ["org.example.Legacy"]) + service.set_app_override.assert_called_once_with("org.example.Legacy") + + def test_v2_override_setup_and_removal_clean_legacy_overrides(self): + from lsfg_vk.flatpak_service import FlatpakService + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + config_dir = home / ".config" / "lsfg-vk" + config_dir.mkdir(parents=True) + config_path = config_dir / "conf.toml" + config_path.write_text( + ConfigurationManager.generate_toml_content(ConfigurationManager.get_defaults()), + encoding="utf-8", + ) + + service = FlatpakService.__new__(FlatpakService) + service.log = mock.Mock() + service.user_home = home + service.config_dir = config_dir + service.config_file_path = config_path + service.lsfg_launch_script_path = home / "lsfg" + service.check_flatpak_available = mock.Mock(return_value=True) + service._run_flatpak_command = mock.Mock( + return_value=types.SimpleNamespace(returncode=0, stdout="", stderr="") + ) + + app_id = "org.example.Game" + result = service.set_app_override(app_id) + self.assertTrue(result["success"]) + setup_calls = [call.args[0] for call in service._run_flatpak_command.call_args_list] + self.assertIn( + ["override", "--user", f"--env=LSFGVK_CONFIG={config_path}", app_id], + setup_calls, + ) + self.assertIn( + ["override", "--user", "--unset-env=LSFG_CONFIG", app_id], + setup_calls, + ) + self.assertIn( + [ + "override", + "--user", + f"--nofilesystem={home / '.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll'}", + app_id, + ], + setup_calls, + ) + self.assertIn( + ["override", "--user", f"--nofilesystem={home / 'lsfg'}", app_id], + setup_calls, + ) + + service._run_flatpak_command.reset_mock() + result = service.remove_app_override(app_id) + self.assertTrue(result["success"]) + removal_calls = [call.args[0] for call in service._run_flatpak_command.call_args_list] + self.assertIn( + ["override", "--user", "--unset-env=LSFG_CONFIG", app_id], + removal_calls, + ) + self.assertIn( + ["override", "--user", f"--nofilesystem={home / 'lsfg'}", app_id], + removal_calls, + ) + + if __name__ == "__main__": unittest.main() |
