summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJsonDeruloXx <danielhimebauch@gmail.com>2026-08-01 15:15:05 -0400
committerxXJsonDeruloXx <danielhimebauch@gmail.com>2026-08-01 15:15:05 -0400
commit0b0a92b5568d474401bf3be50399b5e22284214b (patch)
tree503c3d011dd8cf2cc2dbea73c38d03f840e8ccb8
parent63d758abe6318c5877e658c7a56282a7d90ede51 (diff)
downloaddecky-lsfg-vk-0b0a92b5568d474401bf3be50399b5e22284214b.tar.gz
decky-lsfg-vk-0b0a92b5568d474401bf3be50399b5e22284214b.zip
feat: migrate Flatpak integrations with native updates
-rw-r--r--README.md1
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py224
-rw-r--r--py_modules/lsfg_vk/plugin.py17
-rw-r--r--py_modules/lsfg_vk/types.py1
-rw-r--r--src/api/lsfgApi.ts12
-rw-r--r--src/hooks/useInstallationActions.ts4
-rw-r--r--src/utils/toastUtils.ts4
-rw-r--r--tests/test_lsfg_v2_migration.py159
8 files changed, 381 insertions, 41 deletions
diff --git a/README.md b/README.md
index a1c233c..12a8ecc 100644
--- a/README.md
+++ b/README.md
@@ -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