diff options
| author | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 15:15:05 -0400 |
|---|---|---|
| committer | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 15:15:05 -0400 |
| commit | 0b0a92b5568d474401bf3be50399b5e22284214b (patch) | |
| tree | 503c3d011dd8cf2cc2dbea73c38d03f840e8ccb8 | |
| parent | 63d758abe6318c5877e658c7a56282a7d90ede51 (diff) | |
| download | decky-lsfg-vk-0b0a92b5568d474401bf3be50399b5e22284214b.tar.gz decky-lsfg-vk-0b0a92b5568d474401bf3be50399b5e22284214b.zip | |
feat: migrate Flatpak integrations with native updates
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 224 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 17 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/types.py | 1 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 12 | ||||
| -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 | 159 |
8 files changed, 381 insertions, 41 deletions
@@ -79,6 +79,7 @@ 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 diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 5832cf4..fe965aa 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -18,6 +18,9 @@ from .constants import ( 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 = "", @@ -88,6 +91,25 @@ class FlatpakService(BaseService): 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" @@ -175,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: @@ -220,9 +226,9 @@ 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(): @@ -235,7 +241,14 @@ class FlatpakService(BaseService): return self._error_response(BaseResponse, error_msg) result = self._run_flatpak_command( - ["install", "--user", "--noninteractive", str(bundle_path)], + [ + "install", + "--user", + "--or-update", + "--assumeyes", + "--noninteractive", + str(bundle_path), + ], capture_output=True, text=True ) @@ -255,10 +268,161 @@ class FlatpakService(BaseService): 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(): @@ -301,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 ) @@ -337,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 = "" diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index c2f22e3..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 @@ -359,7 +370,7 @@ class Plugin: 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", "24.08", or "25.08") 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/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/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 bb5a0b1..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 @@ -226,11 +227,167 @@ class FlatpakMigrationTests(unittest.TestCase): self.assertTrue(result["success"]) service._run_flatpak_command.assert_called_once_with( - ["install", "--user", "--noninteractive", str(bundle_path)], + [ + "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 |
