diff options
| author | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 16:54:06 -0400 |
|---|---|---|
| committer | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 16:54:06 -0400 |
| commit | 45cd2d4ef7bf38ecd8d1d57c4170b9dbaa912c8c (patch) | |
| tree | 10c2943a8d52b079245c4c872c2a6c00b307b68b | |
| parent | 0b0a92b5568d474401bf3be50399b5e22284214b (diff) | |
| download | decky-lsfg-vk-bundle-v2-flatpak-auto-update.tar.gz decky-lsfg-vk-bundle-v2-flatpak-auto-update.zip | |
fix: harden v2 migration and Flatpak updatesbundle-v2-flatpak-auto-update
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 109 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 76 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 2 | ||||
| -rw-r--r-- | src/components/InstallationButton.tsx | 58 | ||||
| -rw-r--r-- | tests/test_lsfg_v2_migration.py | 146 |
6 files changed, 353 insertions, 39 deletions
@@ -80,6 +80,7 @@ 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 +- Records existing Flatpak runtime commits and rolls back already-updated runtimes when a multi-runtime migration fails, when Flatpak permits it - 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 fe965aa..8410496 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -91,6 +91,52 @@ class FlatpakService(BaseService): plugin_dir = Path(__file__).resolve().parent.parent.parent return plugin_dir / BIN_DIR / filename + def _get_extension_id(self, version: str) -> str: + """Return the full user-installation ref for a supported runtime branch.""" + extension_ids = { + "23.08": self.extension_id_23_08, + "24.08": self.extension_id_24_08, + "25.08": self.extension_id_25_08, + } + try: + return extension_ids[version] + except KeyError as error: + raise ValueError(f"Unsupported Flatpak runtime version: {version}") from error + + def _get_installed_extension_commit(self, version: str) -> str: + """Record the deployed commit so a failed multi-runtime migration can roll back.""" + result = self._run_flatpak_command( + ["info", "--user", "--show-commit", self._get_extension_id(version)], + capture_output=True, + text=True, + check=True, + ) + commit = str(result.stdout or "").strip() + if not commit: + raise OSError(f"Flatpak did not report a deployed commit for {version}") + return commit + + def _rollback_extension(self, version: str, commit: str) -> Optional[str]: + """Restore one runtime to its previous deployed commit; return an error if it fails.""" + try: + result = self._run_flatpak_command( + [ + "update", + "--user", + "--assumeyes", + "--noninteractive", + f"--commit={commit}", + self._get_extension_id(version), + ], + capture_output=True, + text=True, + ) + except (OSError, subprocess.SubprocessError) as error: + return str(error) + if result.returncode != 0: + return result.stderr or f"Flatpak rollback failed for {version}" + return None + def _get_installed_extension_versions(self) -> list[str]: """Return installed lsfg-vk runtime branches from the user Flatpak installation.""" result = self._run_flatpak_command( @@ -271,7 +317,7 @@ class FlatpakService(BaseService): def _get_flatpak_app_ids(self) -> list[str]: """Return application IDs in the user Flatpak installation.""" result = self._run_flatpak_command( - ["list", "--app"], + ["list", "--user", "--app"], capture_output=True, text=True, check=True, @@ -348,6 +394,8 @@ class FlatpakService(BaseService): empty_result = { "updated_versions": [], "failed_versions": [], + "rolled_back_versions": [], + "rollback_failed_versions": [], "migrated_apps": [], "failed_apps": [], } @@ -369,6 +417,37 @@ class FlatpakService(BaseService): **empty_result, ) + missing_bundles = [ + {"version": version, "error": f"Bundled Flatpak extension not found for {version}"} + for version in installed_versions + if not self._get_bundled_extension_path(version).is_file() + ] + if missing_bundles: + return self._error_response( + BaseResponse, + "Flatpak runtime migration was not started because a bundled asset is missing", + skipped=False, + failed_versions=missing_bundles, + **{key: value for key, value in empty_result.items() if key != "failed_versions"}, + ) + + previous_commits = {} + commit_failures = [] + for version in installed_versions: + try: + previous_commits[version] = self._get_installed_extension_commit(version) + except (subprocess.CalledProcessError, OSError) as error: + stderr = getattr(error, "stderr", None) + commit_failures.append({"version": version, "error": stderr or str(error)}) + if commit_failures: + return self._error_response( + BaseResponse, + "Flatpak runtime migration was not started because existing commits could not be recorded", + skipped=False, + failed_versions=commit_failures, + **{key: value for key, value in empty_result.items() if key != "failed_versions"}, + ) + updated_versions = [] failed_versions = [] for version in installed_versions: @@ -379,12 +458,29 @@ class FlatpakService(BaseService): failed_versions.append({"version": version, "error": result.get("error", "unknown error")}) if failed_versions: + rolled_back_versions = [] + rollback_failed_versions = [] + for version in reversed(updated_versions): + rollback_error = self._rollback_extension(version, previous_commits[version]) + if rollback_error is None: + rolled_back_versions.append(version) + else: + rollback_failed_versions.append({"version": version, "error": rollback_error}) + + rollback_message = ( + f"rolled back {len(rolled_back_versions)} runtime(s)" + if not rollback_failed_versions + else f"could not roll back {len(rollback_failed_versions)} runtime(s)" + ) return self._error_response( BaseResponse, - "Flatpak runtime migration failed; legacy app overrides were left unchanged", + f"Flatpak runtime migration failed; {rollback_message}; " + "legacy app overrides were left unchanged", skipped=False, updated_versions=updated_versions, failed_versions=failed_versions, + rolled_back_versions=rolled_back_versions, + rollback_failed_versions=rollback_failed_versions, migrated_apps=[], failed_apps=[], ) @@ -393,6 +489,8 @@ class FlatpakService(BaseService): response = { "updated_versions": updated_versions, "failed_versions": [], + "rolled_back_versions": [], + "rollback_failed_versions": [], "migrated_apps": override_result.get("migrated_apps", []), "failed_apps": override_result.get("failed_apps", []), } @@ -428,12 +526,7 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): return self._error_response(BaseResponse, "Flatpak is not available on this system") - if version == "23.08": - extension_id = self.extension_id_23_08 - elif version == "24.08": - extension_id = self.extension_id_24_08 - else: - extension_id = self.extension_id_25_08 + extension_id = self._get_extension_id(version) result = self._run_flatpak_command( ["uninstall", "--user", "--noninteractive", extension_id], diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 7eee848..1ba091a 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -45,10 +45,16 @@ class InstallationService(BaseService): return self._error_response(InstallationResponse, error_msg, message="") self._ensure_directories() - self._extract_and_install_files(archive_path) - profile_data = self._create_config_file() - self._create_lsfg_launch_script(profile_data) - self._remove_legacy_layer_files() + with tempfile.TemporaryDirectory(prefix="lsfg-vk-install-") as transaction_dir: + snapshots = self._snapshot_installation_files(Path(transaction_dir)) + try: + self._extract_and_install_files(archive_path) + profile_data = self._create_config_file() + self._create_lsfg_launch_script(profile_data) + self._remove_legacy_layer_files() + except Exception: + self._restore_installation_files(snapshots) + raise message = "lsfg-vk v2 installed successfully" if self._config_recovery_backup is not None: @@ -126,12 +132,54 @@ class InstallationService(BaseService): """Point the manifest at ~/.local/lib after Decky installs it.""" try: data = json.loads(source.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not isinstance(data.get("layer"), dict): + raise ValueError("manifest is missing its layer object") data["layer"]["library_path"] = "../../../lib/liblsfg-vk-layer.so" - destination.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - destination.chmod(0o644) - except (json.JSONDecodeError, KeyError, OSError) as error: + self._write_file(destination, json.dumps(data, indent=2) + "\n", 0o644) + except (json.JSONDecodeError, OSError, TypeError, UnicodeError, ValueError) as error: self.log.error("Error fixing JSON file %s: %s", source, error) - self._copy_plugin_file(source, destination) + raise OSError(f"Invalid Vulkan layer manifest {source}: {error}") from error + + def _installation_paths(self) -> tuple[Path, ...]: + """Return every file that can be changed by a native installation.""" + return ( + self.lib_file, + self.json_file, + self.config_file_path, + self.lsfg_launch_script_path, + self.legacy_lib_file, + self.legacy_json_file, + self.config_file_path.with_name(f"{self.config_file_path.name}.v1.bak"), + self.config_file_path.with_name(f"{self.config_file_path.name}.unrecognized.bak"), + ) + + def _snapshot_installation_files(self, transaction_dir: Path) -> Dict[Path, Path | None]: + """Snapshot installation files so a failed update can be rolled back.""" + snapshots: Dict[Path, Path | None] = {} + for index, path in enumerate(self._installation_paths()): + if not path.exists() and not path.is_symlink(): + snapshots[path] = None + continue + if not path.is_file(): + raise OSError(f"Installation target is not a regular file: {path}") + backup = transaction_dir / str(index) + shutil.copy2(path, backup) + snapshots[path] = backup + return snapshots + + def _restore_installation_files(self, snapshots: Dict[Path, Path | None]) -> None: + """Restore a previous installation after a failed update.""" + try: + for path, backup in snapshots.items(): + if path.exists() or path.is_symlink(): + path.unlink() + if backup is not None: + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup, path) + self.log.warning("Rolled back incomplete lsfg-vk installation") + except OSError as error: + self.log.error("Could not fully roll back lsfg-vk installation: %s", error) + raise def _create_config_file(self) -> ProfileData: """Migrate v1 once, normalize v2, and retain Decky launch workarounds.""" @@ -222,7 +270,17 @@ class InstallationService(BaseService): def _merge_config_with_defaults(self, existing: ProfileData, dll_service) -> ProfileData: defaults = ConfigurationManager.get_defaults_with_dll_detection(dll_service) global_config = dict(existing.get("global_config", {})) - global_config.setdefault("dll", defaults["dll"]) + configured_dll = str(global_config.get("dll", "") or "").strip() + detected_dll = str(defaults.get("dll", "") or "").strip() + if configured_dll and Path(configured_dll).is_file(): + global_config["dll"] = configured_dll + elif detected_dll and Path(detected_dll).is_file(): + self.log.warning("Replacing stale configured Lossless.dll path %s with %s", configured_dll, detected_dll) + global_config["dll"] = detected_dll + else: + if configured_dll: + self.log.warning("Clearing stale configured Lossless.dll path %s", configured_dll) + global_config["dll"] = "" global_config.setdefault("allow_fp16", defaults["allow_fp16"]) profiles: Dict[str, Any] = {} diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index f112d39..07f4b72 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -17,6 +17,8 @@ export interface FlatpakMigrationResult { skipped?: boolean; updated_versions: string[]; failed_versions: Array<{ version: string; error: string }>; + rolled_back_versions: string[]; + rollback_failed_versions: Array<{ version: string; error: string }>; migrated_apps: string[]; failed_apps: Array<{ app_id: string; error: string }>; } diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx index 40ba955..de7d454 100644 --- a/src/components/InstallationButton.tsx +++ b/src/components/InstallationButton.tsx @@ -16,15 +16,24 @@ export function InstallationButton({ onInstall, onUninstall }: InstallationButtonProps) { - const renderButtonContent = () => { + const renderInstallButtonContent = () => { if (isInstalling) { return ( <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div>Installing...</div> + <div>{isInstalled ? "Updating..." : "Installing..."}</div> </div> ); } + return ( + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> + <FaDownload /> + <div>{isInstalled ? "Update LSFG-VK" : "Install / Update LSFG-VK"}</div> + </div> + ); + }; + + const renderUninstallButtonContent = () => { if (isUninstalling) { return ( <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> @@ -33,32 +42,37 @@ export function InstallationButton({ ); } - if (isInstalled) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <FaTrash /> - <div>Uninstall LSFG-VK</div> - </div> - ); - } - return ( <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <FaDownload /> - <div>Install / Update LSFG-VK</div> + <FaTrash /> + <div>Uninstall LSFG-VK</div> </div> ); }; return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {renderButtonContent()} - </ButtonItem> - </PanelSectionRow> + <> + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={onInstall} + disabled={isInstalling || isUninstalling} + > + {renderInstallButtonContent()} + </ButtonItem> + </PanelSectionRow> + + {isInstalled && ( + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={onUninstall} + disabled={isInstalling || isUninstalling} + > + {renderUninstallButtonContent()} + </ButtonItem> + </PanelSectionRow> + )} + </> ); } diff --git a/tests/test_lsfg_v2_migration.py b/tests/test_lsfg_v2_migration.py index ee1fcba..8b2e2a3 100644 --- a/tests/test_lsfg_v2_migration.py +++ b/tests/test_lsfg_v2_migration.py @@ -192,6 +192,95 @@ class InstallerInfrastructureTests(unittest.TestCase): 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_stale_dll_path_is_replaced_when_detection_finds_a_valid_file(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" + detected_dll = home / "Lossless.dll" + detected_dll.write_bytes(b"dll") + profile_data = { + "current_profile": "decky-lsfg-vk", + "profiles": {"decky-lsfg-vk": ConfigurationManager.get_defaults()}, + "global_config": {"dll": str(home / "missing" / "Lossless.dll"), "allow_fp16": True}, + } + config_path.write_text( + ConfigurationManager.generate_toml_content_multi_profile(profile_data), + 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": True, + "path": str(detected_dll), + } + migrated = service._create_config_file() + + self.assertEqual(migrated["global_config"]["dll"], str(detected_dll)) + self.assertIn(str(detected_dll), config_path.read_text(encoding="utf-8")) + + def test_invalid_layer_manifest_fails_without_copying_unmodified_json(self): + from lsfg_vk.installation import InstallationService + + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "manifest.json" + destination = Path(directory) / "installed.json" + source.write_text('{"not_layer": true}', encoding="utf-8") + + service = InstallationService.__new__(InstallationService) + service.log = mock.Mock() + + with self.assertRaises(OSError): + service._copy_and_fix_json_file(source, destination) + self.assertFalse(destination.exists()) + + def test_installation_snapshot_restores_previous_files(self): + from lsfg_vk.installation import InstallationService + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + service = InstallationService.__new__(InstallationService) + service.log = mock.Mock() + service.local_lib_dir = home / "lib" + service.local_share_dir = home / "share" + service.config_file_path = home / "config" / "conf.toml" + service.lsfg_launch_script_path = home / "lsfg" + service.legacy_lib_file = service.local_lib_dir / "liblsfg-vk.so" + service.legacy_json_file = service.local_share_dir / "legacy.json" + service.lib_file = service.local_lib_dir / "liblsfg-vk-layer.so" + service.json_file = service.local_share_dir / "manifest.json" + service.config_file_path.parent.mkdir(parents=True) + service.local_lib_dir.mkdir(parents=True) + service.local_share_dir.mkdir(parents=True) + service.lib_file.write_bytes(b"old layer") + service.json_file.write_text("old manifest", encoding="utf-8") + service.config_file_path.write_text("old config", encoding="utf-8") + service.lsfg_launch_script_path.write_text("old script", encoding="utf-8") + + with tempfile.TemporaryDirectory() as transaction_dir: + snapshots = service._snapshot_installation_files(Path(transaction_dir)) + service.lib_file.write_bytes(b"new layer") + service.json_file.write_text("new manifest", encoding="utf-8") + service.config_file_path.unlink() + service.lsfg_launch_script_path.write_text("new script", encoding="utf-8") + service._restore_installation_files(snapshots) + + self.assertEqual(service.lib_file.read_bytes(), b"old layer") + self.assertEqual(service.json_file.read_text(encoding="utf-8"), "old manifest") + self.assertEqual(service.config_file_path.read_text(encoding="utf-8"), "old config") + self.assertEqual(service.lsfg_launch_script_path.read_text(encoding="utf-8"), "old script") + def test_writes_replace_the_config_atomically(self): service = BaseService.__new__(BaseService) service.log = mock.Mock() @@ -246,6 +335,10 @@ class FlatpakMigrationTests(unittest.TestCase): 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"]) + bundle = mock.Mock() + bundle.is_file.return_value = True + service._get_bundled_extension_path = mock.Mock(return_value=bundle) + service._get_installed_extension_commit = mock.Mock(side_effect=lambda version: f"old-{version}") service.install_extension = mock.Mock( side_effect=[{"success": True}, {"success": True}] ) @@ -298,6 +391,10 @@ class FlatpakMigrationTests(unittest.TestCase): service.log = mock.Mock() service.check_flatpak_available = mock.Mock(return_value=True) service._get_installed_extension_versions = mock.Mock(return_value=["24.08"]) + bundle = mock.Mock() + bundle.is_file.return_value = True + service._get_bundled_extension_path = mock.Mock(return_value=bundle) + service._get_installed_extension_commit = mock.Mock(return_value="old-24.08") service.install_extension = mock.Mock( return_value={"success": False, "error": "bundle unavailable"} ) @@ -309,6 +406,55 @@ class FlatpakMigrationTests(unittest.TestCase): self.assertEqual(result["failed_versions"][0]["version"], "24.08") service._migrate_legacy_app_overrides.assert_not_called() + def test_failed_runtime_update_rolls_back_previously_updated_runtimes(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", "24.08"]) + bundle = mock.Mock() + bundle.is_file.return_value = True + service._get_bundled_extension_path = mock.Mock(return_value=bundle) + service._get_installed_extension_commit = mock.Mock( + side_effect=lambda version: f"old-{version}" + ) + service.install_extension = mock.Mock( + side_effect=[ + {"success": True}, + {"success": False, "error": "transaction failed"}, + ] + ) + service._rollback_extension = mock.Mock(return_value=None) + service._migrate_legacy_app_overrides = mock.Mock() + + result = service.update_installed_extensions() + + self.assertFalse(result["success"]) + self.assertEqual(result["rolled_back_versions"], ["23.08"]) + service._rollback_extension.assert_called_once_with("23.08", "old-23.08") + service._migrate_legacy_app_overrides.assert_not_called() + + def test_flatpak_app_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="Game\torg.example.Game\t1.0\tx86_64\n", + stderr="", + ) + ) + + self.assertEqual(service._get_flatpak_app_ids(), ["org.example.Game"]) + service._run_flatpak_command.assert_called_once_with( + ["list", "--user", "--app"], + capture_output=True, + text=True, + check=True, + ) + def test_native_install_runs_flatpak_migration_after_success(self): from lsfg_vk.plugin import Plugin |
