summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJsonDeruloXx <danielhimebauch@gmail.com>2026-08-01 16:54:06 -0400
committerxXJsonDeruloXx <danielhimebauch@gmail.com>2026-08-01 16:54:06 -0400
commit45cd2d4ef7bf38ecd8d1d57c4170b9dbaa912c8c (patch)
tree10c2943a8d52b079245c4c872c2a6c00b307b68b
parent0b0a92b5568d474401bf3be50399b5e22284214b (diff)
downloaddecky-lsfg-vk-45cd2d4ef7bf38ecd8d1d57c4170b9dbaa912c8c.tar.gz
decky-lsfg-vk-45cd2d4ef7bf38ecd8d1d57c4170b9dbaa912c8c.zip
fix: harden v2 migration and Flatpak updates
-rw-r--r--README.md1
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py109
-rw-r--r--py_modules/lsfg_vk/installation.py76
-rw-r--r--src/api/lsfgApi.ts2
-rw-r--r--src/components/InstallationButton.tsx58
-rw-r--r--tests/test_lsfg_v2_migration.py146
6 files changed, 353 insertions, 39 deletions
diff --git a/README.md b/README.md
index 12a8ecc..c0642a4 100644
--- a/README.md
+++ b/README.md
@@ -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