diff options
51 files changed, 4112 insertions, 1861 deletions
@@ -1,6 +1,5 @@ # Decky LSFG-VK -> **Note:** > This is an **unofficial community plugin**. It is independently developed and **not officially supported** by the creators of Lossless Scaling or lsfg-vk. For support, please use the [decky-lsfg-vk Discord Channel](https://discord.gg/TwvHdVucC3). @@ -14,26 +13,26 @@ </p> -## What is this? - -A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://lsfg-vk.dev/)) 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. +Decky LSFG-VK is a Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://lsfg-vk.dev/)) 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. ## Installation 1. **Download the plugin** from the [releases tab](https://github.com/xXJSONDeruloXx/decky-lsfg-vk/releases) - - Download the "decky-lsfg-vk.zip" file to your Steam Deck + - Download the "Decky LSFG-VK.zip" file to your Steam Deck 2. **Install manually through Decky**: - In Game Mode, go to the settings cog in the top right of the Decky Loader tab - Enable "Developer Mode" - Go to "Developer" tab and select "Install Plugin from Zip" - - Select the downloaded "decky-lsfg-vk.zip" file + - Select the downloaded "Decky LSFG-VK.zip" file ## How to Use 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam +2. **Switch Branch** In the Lossless Scaling Steam app, go to Properties > Game Versions & Betas > select "lsfg-vk" branch, and let Steam download the new version 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer 4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings +5. **Configure Flatpak apps** in the Flatpak tab individually or with **Enable all** and **Remove all profiles** 6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile ### Core Settings diff --git a/assets/flatpak-target.png b/assets/flatpak-target.png Binary files differdeleted file mode 100644 index 773e567..0000000 --- a/assets/flatpak-target.png +++ /dev/null @@ -1,11 +1,14 @@ default: - echo "Available recipes: build, test, clean" + echo "Available recipes: build, deploy, test, clean" build: - .vscode/build.sh + pnpm build + +deploy: + ./scripts/deploy-to-deck.sh test: - scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop + pnpm test watch: ssh deck@192.168.0.6 "journalctl -f" diff --git a/package.json b/package.json index b5e716c..735f9c4 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "decky-lsfg-vk", - "version": "0.12.8", + "version": "0.14.0", "description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer", "type": "module", "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'" + "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts tests/gameTargets.test.ts && python3.12 -m unittest discover -s tests -p 'test_*.py'" }, "repository": { "type": "git", @@ -52,21 +52,6 @@ "name": "lsfg-vk-2.0.0.tar.xz", "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" - }, - { - "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 3816d88..bf3e174 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -113,6 +113,6 @@ class ConfigurationManager: **profile, **global_config, }) - if config["active_in"]: + if name or config["active_in"]: profiles[name] = config return {"profiles": profiles, "global_config": global_config} diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index bec3828..3c65972 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -7,7 +7,7 @@ from .runtime_service import RuntimeService class ConfigurationService(BaseService): - """Controller-facing adapter over upstream lsfg-vk profiles.""" + FLATPAK_PROFILE_PREFIX = "flatpak:" def __init__(self, logger=None, runtime_service: RuntimeService = None): super().__init__(logger) @@ -47,6 +47,13 @@ class ConfigurationService(BaseService): (None, None), ) + @classmethod + def flatpak_profile_name(cls, app_id: str) -> str: + value = str(app_id).strip() + if not value: + raise ValueError("Flatpak application ID is required") + return f"{cls.FLATPAK_PROFILE_PREFIX}{value}" + @staticmethod def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: return ConfigurationManager.validate_config(config) @@ -65,20 +72,30 @@ class ConfigurationService(BaseService): self.log.error(f"Error reading game configs: {error}") return self._error_response(dict, str(error), games=[]) + def update_global_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + try: + data = self._get_profile_data() + merged_config = {**data["global_config"], **config} + validated = self._public_config(merged_config) + data["global_config"] = { + "dll": validated["dll"], + "no_fp16": validated["no_fp16"], + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"])) + except Exception as error: + return self._error_response(dict, str(error), global_config=None) + def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]: try: data = self._get_profile_data() old_name, _ = self._profile_for_appid(data, appid) name = self._profile_name(data, appid, game_name) - merged_config = {**data["global_config"], **config} + merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}} if not config.get("dll"): merged_config["dll"] = data["global_config"].get("dll", "") validated = self._public_config(merged_config) validated["active_in"] = [str(appid)] - data["global_config"] = { - "dll": validated["dll"], - "no_fp16": validated["no_fp16"], - } if old_name and old_name != name: data["profiles"].pop(old_name, None) data["profiles"][name] = validated @@ -87,6 +104,60 @@ class ConfigurationService(BaseService): except Exception as error: return self._error_response(dict, str(error), appid=str(appid), config=None) + def get_flatpak_config(self, app_id: str) -> Dict[str, Any]: + try: + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + raw = data["profiles"].get(name) + return self._success_response( + dict, + app_id=str(app_id), + profile=name, + exists=raw is not None, + config=self._public_config(raw) if raw is not None else None, + global_config=dict(data["global_config"]), + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def update_flatpak_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: + try: + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}} + if not config.get("dll"): + merged_config["dll"] = data["global_config"].get("dll", "") + validated = self._public_config(merged_config) + validated["active_in"] = [] + data["profiles"][name] = validated + self._save_profile_data(data) + return self._success_response(dict, app_id=str(app_id), profile=name, exists=True, config=validated) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def reset_flatpak_config(self, app_id: str) -> Dict[str, Any]: + try: + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + data["profiles"].pop(name, None) + self._save_profile_data(data) + return self._success_response(dict, app_id=str(app_id), profile=name, exists=False) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def reset_all_flatpak_configs(self) -> Dict[str, Any]: + try: + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not name.startswith(self.FLATPAK_PROFILE_PREFIX) + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"])) + except Exception as error: + return self._error_response(dict, str(error)) + def reset_game_config(self, appid: str) -> Dict[str, Any]: try: data = self._get_profile_data() @@ -98,10 +169,44 @@ class ConfigurationService(BaseService): except Exception as error: return self._error_response(dict, str(error), appid=str(appid), config=None) + def reset_game_configs(self, appids: list[str]) -> Dict[str, Any]: + try: + if not isinstance(appids, list): + raise ValueError("appids must be a list") + requested = set() + for appid in appids: + if isinstance(appid, bool) or not isinstance(appid, (str, int)): + raise ValueError("appids must contain only strings or integers") + value = str(appid) + if not re.fullmatch(r"-?[0-9]+", value): + raise ValueError("appids must contain only numeric App IDs") + requested.add(value) + + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not ( + len(profile.get("active_in", [])) == 1 + and str(profile["active_in"][0]) in requested + ) + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) + except Exception as error: + return self._error_response(dict, str(error), games=[]) + def reset_all_game_configs(self) -> Dict[str, Any]: try: data = self._get_profile_data() - data["profiles"] = {} + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not ( + len(profile.get("active_in", [])) == 1 + and re.fullmatch(r"-?[0-9]+", str(profile.get("active_in", [""])[0])) + ) + } self._save_profile_data(data) return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) except Exception as error: diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 960d230..45ac4c5 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -16,9 +16,6 @@ CLI_FILENAME = "lsfg-vk-cli" UI_FILENAME = "lsfg-vk-ui" UI_DESKTOP_FILENAME = "gay.pancake.lsfg-vk-ui.desktop" UI_ICON_FILENAME = "gay.pancake.lsfg-vk-ui.png" -FLATPAK_23_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak" -FLATPAK_24_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak" -FLATPAK_25_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak" STEAM_LOSSLESS_SCALING_APP_ID = "993090" STEAM_LOSSLESS_SCALING_BRANCH = "lsfg-vk" diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py new file mode 100644 index 0000000..9d2d104 --- /dev/null +++ b/py_modules/lsfg_vk/flatpak_profile_service.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import re +from typing import Any, Dict + +from .configuration import ConfigurationService +from .flatpak_service import FlatpakService + + +class FlatpakProfileService: + STATE_FIELDS = ( + "dxvkFrameRate", + "disableGamescopeWsi", + "disableHdr", + "disableSteamdeckMode", + "disableVkbasalt", + "enableZink", + ) + BOOLEAN_FIELDS = STATE_FIELDS[1:] + DXVK_FRAME_RATE_SEGMENT = re.compile( + r"^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=", + re.IGNORECASE, + ) + + def __init__( + self, + flatpak_service: FlatpakService, + configuration_service: ConfigurationService, + ): + self.flatpak_service = flatpak_service + self.configuration_service = configuration_service + + @classmethod + def default_state(cls) -> Dict[str, Any]: + return { + "dxvkFrameRate": 0, + "disableGamescopeWsi": True, + "disableHdr": True, + "disableSteamdeckMode": False, + "disableVkbasalt": False, + "enableZink": False, + } + + @classmethod + def _validate_state(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("Workaround state must be an object") + missing = [field for field in cls.STATE_FIELDS if field not in raw] + if missing: + raise ValueError("Workaround state is missing: " + ", ".join(missing)) + state = {field: raw[field] for field in cls.STATE_FIELDS} + frame_rate = state["dxvkFrameRate"] + if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60: + raise ValueError("Base FPS Cap must be an integer from 0 to 60") + for field in cls.BOOLEAN_FIELDS: + if type(state[field]) is not bool: + raise ValueError(f"{field} must be a boolean") + return state + + def _state_entry(self, app_id: str) -> tuple[Dict[str, object], Dict[str, Any]]: + state = self.flatpak_service._read_state() + entry = state["prepared_apps"].get(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Flatpak application is not owned by this plugin") + return state, entry + + def _baseline_content(self, app_id: str, entry: Dict[str, Any]) -> str: + if not entry.get("override_existed"): + return "" + path = self.flatpak_service._backup_path(app_id) + if path.is_symlink() or not path.is_file(): + raise RuntimeError("Flatpak override backup is unavailable") + return path.read_text(encoding="utf-8") + + @staticmethod + def _environment_value(content: str, key: str) -> str: + section = None + for raw_line in content.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + if section != "Environment": + continue + name, separator, value = line.partition("=") + if separator and name == key: + return value + return "" + + @classmethod + def _dxvk_config(cls, baseline: str, frame_rate: int) -> str: + existing = cls._environment_value(baseline, "DXVK_CONFIG") + parts = [part.strip() for part in existing.split(";") if part.strip()] + parts = [part for part in parts if not cls.DXVK_FRAME_RATE_SEGMENT.match(part)] + if frame_rate > 0: + parts.append(f"dxvk.maxFrameRate = {frame_rate}") + return "; ".join(parts) + + def _restore_baseline(self, app_id: str, entry: Dict[str, Any]) -> None: + existed, current = self.flatpak_service._snapshot_override(app_id) + current_hash = self.flatpak_service._sha256(current) if existed else self.flatpak_service._sha256(b"") + if current_hash != entry.get("managed_sha256"): + raise RuntimeError("Flatpak override changed after preparation; refusing to overwrite unrelated settings") + path = self.flatpak_service._override_path(app_id) + if entry.get("override_existed"): + self.flatpak_service._write_file(path, self._baseline_content(app_id, entry)) + else: + path.unlink(missing_ok=True) + + def _apply_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: + workaround_state = self._validate_state(workaround_state) + _, entry = self._state_entry(app_id) + baseline = self._baseline_content(app_id, entry) + self._restore_baseline(app_id, entry) + prepared = self.flatpak_service.prepare_app(app_id) + if not prepared.get("success") or not prepared.get("owned"): + raise RuntimeError(prepared.get("error") or "Could not restore plugin-owned Flatpak preparation") + profile = self.configuration_service.flatpak_profile_name(app_id) + args = [ + "override", + "--user", + f"--env=LSFGVK_PROFILE={profile}", + "--unset-env=DISABLE_LSFGVK", + "--unset-env=DISABLE_LSFG", + ] + if workaround_state["disableGamescopeWsi"]: + args.extend(["--env=ENABLE_GAMESCOPE_WSI=0", "--unset-env=DISABLE_GAMESCOPE_WSI"]) + if workaround_state["disableHdr"]: + args.append("--env=DXVK_HDR=0") + if workaround_state["disableSteamdeckMode"]: + args.append("--env=SteamDeck=0") + if workaround_state["disableVkbasalt"]: + args.extend(["--env=DISABLE_VKBASALT=1", "--unset-env=ENABLE_VKBASALT"]) + if workaround_state["enableZink"]: + args.extend([ + "--env=__GLX_VENDOR_LIBRARY_NAME=mesa", + "--env=MESA_LOADER_DRIVER_OVERRIDE=zink", + "--env=GALLIUM_DRIVER=zink", + ]) + dxvk_config = self._dxvk_config(baseline, workaround_state["dxvkFrameRate"]) + if dxvk_config: + args.append(f"--env=DXVK_CONFIG={dxvk_config}") + args.append(app_id) + result = self.flatpak_service._run_flatpak_command(args, capture_output=True, text=True) + if result.returncode != 0: + raise OSError(result.stderr.strip() or f"Could not apply Flatpak workarounds for {app_id}") + existed, managed = self.flatpak_service._snapshot_override(app_id) + if not existed: + raise RuntimeError(f"Flatpak override for {app_id} was not created") + state = self.flatpak_service._read_state() + entry = state["prepared_apps"].get(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Flatpak application ownership state disappeared") + entry["managed_sha256"] = self.flatpak_service._sha256(managed) + entry["workaround_state"] = workaround_state + self.flatpak_service._write_state(state) + return workaround_state + + def enable_app(self, app_id: str) -> Dict[str, Any]: + created_profile = False + newly_owned = False + try: + existing = self.configuration_service.get_flatpak_config(app_id) + before = self.flatpak_service._read_state() + was_owned = app_id in before["prepared_apps"] + prepared = self.flatpak_service.prepare_app(app_id) + if not prepared.get("success"): + raise RuntimeError(prepared.get("error") or "Could not prepare Flatpak application") + if not prepared.get("owned"): + raise RuntimeError("Flatpak application is prepared outside this plugin and cannot be managed safely") + newly_owned = not was_owned + if not existing.get("exists"): + config = { + **self.configuration_service._public_config({}), + **(existing.get("global_config") or {}), + } + config["active_in"] = [] + saved = self.configuration_service.update_flatpak_config(app_id, config) + if not saved.get("success"): + raise RuntimeError(saved.get("error") or "Could not create Flatpak profile") + created_profile = True + _, entry = self._state_entry(app_id) + workaround_state = self._validate_state(entry.get("workaround_state", self.default_state())) + self._apply_state(app_id, workaround_state) + return self.get_app(app_id) + except Exception as error: + if created_profile: + self.configuration_service.reset_flatpak_config(app_id) + if newly_owned: + self.flatpak_service.remove_app_override(app_id) + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": False, + } + + def update_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: + try: + self._state_entry(app_id) + result = self.configuration_service.update_flatpak_config(app_id, config) + if not result.get("success"): + raise RuntimeError(result.get("error") or "Could not update Flatpak profile") + return result + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": False, + } + + def get_workaround_state(self, app_id: str) -> Dict[str, Any]: + try: + _, entry = self._state_entry(app_id) + state = self._validate_state(entry.get("workaround_state", self.default_state())) + return { + "success": True, + "message": "", + "error": None, + "app_id": app_id, + "state": state, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "state": None, + } + + def set_workaround_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: + try: + state = self._apply_state(app_id, workaround_state) + return { + "success": True, + "message": "", + "error": None, + "app_id": app_id, + "state": state, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "state": None, + } + + def remove_app(self, app_id: str) -> Dict[str, Any]: + try: + removed = self.flatpak_service.remove_app_override(app_id) + if not removed.get("success"): + raise RuntimeError(removed.get("error") or "Could not remove Flatpak preparation") + reset = self.configuration_service.reset_flatpak_config(app_id) + if not reset.get("success"): + raise RuntimeError(reset.get("error") or "Could not remove Flatpak profile") + return { + "success": True, + "message": "Flatpak profile removed", + "error": None, + "app_id": app_id, + "enabled": False, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": True, + } + + def get_app(self, app_id: str) -> Dict[str, Any]: + apps = self.get_apps() + if not apps.get("success"): + return { + "success": False, + "message": "", + "error": apps.get("error"), + "app_id": str(app_id), + "enabled": False, + } + app = next((item for item in apps.get("apps", []) if item.get("app_id") == app_id), None) + if app is None: + return { + "success": False, + "message": "", + "error": "Flatpak application is not installed", + "app_id": str(app_id), + "enabled": False, + } + return {"success": True, "message": "", "error": None, **app} + + def get_apps(self) -> Dict[str, Any]: + try: + result = self.flatpak_service.get_flatpak_apps() + if not result.get("success"): + raise RuntimeError(result.get("error") or "Could not list Flatpak applications") + ownership = self.flatpak_service._read_state() + apps = [] + for item in result.get("apps", []): + app_id = item["app_id"] + config_result = self.configuration_service.get_flatpak_config(app_id) + entry = ownership["prepared_apps"].get(app_id) + workarounds = self.default_state() + if isinstance(entry, dict): + workarounds = self._validate_state(entry.get("workaround_state", workarounds)) + profile = self.configuration_service.flatpak_profile_name(app_id) + selector_ready = False + if item.get("prepared"): + shown = self.flatpak_service._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + selector_ready = shown.returncode == 0 and f"LSFGVK_PROFILE={profile}" in shown.stdout.splitlines() + apps.append({ + **item, + "profile": profile, + "enabled": bool(item.get("owned") and config_result.get("exists") and selector_ready), + "config": config_result.get("config"), + "workarounds": workarounds, + }) + return { + "success": True, + "message": result.get("message", ""), + "error": None, + "apps": apps, + } + except Exception as error: + return {"success": False, "message": "", "error": str(error), "apps": []} + + def get_running_apps(self) -> Dict[str, Any]: + try: + state = self.flatpak_service._read_state() + enabled = set() + for app_id, entry in state["prepared_apps"].items(): + if not isinstance(entry, dict): + continue + config = self.configuration_service.get_flatpak_config(app_id) + if not config.get("exists"): + continue + existed, content = self.flatpak_service._snapshot_override(app_id) + if not existed or self.flatpak_service._sha256(content) != entry.get("managed_sha256"): + continue + profile = self.configuration_service.flatpak_profile_name(app_id) + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + continue + if self._environment_value(text, "LSFGVK_PROFILE") == profile: + enabled.add(app_id) + if not enabled: + return {"success": True, "message": "", "error": None, "apps": []} + result = self.flatpak_service._run_flatpak_command( + ["ps", "--columns=application,active,pid"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Could not inspect running Flatpak applications") + running = [] + for line in result.stdout.splitlines(): + fields = line.split("\t") if "\t" in line else line.split() + if not fields or fields[0] not in enabled: + continue + active = len(fields) > 1 and fields[1].strip().lower() in {"1", "true", "yes", "active"} + running.append({ + "app_id": fields[0], + "active": active, + "pid": fields[2].strip() if len(fields) > 2 else "", + "start_time": self.flatpak_service._process_start_time( + fields[2].strip() if len(fields) > 2 else "" + ), + }) + return {"success": True, "message": "", "error": None, "apps": running} + except Exception as error: + return {"success": False, "message": "", "error": str(error), "apps": []} diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index f486b74..f2ed90c 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,7 +1,6 @@ -"""Flatpak runtime support for classified Steam targets.""" - from __future__ import annotations +import hashlib import json import os import pwd @@ -13,21 +12,16 @@ from pathlib import Path from typing import Dict, Optional, Set from .base_service import BaseService -from .constants import ( - BIN_DIR, - FLATPAK_23_08_FILENAME, - FLATPAK_24_08_FILENAME, - FLATPAK_25_08_FILENAME, -) class FlatpakService(BaseService): EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" - SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") + FLATHUB_REMOTE = "flathub" + SUPPORTED_RUNTIMES = ("24.08", "25.08") DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"} RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL" - OWNERSHIP_FILENAME = "flatpak_extensions.json" - OWNERSHIP_VERSION = 1 + OWNERSHIP_FILENAME = "flatpak_state.json" + OWNERSHIP_VERSION = 2 APP_ID_PATTERN = re.compile( r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$" ) @@ -35,16 +29,28 @@ class FlatpakService(BaseService): def __init__(self, logger=None): super().__init__(logger) self.flatpak_command: Optional[str] = None + self._verified_branches: Set[str] = set() self._lock = threading.RLock() @property def ownership_path(self) -> Path: return self.config_dir / self.OWNERSHIP_FILENAME + @property + def backup_dir(self) -> Path: + return self.config_dir / "flatpak-overrides" + def _clean_env(self) -> Dict[str, str]: env = os.environ.copy() env.pop("LD_LIBRARY_PATH", None) env["HOME"] = str(self.user_home) + try: + user_id = self.user_home.stat().st_uid + except OSError: + user_id = None + if user_id is not None: + env["XDG_RUNTIME_DIR"] = f"/run/user/{user_id}" + env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{user_id}/bus" path = [entry for entry in env.get("PATH", "").split(":") if entry] for entry in ("/usr/bin", "/usr/local/bin", "/bin"): if entry not in path: @@ -89,13 +95,6 @@ class FlatpakService(BaseService): return branch @classmethod - def runtime_branch_from_ref(cls, runtime_ref: str) -> str: - parts = runtime_ref.strip().split("/") if isinstance(runtime_ref, str) else [] - if len(parts) != 3 or parts[0] != "org.freedesktop.Platform": - raise ValueError(f"Unsupported Flatpak runtime reference: {runtime_ref}") - return cls._validate_runtime(parts[2]) - - @classmethod def runtime_branch_from_metadata(cls, metadata: str) -> str: section = None versions = [] @@ -119,18 +118,8 @@ class FlatpakService(BaseService): def _extension_ref(cls, branch: str) -> str: return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}" - def _bundled_extension_path(self, branch: str) -> Path: - filename = { - "23.08": FLATPAK_23_08_FILENAME, - "24.08": FLATPAK_24_08_FILENAME, - "25.08": FLATPAK_25_08_FILENAME, - }[self._validate_runtime(branch)] - return Path(__file__).resolve().parent.parent.parent / BIN_DIR / filename - def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]: scopes = ("user", "system") if scope is None else (scope,) - if any(item not in ("user", "system") for item in scopes): - raise ValueError("Flatpak installation scope must be user or system") installed = set() for item in scopes: result = self._run_flatpak_command( @@ -145,64 +134,109 @@ class FlatpakService(BaseService): installed.add(fields[2]) return installed - def _owned_branches(self) -> Set[str]: - path = self.ownership_path - if not path.exists() and not path.is_symlink(): - return set() - if path.is_symlink() or not path.is_file(): + def _user_extension_origin(self, branch: str) -> str: + result = self._run_flatpak_command( + ["info", "--user", "--show-origin", self._extension_ref(branch)], + capture_output=True, + text=True, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + def _empty_state(self) -> Dict[str, object]: + return { + "version": self.OWNERSHIP_VERSION, + "plugin_owned_branches": [], + "prepared_apps": {}, + } + + def _read_state(self) -> Dict[str, object]: + if not self.ownership_path.exists(): + return self._empty_state() + if self.ownership_path.is_symlink() or not self.ownership_path.is_file(): raise RuntimeError("Flatpak ownership metadata is not a regular file") try: - data = json.loads(path.read_text(encoding="utf-8")) - branches = data.get("plugin_owned_branches") - if data.get("version") != self.OWNERSHIP_VERSION or not isinstance(branches, list): - raise ValueError("invalid ownership metadata") - owned = {self._validate_runtime(branch) for branch in branches} - if len(owned) != len(branches): - raise ValueError("invalid ownership metadata") - return owned - except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: - raise RuntimeError(f"Could not trust Flatpak ownership metadata: {error}") from error - - def _write_owned_branches(self, branches: Set[str]) -> None: - if not branches: + data = json.loads(self.ownership_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"Could not read Flatpak ownership metadata: {error}") from error + if data.get("version") != self.OWNERSHIP_VERSION: + raise RuntimeError("Unsupported Flatpak ownership metadata version") + branches = data.get("plugin_owned_branches") + apps = data.get("prepared_apps") + if not isinstance(branches, list) or not isinstance(apps, dict): + raise RuntimeError("Invalid Flatpak ownership metadata") + for branch in branches: + self._validate_runtime(branch) + for app_id, entry in apps.items(): + self._validate_app_id(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Invalid Flatpak app ownership metadata") + if type(entry.get("override_existed")) is not bool: + raise RuntimeError("Invalid Flatpak app ownership metadata") + if not isinstance(entry.get("managed_sha256"), str): + raise RuntimeError("Invalid Flatpak app ownership metadata") + return data + + def _write_state(self, state: Dict[str, object]) -> None: + branches = state.get("plugin_owned_branches", []) + apps = state.get("prepared_apps", {}) + if not branches and not apps: self.ownership_path.unlink(missing_ok=True) + if self.backup_dir.exists() and not any(self.backup_dir.iterdir()): + self.backup_dir.rmdir() return self._write_file( self.ownership_path, - json.dumps( - { - "version": self.OWNERSHIP_VERSION, - "plugin_owned_branches": sorted(branches), - }, - indent=2, - ) + "\n", + json.dumps(state, indent=2, sort_keys=True) + "\n", ) - def get_extension_status(self): + def _owned_branches(self, state: Optional[Dict[str, object]] = None) -> Set[str]: + current = state if state is not None else self._read_state() + return {self._validate_runtime(branch) for branch in current["plugin_owned_branches"]} + + def _override_path(self, app_id: str) -> Path: + return self.user_home / ".local/share/flatpak/overrides" / self._validate_app_id(app_id) + + def _backup_path(self, app_id: str) -> Path: + return self.backup_dir / f"{self._validate_app_id(app_id)}.ini" + + @staticmethod + def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + @staticmethod + def _parse_process_start_time(stat_content: str) -> Optional[int]: + closing_command = stat_content.rfind(")") + if closing_command < 0: + return None + fields = stat_content[closing_command + 2:].split() + if len(fields) <= 19: + return None try: - available = self.check_flatpak_available() - installed = self._installed_extension_branches() if available else set() - return self._success_response( - dict, - "Flatpak runtime extension status retrieved" if available else "Flatpak is not available", - available=available, - extension_id=self.EXTENSION_ID, - supported_branches=list(self.SUPPORTED_RUNTIMES), - installed_branches=sorted(installed), - ) - except Exception as error: - return self._error_response( - dict, - str(error), - available=False, - extension_id=self.EXTENSION_ID, - supported_branches=list(self.SUPPORTED_RUNTIMES), - installed_branches=[], - ) + return int(fields[19]) + except (TypeError, ValueError): + return None - get_flatpak_support_status = get_extension_status + @classmethod + def _process_start_time(cls, pid: str) -> Optional[int]: + if not isinstance(pid, str) or re.fullmatch(r"[0-9]+", pid) is None: + return None + try: + stat_content = (Path("/proc") / pid / "stat").read_text(encoding="utf-8") + except OSError: + return None + return cls._parse_process_start_time(stat_content) - def _resolve_runtime(self, app_id: str): + def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]: + path = self._override_path(app_id) + if path.is_symlink(): + raise RuntimeError("Flatpak override path is a symlink") + if not path.exists(): + return False, b"" + if not path.is_file(): + raise RuntimeError("Flatpak override path is not a regular file") + return True, path.read_bytes() + + def _resolve_runtime(self, app_id: str) -> tuple[str, str]: self._validate_app_id(app_id) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") @@ -218,77 +252,146 @@ class FlatpakService(BaseService): if len(parts) != 3: raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") if parts[0] == "org.freedesktop.Platform": - branch = self._validate_runtime(parts[2]) - elif parts[0] in self.DERIVED_RUNTIME_IDS: - metadata_result = self._run_flatpak_command( - ["info", "--show-metadata", runtime], - capture_output=True, - text=True, - ) - if metadata_result.returncode != 0: - raise OSError( - metadata_result.stderr.strip() - or f"Could not inspect Flatpak runtime {runtime}" - ) - branch = self.runtime_branch_from_metadata(metadata_result.stdout) - else: + return runtime, self._validate_runtime(parts[2]) + if parts[0] not in self.DERIVED_RUNTIME_IDS: raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") - return runtime, branch + metadata_result = self._run_flatpak_command( + ["info", "--show-metadata", runtime], + capture_output=True, + text=True, + ) + if metadata_result.returncode != 0: + raise OSError(metadata_result.stderr.strip() or f"Could not inspect Flatpak runtime {runtime}") + return runtime, self.runtime_branch_from_metadata(metadata_result.stdout) - def resolve_app_support(self, app_id: str): + def _dll_directory(self) -> Path: + if self.config_file_path.exists(): + try: + content = self.config_file_path.read_text(encoding="utf-8") + match = re.search( + r'(?m)^[ \t]*dll[ \t]*=[ \t]*"((?:\\.|[^"\\])*)"', + content, + ) + if match: + configured_dll = json.loads('"' + match.group(1) + '"') + if configured_dll: + return Path(configured_dll).parent + except Exception: + pass + return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling" + + def _filesystem_present(self, entries: str, host_path: Path) -> bool: + accepted = {str(host_path)} try: - app_id = self._validate_app_id(app_id) - runtime, branch = self._resolve_runtime(app_id) - installed = self._installed_extension_branches() - ready = branch in installed + accepted.add(f"~/{host_path.relative_to(self.user_home).as_posix()}") + except ValueError: + pass + enabled = False + for raw in entries.split(";"): + value = raw.strip() + if not value: + continue + denied = value.startswith("!") + path = value[1:] if denied else value + path = path.split(":", 1)[0] + if path in accepted: + if denied: + return False + enabled = True + return enabled + + def _app_override_status(self, app_id: str) -> Dict[str, object]: + result = self._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + output = result.stdout if result.returncode == 0 else "" + section = None + filesystems = "" + unset_environment = set() + environment = {} + for raw_line in output.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + key, separator, value = line.partition("=") + if not separator: + continue + if section == "Context" and key == "filesystems": + filesystems = value + elif section == "Context" and key == "unset-environment": + unset_environment.update(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + config_ready = self._filesystem_present(filesystems, self.config_dir) + dll_ready = self._filesystem_present(filesystems, self._dll_directory()) + env_ready = ( + environment.get("LSFGVK_CONFIG") == str(self.config_file_path) + and environment.get("LSFGVK_FLATPAK") == "1" + and "DISABLE_LSFGVK" in unset_environment + and "DISABLE_LSFG" in unset_environment + ) + return { + "filesystem_ready": config_ready and dll_ready, + "environment_ready": env_ready, + "prepared": config_ready and dll_ready and env_ready, + } + + def get_extension_status(self): + try: + available = self.check_flatpak_available() + installed = self._installed_extension_branches() if available else set() return self._success_response( dict, - f"lsfg-vk support is ready for {app_id}" if ready - else f"lsfg-vk runtime extension {branch} is required for {app_id}", - flatpak_app_id=app_id, - runtime=runtime, - runtime_branch=branch, - support_status="ready" if ready else "needs-runtime", - extension_installed=ready, + "Flatpak runtime extension status retrieved" if available else "Flatpak is not available", + available=available, + extension_id=self.EXTENSION_ID, + supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=sorted(installed), ) - except ValueError as error: - return self._success_response( - dict, - str(error), - flatpak_app_id=app_id, - runtime=None, - runtime_branch=None, - support_status="unsupported", - extension_installed=False, - installed_branches=[], - error=str(error), - ) except Exception as error: return self._error_response( dict, str(error), - flatpak_app_id=app_id, - runtime=None, - runtime_branch=None, - support_status="error", - extension_installed=False, + available=False, + extension_id=self.EXTENSION_ID, + supported_branches=list(self.SUPPORTED_RUNTIMES), installed_branches=[], ) + get_flatpak_support_status = get_extension_status + def install_extension(self, branch: str): try: branch = self._validate_runtime(branch) if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - if branch in self._installed_extension_branches(): - return self._extension_result(branch, True, False, "already installed") - bundle = self._bundled_extension_path(branch) - if not bundle.is_file(): - raise FileNotFoundError(f"Bundled Flatpak extension not found at {bundle}; reinstall the plugin") + if branch in self._verified_branches: + return self._extension_result(branch, True, False, "is ready") + user_installed = self._installed_extension_branches("user") + system_installed = self._installed_extension_branches("system") + if branch in system_installed and branch not in user_installed: + return self._extension_result(branch, True, False, "is ready") + if branch in user_installed and self._user_extension_origin(branch) != self.FLATHUB_REMOTE: + result = self._run_flatpak_command( + ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Could not replace the existing Flatpak extension") result = self._run_flatpak_command( - ["install", "--user", "--noninteractive", "--or-update", str(bundle)], + [ + "install", + "--user", + "--noninteractive", + "--or-update", + self.FLATHUB_REMOTE, + f"{self.EXTENSION_ID}//{branch}", + ], capture_output=True, text=True, ) @@ -296,9 +399,12 @@ class FlatpakService(BaseService): raise OSError(result.stderr.strip() or "Flatpak installation failed") if branch not in self._installed_extension_branches("user"): raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards") - owned = self._owned_branches() + state = self._read_state() + owned = self._owned_branches(state) owned.add(branch) - self._write_owned_branches(owned) + state["plugin_owned_branches"] = sorted(owned) + self._write_state(state) + self._verified_branches.add(branch) return self._extension_result(branch, True, False, "installed") except Exception as error: return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False) @@ -313,8 +419,6 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Flatpak uninstall failed") - if branch in self._installed_extension_branches("user"): - raise RuntimeError(f"Flatpak uninstall completed but {self._extension_ref(branch)} is still installed") return True def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str): @@ -333,99 +437,226 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - owned = self._owned_branches() + state = self._read_state() + owned = self._owned_branches(state) if branch not in owned: installed = branch in self._installed_extension_branches() return self._extension_result(branch, installed, False, "preserved (not plugin-owned)") removed = self._remove_extension(branch) owned.remove(branch) - self._write_owned_branches(owned) + state["plugin_owned_branches"] = sorted(owned) + self._write_state(state) installed = branch in self._installed_extension_branches() return self._extension_result(branch, installed, removed, "uninstalled") except Exception as error: - return self._error_response( - dict, - str(error), - runtime_branch=branch, - removed=False, - installed=False, - enabled=False, - ) + return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False) def ensure_extension(self, branch: str): - try: - branch = self._validate_runtime(branch) - if branch in self._installed_extension_branches(): - return self._extension_result(branch, True, False, "is ready") - except Exception as error: - return self._error_response(dict, str(error), runtime_branch=branch, support_status="error") return self.install_extension(branch) - def ensure_app_support(self, app_id: str): - resolved = self.resolve_app_support(app_id) - if not resolved.get("success") or resolved.get("support_status") != "needs-runtime": - return resolved - result = self.ensure_extension(resolved["runtime_branch"]) - if not result.get("success"): - return self._error_response( - dict, - result.get("error") or "Could not install the required Flatpak runtime extension", - flatpak_app_id=app_id, - runtime=resolved.get("runtime"), - runtime_branch=resolved.get("runtime_branch"), - support_status="error", - extension_installed=False, - ) - return self.resolve_app_support(app_id) - def set_extension_enabled(self, branch: str, enabled: bool): if type(enabled) is not bool: return self._error_response(dict, "enabled must be a boolean", runtime_branch=branch, installed=False, enabled=False) return self.install_extension(branch) if enabled else self.uninstall_extension(branch) - def remove_plugin_owned_extensions(self): + def get_flatpak_apps(self): + try: + if not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak is not available on this system") + installed_extensions = self._installed_extension_branches() + state = self._read_state() + owned_apps = state["prepared_apps"] + result = self._run_flatpak_command( + ["list", "--app", "--columns=name,application"], + capture_output=True, + text=True, + check=True, + ) + apps = [] + for line in result.stdout.splitlines(): + fields = line.split("\t") + if len(fields) < 2: + continue + name, app_id = fields[0].strip(), fields[1].strip() + if not app_id: + continue + item = { + "app_id": app_id, + "app_name": name or app_id, + "runtime": None, + "runtime_branch": None, + "runtime_ready": False, + "prepared": False, + "owned": app_id in owned_apps, + "error": None, + } + try: + runtime, branch = self._resolve_runtime(app_id) + status = self._app_override_status(app_id) + item.update({ + "runtime": runtime, + "runtime_branch": branch, + "runtime_ready": branch in installed_extensions, + "prepared": status["prepared"], + }) + except Exception as error: + item["error"] = str(error) + apps.append(item) + apps.sort(key=lambda item: str(item["app_name"]).lower()) + return self._success_response(dict, f"Found {len(apps)} Flatpak applications", apps=apps) + except Exception as error: + return self._error_response(dict, str(error), apps=[]) + + def prepare_app(self, app_id: str): + try: + app_id = self._validate_app_id(app_id) + with self._lock: + runtime, branch = self._resolve_runtime(app_id) + extension = self.ensure_extension(branch) + if not extension.get("success") or not extension.get("installed"): + raise RuntimeError(extension.get("error") or f"Could not install Flatpak runtime {branch}") + state = self._read_state() + apps = state["prepared_apps"] + status = self._app_override_status(app_id) + if status["prepared"] and app_id not in apps: + return self._success_response( + dict, + "Flatpak application is already prepared outside this plugin", + app_id=app_id, + runtime=runtime, + runtime_branch=branch, + prepared=True, + owned=False, + ) + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + backup = self._backup_path(app_id) + if existed: + self._write_file(backup, original.decode("utf-8")) + else: + backup.unlink(missing_ok=True) + apps[app_id] = { + "override_existed": existed, + "managed_sha256": "", + } + result = self._run_flatpak_command( + [ + "override", + "--user", + f"--filesystem={self.config_dir}:ro", + f"--filesystem={self._dll_directory()}:ro", + f"--env=LSFGVK_CONFIG={self.config_file_path}", + "--env=LSFGVK_FLATPAK=1", + "--unset-env=DISABLE_LSFGVK", + "--unset-env=DISABLE_LSFG", + app_id, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or f"Could not prepare Flatpak app {app_id}") + status = self._app_override_status(app_id) + if not status["prepared"]: + raise RuntimeError(f"Flatpak preparation did not become visible for {app_id}") + existed, managed = self._snapshot_override(app_id) + if not existed: + raise RuntimeError(f"Flatpak override for {app_id} was not created") + apps[app_id]["managed_sha256"] = self._sha256(managed) + self._write_state(state) + return self._success_response( + dict, + "Flatpak application prepared for lsfg-vk", + app_id=app_id, + runtime=runtime, + runtime_branch=branch, + prepared=True, + owned=True, + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=False) + + def remove_app_override(self, app_id: str): try: + app_id = self._validate_app_id(app_id) with self._lock: - owned = self._owned_branches() - if not owned: + state = self._read_state() + apps = state["prepared_apps"] + entry = apps.get(app_id) + if entry is None: return self._success_response( dict, - "No plugin-owned Flatpak extensions to remove", + "Flatpak application is not plugin-owned; existing overrides were preserved", + app_id=app_id, + prepared=self._app_override_status(app_id)["prepared"], + owned=False, + ) + existed, current = self._snapshot_override(app_id) + current_hash = self._sha256(current) if existed else self._sha256(b"") + if current_hash != entry["managed_sha256"]: + raise RuntimeError( + "Flatpak override changed after preparation; refusing to overwrite unrelated settings" + ) + override_path = self._override_path(app_id) + backup_path = self._backup_path(app_id) + if entry["override_existed"]: + if not backup_path.is_file() or backup_path.is_symlink(): + raise RuntimeError("Flatpak override backup is unavailable") + self._write_file(override_path, backup_path.read_text(encoding="utf-8")) + else: + override_path.unlink(missing_ok=True) + backup_path.unlink(missing_ok=True) + apps.pop(app_id, None) + self._write_state(state) + return self._success_response( + dict, + "Plugin-owned Flatpak preparation removed", + app_id=app_id, + prepared=False, + owned=False, + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=True) + + def remove_plugin_owned_environment(self): + try: + with self._lock: + state = self._read_state() + failures = [] + removed_apps = [] + for app_id in list(state["prepared_apps"]): + result = self.remove_app_override(app_id) + if result.get("success"): + removed_apps.append(app_id) + else: + failures.append(f"{app_id}: {result.get('error')}") + if failures: + return self._error_response( + dict, + "; ".join(failures), + removed_apps=removed_apps, removed_branches=[], - preserved_branches=[], - ownership_uncertain=False, ) - if not self.check_flatpak_available(): - raise RuntimeError("Flatpak is not available; plugin-owned extension metadata was preserved") - removed, failures = [], [] - for branch in sorted(owned): - try: - self._remove_extension(branch) - removed.append(branch) - except Exception as error: - failures.append(f"{branch}: {error}") - remaining = owned - set(removed) - self._write_owned_branches(remaining) + state = self._read_state() + removed_branches = [] + for branch in sorted(self._owned_branches(state)): + result = self.uninstall_extension(branch) + if result.get("success"): + removed_branches.append(branch) + else: + failures.append(f"{branch}: {result.get('error')}") if failures: return self._error_response( dict, "; ".join(failures), - removed_branches=removed, - preserved_branches=sorted(remaining), - ownership_uncertain=False, + removed_apps=removed_apps, + removed_branches=removed_branches, ) return self._success_response( dict, - "Plugin-owned Flatpak extensions removed", - removed_branches=removed, - preserved_branches=[], - ownership_uncertain=False, + "Plugin-owned Flatpak state removed", + removed_apps=removed_apps, + removed_branches=removed_branches, ) except Exception as error: - return self._error_response( - dict, - str(error), - removed_branches=[], - preserved_branches=[], - ownership_uncertain=True, - ) + return self._error_response(dict, str(error), removed_apps=[], removed_branches=[]) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index a73706c..e7366ee 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -162,6 +162,25 @@ class InstallationService(BaseService): for path in (self.legacy_lib_file, self.legacy_json_file): self._remove_if_exists(path) + def _prune_empty_directories(self) -> None: + # Only prune directories created by this plugin. Never remove a + # non-empty directory because the user's other tools may use it. + candidates = ( + self.config_dir, + self.local_bin_dir, + self.local_lib_dir, + self.local_share_dir, + self.user_home / LOCAL_SHARE / "applications", + self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps", + ) + for directory in candidates: + try: + if directory.is_dir() and not directory.is_symlink(): + directory.rmdir() + self.log.info(f"Removed empty directory {directory}") + except OSError: + continue + def check_installation(self) -> InstallationCheckResponse: try: installation_error = None @@ -200,9 +219,12 @@ class InstallationService(BaseService): self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME, self.legacy_lib_file, self.legacy_json_file, + self.legacy_script_path, + self.config_file_path, ) if self._remove_if_exists(path) ] + self._prune_empty_directories() if not removed: return self._success_response( UninstallationResponse, @@ -221,9 +243,14 @@ class InstallationService(BaseService): removed_files=None, ) - def cleanup_on_uninstall(self) -> None: + def cleanup_on_uninstall(self) -> bool: try: - self.uninstall() + result = self.uninstall() + if not result.get("success"): + self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {result.get('error')}") + return False + return True except Exception as error: self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {error}") self.log.error(traceback.format_exc()) + return False diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 071b19b..c63e3a9 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -1,9 +1,10 @@ import os -from typing import Any, Dict, Optional +from typing import Any, Dict import decky from .configuration import ConfigurationService +from .flatpak_profile_service import FlatpakProfileService from .flatpak_service import FlatpakService from .installation import InstallationService from .runtime_service import RuntimeService @@ -21,6 +22,10 @@ class Plugin: ) self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() + self.flatpak_profile_service = FlatpakProfileService( + self.flatpak_service, + self.configuration_service, + ) self.wrapper_service = WrapperService() async def install_lsfg_vk(self): @@ -29,28 +34,37 @@ class Plugin: async def check_lsfg_vk_installed(self): return self.installation_service.check_installation() + def _cleanup_runtime_state(self, preserve_wrapper: bool = False): + flatpak = self.flatpak_service.remove_plugin_owned_environment() + if not flatpak.get("success"): + return flatpak.get("error") or "Could not clean up Flatpak support" + profiles = self.configuration_service.reset_all_flatpak_configs() + if not profiles.get("success"): + return profiles.get("error") or "Could not remove Flatpak profiles" + wrapper = self.wrapper_service.neutralize() if preserve_wrapper else self.wrapper_service.purge() + if not wrapper.get("success"): + return wrapper.get("error") or "Could not remove workaround state" + return None + async def uninstall_lsfg_vk(self): + error = self._cleanup_runtime_state() + if error: + return { + "success": False, + "message": "", + "error": error, + "removed_files": None, + } return self.installation_service.uninstall() async def get_game_configs(self): return self.configuration_service.get_game_configs() + async def update_global_config(self, config: Dict[str, Any]): + return self.configuration_service.update_global_config(config) + async def get_installed_games(self): - result = self.steam_service.get_installed_games() - if not result.get("success"): - return result - cache: Dict[str, Dict[str, Any]] = {} - for game in result.get("games", []): - transport = game.get("transport", {}) - if transport.get("kind") != "flatpak": - continue - app_id = transport.get("flatpakAppId") - if not app_id: - continue - if app_id not in cache: - cache[app_id] = self.flatpak_service.resolve_app_support(app_id) - game["flatpakSupport"] = cache[app_id] - return result + return self.steam_service.get_installed_games() async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]): return self.configuration_service.update_game_config(appid, game_name, config) @@ -58,27 +72,26 @@ class Plugin: async def reset_game_config(self, appid: str): return self.configuration_service.reset_game_config(appid) + async def reset_game_configs(self, appids): + return self.configuration_service.reset_game_configs(appids) + async def reset_all_game_configs(self): return self.configuration_service.reset_all_game_configs() async def get_workaround_state(self, appid: str): return self.wrapper_service.get(appid) + async def get_workaround_apps(self): + return self.wrapper_service.list_apps() + async def set_workaround_state( self, appid: str, state: Dict[str, Any], - shortcut_exe: Optional[str] = None, command_token_added: bool = False, - transport: Optional[Dict[str, Any]] = None, + non_steam: bool = False, ): - return self.wrapper_service.set( - appid, - state, - shortcut_exe, - command_token_added, - transport, - ) + return self.wrapper_service.set(appid, state, command_token_added, non_steam) async def remove_workaround_state(self, appid: str): return self.wrapper_service.remove(appid) @@ -107,20 +120,66 @@ class Plugin: "error": f"Error reading config file: {error}", } + async def get_debug_file_contents(self): + files = ( + ("config", "LSFG-VK configuration", self.configuration_service.config_file_path), + ("workarounds", "Per-app workarounds", self.wrapper_service.sidecar_path), + ("wrapper", "Generated launch wrapper", self.wrapper_service.wrapper_path), + ("flatpak", "Flatpak ownership state", self.flatpak_service.ownership_path), + ) + contents = [] + for file_id, label, path in files: + item = { + "id": file_id, + "label": label, + "path": str(path), + "exists": False, + "content": None, + "error": None, + } + try: + if path.is_symlink(): + item["error"] = "Path is a symlink; refusing to read it" + elif not path.exists(): + item["error"] = "File does not exist" + elif not path.is_file(): + item["error"] = "Path is not a regular file" + else: + item["exists"] = True + item["content"] = path.read_text(encoding="utf-8") + except Exception as error: + item["error"] = f"Error reading file: {error}" + contents.append(item) + return { + "success": True, + "message": "Debug file contents retrieved", + "error": None, + "files": contents, + } + async def get_lossless_scaling_branch_status(self): return self.steam_service.get_branch_status() - async def get_flatpak_support_status(self): - return self.flatpak_service.get_flatpak_support_status() + async def get_flatpak_apps(self): + return self.flatpak_profile_service.get_apps() - async def ensure_flatpak_support(self, flatpak_app_id: str): - return self.flatpak_service.ensure_app_support(flatpak_app_id) + async def enable_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_profile_service.enable_app(flatpak_app_id) - async def repair_flatpak_support(self, flatpak_app_id: str): - return self.flatpak_service.ensure_app_support(flatpak_app_id) + async def update_flatpak_config(self, flatpak_app_id: str, config: Dict[str, Any]): + return self.flatpak_profile_service.update_config(flatpak_app_id, config) - async def set_flatpak_extension_enabled(self, version: str, enabled: bool): - return self.flatpak_service.set_extension_enabled(version, enabled) + async def get_flatpak_workaround_state(self, flatpak_app_id: str): + return self.flatpak_profile_service.get_workaround_state(flatpak_app_id) + + async def set_flatpak_workaround_state(self, flatpak_app_id: str, state: Dict[str, Any]): + return self.flatpak_profile_service.set_workaround_state(flatpak_app_id, state) + + async def remove_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_profile_service.remove_app(flatpak_app_id) + + async def get_running_flatpak_apps(self): + return self.flatpak_profile_service.get_running_apps() async def _main(self): repair = self.wrapper_service.repair() @@ -133,13 +192,15 @@ class Plugin: async def _uninstall(self): decky.logger.info("decky-lsfg-vk plugin being uninstalled") - self.installation_service.cleanup_on_uninstall() try: - result = self.flatpak_service.remove_plugin_owned_extensions() - if not result.get("success"): - decky.logger.warning(result.get("error")) + error = self._cleanup_runtime_state(preserve_wrapper=True) + if error: + decky.logger.warning(f"Preserving lsfg-vk files because uninstall cleanup failed: {error}") + return except Exception as error: - decky.logger.error(f"Error during Flatpak cleanup: {error}") + decky.logger.error(f"Error during lsfg-vk cleanup: {error}") + return + self.installation_service.cleanup_on_uninstall() decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed") async def _migration(self): diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 201441e..93f2593 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -1,5 +1,4 @@ import re -import shlex from pathlib import Path from typing import Dict, Optional, Tuple @@ -7,65 +6,40 @@ from .base_service import BaseService from .constants import ( STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH, - WRAPPER_FILENAME, ) -_FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$") -_WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" - - -def _split_command(value: Optional[str]) -> Optional[list[str]]: - if not isinstance(value, str) or not value.strip(): - return [] - try: - return shlex.split(value, posix=True) - except ValueError: - return None - - -def _is_managed_wrapper(value: str) -> bool: - if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}: - return True - path = Path(value) - return path.is_absolute() and path.name == WRAPPER_FILENAME - - -def classify_shortcut_transport(executable: Optional[str], launch_options: Optional[str] = None) -> Dict[str, object]: - executable_tokens = _split_command(executable) - option_tokens = _split_command(launch_options) - if executable_tokens is None or option_tokens is None or not executable_tokens: - return {"kind": "host"} - direct_flatpak = executable_tokens[0] == "/usr/bin/flatpak" - managed_wrapper = len(executable_tokens) == 1 and _is_managed_wrapper(executable_tokens[0]) - if not direct_flatpak and not managed_wrapper: - return {"kind": "host"} - arguments = [*executable_tokens[1:], *option_tokens] - if not arguments or arguments[0] != "run": - return {"kind": "host"} - for argument in arguments[1:]: - if argument == "--" or argument.startswith("-"): - continue - return ( - {"kind": "flatpak", "flatpakAppId": argument} - if _FLATPAK_APP_ID.fullmatch(argument) - else {"kind": "host"} - ) - return {"kind": "host"} - - -def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: - return next((values[key] for key in keys if isinstance(values.get(key), str)), None) - class SteamService(BaseService): DEFAULT_BRANCH = "public" MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" + # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG. GAME_SELECTOR_EXCLUDED_APPIDS = { - "858280", "961940", "1054830", "1113280", "1245040", "1420170", - "1493710", "1580130", "1887720", "2180100", "228980", "2348590", - "2805730", "3029110", "3127680", "3658110", "4183110", "4185400", - "4427310", "4628710", "4628740", "4690330", "993090", "1070560", - "1391110", "1628350", + "858280", # Proton 3.7 + "961940", # Proton 3.16 + "1054830", # Proton 4.2 + "1113280", # Proton 4.11 + "1245040", # Proton 5.0 + "1420170", # Proton 5.13 + "1493710", # Proton Experimental + "1580130", # Proton 6.3 + "1887720", # Proton 7 + "2180100", # Proton Hotfix + "228980", # Steamworks Common Redistributables + "2348590", # Proton 8 + "2805730", # Proton 9 + "3029110", # Lepton + "3127680", # fex + "3658110", # Proton 10 + "4183110", # Steam Linux Runtime 4.0 + "4185400", # Steam Linux Runtime 4.0 for arm64 + "4427310", # Proton Experimental (ARM64) + "4628710", # Proton 11 / Proton Next + "4628740", # Proton 11 (ARM64) + "4690330", # Legacy Steam Runtime + "993090", # Lossless Scaling + "1070560", # Steam Linux Runtime 1.0 + "1391110", # Steam Linux Runtime 2.0 + "1628350", # Steam Linux Runtime 3.0 } def _steam_roots(self): @@ -147,18 +121,14 @@ class SteamService(BaseService): name = shortcut.get("AppName") or shortcut.get("appname") if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name: return None - executable = _first_string(shortcut, "Exe", "exe", "executable") - arguments = _first_string(shortcut, "LaunchOptions", "launchoptions", "launch_options", "arguments") - start_dir = _first_string(shortcut, "StartDir", "startdir", "start_dir") - game: Dict[str, object] = { + game = { "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "transport": classify_shortcut_transport(executable, arguments), } - for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): - if value is not None: - game[key] = value + executable = shortcut.get("Exe") or shortcut.get("exe") + if isinstance(executable, str) and executable.strip().strip('"') in {"flatpak", "/usr/bin/flatpak"}: + game["isFlatpakShortcut"] = True return game def _shortcut_games(self): @@ -309,7 +279,6 @@ class SteamService(BaseService): "appid": appid, "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, - "transport": {"kind": "host"}, } for game in self._shortcut_games(): games.setdefault(str(game["appid"]), game) diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py index dae565b..30c2323 100644 --- a/py_modules/lsfg_vk/wrapper_service.py +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -1,12 +1,9 @@ -"""Own the small per-AppID workaround dispatcher used by Steam launches.""" - from __future__ import annotations import json import re import shlex import threading -from pathlib import Path from typing import Any, Dict, Optional, Tuple from .base_service import BaseService @@ -14,8 +11,6 @@ from .constants import WRAPPER_FILENAME class WrapperService(BaseService): - """Persist workaround state and compile it into a safe POSIX wrapper.""" - LEGACY_FORMAT_VERSION = 1 FORMAT_VERSION = 2 LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1" @@ -35,6 +30,8 @@ class WrapperService(BaseService): "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck", + "DISABLE_LSFGVK", + "DISABLE_LSFG", "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE", @@ -84,51 +81,18 @@ class WrapperService(BaseService): return state @classmethod - def _validate_transport(cls, raw: Any) -> Dict[str, Any]: - if raw is None: - return {"kind": "host"} - if not isinstance(raw, dict): - raise ValueError("Workaround transport must be an object") - kind = raw.get("kind") - if kind == "host": - return {"kind": "host"} - if kind == "flatpak": - app_id = raw.get("flatpakAppId") - if ( - not isinstance(app_id, str) - or not re.fullmatch( - r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$", - app_id, - ) - ): - raise ValueError("Flatpak transport requires a valid application ID") - return {"kind": "flatpak", "flatpakAppId": app_id} - raise ValueError("Workaround transport must be host or flatpak") - - @classmethod def _validate_entry(cls, raw: Any) -> Dict[str, Any]: if not isinstance(raw, dict): raise ValueError("Workaround AppID entry must be an object") entry = { "state": cls._validate_state(raw.get("state")), "command_token_added": raw.get("command_token_added", False), - # Version 1 entries had no transport field. They are preserved as - # host entries until the shortcut is explicitly repaired with the - # backend's classified transport. - "transport": cls._validate_transport(raw.get("transport")), + "non_steam": raw.get("non_steam", False), } if type(entry["command_token_added"]) is not bool: raise ValueError("command_token_added must be a boolean") - if "shortcut_exe" in raw and raw["shortcut_exe"] is not None: - shortcut_exe = raw["shortcut_exe"] - if ( - not isinstance(shortcut_exe, str) - or not shortcut_exe.startswith("/") - or "\x00" in shortcut_exe - or not shortcut_exe.strip() - ): - raise ValueError("shortcut_exe must be an absolute executable path") - entry["shortcut_exe"] = shortcut_exe + if type(entry["non_steam"]) is not bool: + raise ValueError("non_steam must be a boolean") return entry @classmethod @@ -158,11 +122,11 @@ class WrapperService(BaseService): if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file(): raise RuntimeError("Workaround state path is not a regular file") try: - raw = json.loads(self.sidecar_path.read_text(encoding="utf-8")) + content = self.sidecar_path.read_text(encoding="utf-8") + raw = json.loads(content) except (OSError, json.JSONDecodeError) as error: raise RuntimeError(f"Could not read workaround state: {error}") from error - document = self._validate_document(raw) - return document, True, self.sidecar_path.read_text(encoding="utf-8") + return self._validate_document(raw), True, content def _wrapper_marker(self) -> bool: if self.wrapper_path.is_symlink() or not self.wrapper_path.exists(): @@ -179,29 +143,21 @@ class WrapperService(BaseService): if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink(): return False if self.wrapper_path.is_symlink() or not self._wrapper_marker(): - raise RuntimeError( - f"Refusing to replace unowned wrapper at {self.wrapper_path}" - ) + raise RuntimeError(f"Refusing to replace unowned wrapper at {self.wrapper_path}") return True @staticmethod def _shell(value: str) -> str: return shlex.quote(value) - @staticmethod - def _direct_flatpak_tokens(value: str) -> Optional[list[str]]: - """Parse the supported full executable form: /usr/bin/flatpak run APP.""" - try: - tokens = shlex.split(value, posix=True) - except ValueError: - return None - if len(tokens) >= 3 and Path(tokens[0]).name == "flatpak" and tokens[1] == "run": - return tokens - return None - - @classmethod - def _state_lines(cls, state: Dict[str, Any], shortcut_exe: Optional[str]) -> list[str]: - lines = [" unset " + " ".join(cls.MANAGED_ENV_KEYS)] + def _state_lines(self, state: Dict[str, Any]) -> list[str]: + lines = [" unset " + " ".join(self.MANAGED_ENV_KEYS)] + lines.extend([ + ' SteamAppId="$appid"', + " export SteamAppId", + f" LSFGVK_CONFIG={self._shell(str(self.config_file_path))}", + " export LSFGVK_CONFIG", + ]) if state["disableGamescopeWsi"]: lines.extend([" ENABLE_GAMESCOPE_WSI=0", " export ENABLE_GAMESCOPE_WSI"]) if state["disableHdr"]: @@ -233,72 +189,12 @@ class WrapperService(BaseService): " fi", " export DXVK_CONFIG", ]) - lines.append(f" shortcut_exe={cls._shell(shortcut_exe or '')}") return lines - def _dll_directory(self) -> Path: - if self.config_file_path.exists(): - try: - content = self.config_file_path.read_text(encoding="utf-8") - match = re.search( - r'(?m)^[ \t]*dll[ \t]*=[ \t]*"((?:\\.|[^"\\])*)"', - content, - ) - if match: - configured_dll = json.loads('"' + match.group(1) + '"') - if configured_dll: - return Path(configured_dll).parent - except Exception: - pass - return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling" - - def _flatpak_args(self, state: Dict[str, Any]) -> list[str]: - config_dir = str(self.config_dir) - config_file = str(self.config_file_path) - dll_dir = str(self._dll_directory()) - args = [ - self._shell(f"--filesystem={config_dir}:rw"), - self._shell(f"--filesystem={dll_dir}:ro"), - self._shell(f"--env=LSFGVK_CONFIG={config_file}"), - '"--env=LSFGVK_FLATPAK=1"', - '"--env=SteamAppId=$appid"', - '"--unset-env=DISABLE_GAMESCOPE_WSI"', - '"--unset-env=ENABLE_GAMESCOPE_WSI"' if not state["disableGamescopeWsi"] else - '"--env=ENABLE_GAMESCOPE_WSI=0"', - '"--unset-env=DXVK_HDR"' if not state["disableHdr"] else - '"--env=DXVK_HDR=0"', - '"--unset-env=SteamDeck"' if not state["disableSteamdeckMode"] else - '"--env=SteamDeck=0"', - '"--unset-env=DISABLE_VKBASALT" "--unset-env=ENABLE_VKBASALT"', - ] - if state["disableVkbasalt"]: - args.append('"--env=DISABLE_VKBASALT=1"') - args.extend([ - '"--unset-env=MESA_LOADER_DRIVER_OVERRIDE" "--unset-env=__GLX_VENDOR_LIBRARY_NAME" "--unset-env=GALLIUM_DRIVER"', - ]) - if state["enableZink"]: - args.extend([ - '"--env=__GLX_VENDOR_LIBRARY_NAME=mesa"', - '"--env=MESA_LOADER_DRIVER_OVERRIDE=zink"', - '"--env=GALLIUM_DRIVER=zink"', - ]) - args.extend([ - '"--unset-env=DXVK_FRAME_RATE"', - ]) - static_args = " ".join(args) - return [ - ' if [ -n "${DXVK_CONFIG+x}" ]; then', - f' set -- "$flatpak_command" {static_args} "--env=DXVK_CONFIG=$DXVK_CONFIG" "$@"', - " else", - f' set -- "$flatpak_command" {static_args} "$@"', - " fi", - ] - def _render_wrapper(self, document: Dict[str, Any]) -> str: lines = [ "#!/bin/sh", self.MARKER, - "# Generated by Decky LSFG-VK; edits will be rejected on the next update.", "", "appid=", 'case "${SteamAppId-}" in', @@ -317,77 +213,25 @@ class WrapperService(BaseService): ' *) appid="${STEAM_COMPAT_APP_ID}" ;;', " esac", "fi", - "shortcut_exe=", 'case "$appid" in', ] for appid in sorted(document["apps"], key=lambda value: int(value)): - entry = document["apps"][appid] lines.append(f" {appid})") - lines.extend(self._state_lines(entry["state"], entry.get("shortcut_exe"))) + lines.extend(self._state_lines(document["apps"][appid]["state"])) lines.append(" ;;") lines.extend([ "esac", - "", - 'if [ -n "$shortcut_exe" ]; then', - ]) - # The arguments are emitted per branch below so the values are static and - # the wrapper never needs a JSON parser or another helper executable. - lines.append(' case "$appid" in') - for appid in sorted(document["apps"], key=lambda value: int(value)): - entry = document["apps"][appid] - transport = entry.get("transport", {"kind": "host"}) - if transport.get("kind") != "flatpak": - continue - shortcut_exe = entry.get("shortcut_exe", "") - direct_flatpak_tokens = self._direct_flatpak_tokens(shortcut_exe) - if direct_flatpak_tokens is None and Path(shortcut_exe).name != "flatpak": - raise ValueError( - f"Flatpak target {appid} does not use a direct flatpak executable" - ) - lines.append(f" {appid})") - lines.extend([ - *( - [ - f" shortcut_exe={self._shell(direct_flatpak_tokens[0])}", - " set -- " - + " ".join(self._shell(token) for token in direct_flatpak_tokens[1:]) - + ' "$@"', - ] - if direct_flatpak_tokens - else [] - ), - ' if [ "${1-}" != "run" ]; then', - ' echo "lsfg-vk: Flatpak shortcut must use direct flatpak run transport" >&2', - " exit 64", - " fi", - ' flatpak_command="$1"', - " shift", - " flatpak_target=", - ' for flatpak_arg in "$@"; do', - ' case "$flatpak_arg" in', - ' -*) ;;', - ' *) flatpak_target="$flatpak_arg"; break ;;', - " esac", - " done", - f' if [ "$flatpak_target" != {self._shell(transport["flatpakAppId"])} ]; then', - ' echo "lsfg-vk: Flatpak shortcut application ID changed externally" >&2', - " exit 64", - " fi", - ]) - lines.extend(self._flatpak_args(entry["state"])) - lines.append(" ;;") - lines.extend([ - " esac", - ' exec "$shortcut_exe" "$@"', - "fi", 'exec "$@"', "", ]) return "\n".join(lines) def _write_document(self, document: Dict[str, Any]) -> None: - content = json.dumps(document, indent=2, sort_keys=True) + "\n" - self._write_file(self.sidecar_path, content, 0o644) + self._write_file( + self.sidecar_path, + json.dumps(document, indent=2, sort_keys=True) + "\n", + 0o644, + ) def _write_pair(self, document: Dict[str, Any]) -> None: old_sidecar_exists = self.sidecar_path.exists() @@ -421,9 +265,8 @@ class WrapperService(BaseService): "state": dict(entry["state"]) if entry else None, "wrapper_path": self.WRAPPER_TOKEN, "wrapper_owned": self._wrapper_marker() if document["apps"] else False, - "shortcut_exe": entry.get("shortcut_exe") if entry else None, "command_token_added": entry.get("command_token_added", False) if entry else False, - "transport": dict(entry.get("transport", {"kind": "host"})) if entry else None, + "non_steam": entry.get("non_steam", False) if entry else False, } def get(self, appid: str) -> Dict[str, Any]: @@ -442,44 +285,31 @@ class WrapperService(BaseService): "state": None, "wrapper_path": self.WRAPPER_TOKEN, "wrapper_owned": False, + "non_steam": False, } def set( self, appid: str, state: Dict[str, Any], - shortcut_exe: Optional[str] = None, command_token_added: bool = False, - transport: Optional[Dict[str, Any]] = None, + non_steam: bool = False, ) -> Dict[str, Any]: try: normalized = self._valid_appid(appid) validated_state = self._validate_state(state) if type(command_token_added) is not bool: raise ValueError("command_token_added must be a boolean") + if type(non_steam) is not bool: + raise ValueError("non_steam must be a boolean") with self._lock: self._assert_wrapper_owned_or_absent() document, _, _ = self._read_document() - previous_entry = document["apps"].get(normalized) - selected_transport = self._validate_transport( - transport - if transport is not None - else ( - previous_entry.get("transport") - if previous_entry - else None - ) - ) - entry: Dict[str, Any] = { + document["apps"][normalized] = { "state": validated_state, - "command_token_added": bool(command_token_added), - "transport": selected_transport, + "command_token_added": command_token_added, + "non_steam": non_steam, } - if shortcut_exe is not None: - entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe}) - elif previous_entry and "shortcut_exe" in previous_entry: - entry["shortcut_exe"] = previous_entry["shortcut_exe"] - document["apps"][normalized] = entry self._write_pair(document) return self._response(document, normalized) except Exception as error: @@ -491,6 +321,7 @@ class WrapperService(BaseService): "state": None, "wrapper_path": self.WRAPPER_TOKEN, "wrapper_owned": False, + "non_steam": False, } def remove(self, appid: str) -> Dict[str, Any]: @@ -513,10 +344,10 @@ class WrapperService(BaseService): "state": None, "wrapper_path": self.WRAPPER_TOKEN, "wrapper_owned": False, + "non_steam": False, } def repair(self) -> Dict[str, Any]: - """Regenerate a missing owned wrapper without importing old global state.""" try: with self._lock: document, _, _ = self._read_document() @@ -533,3 +364,101 @@ class WrapperService(BaseService): "wrapper_path": self.WRAPPER_TOKEN, "wrapper_owned": False, } + + def list_apps(self) -> Dict[str, Any]: + try: + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + apps = [ + { + "appid": appid, + "non_steam": entry.get("non_steam", False), + "command_token_added": entry.get("command_token_added", False), + } + for appid, entry in document["apps"].items() + ] + return { + "success": True, + "message": "", + "error": None, + "apps": apps, + "wrapper_path": self.WRAPPER_TOKEN, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "apps": [], + "wrapper_path": self.WRAPPER_TOKEN, + } + + def purge(self) -> Dict[str, Any]: + """Remove the plugin-owned wrapper and its sidecar during uninstall. + + This is deliberately separate from ``remove``: normal profile removal + leaves a safe passthrough wrapper for the remaining profiles, while an + uninstall should remove the wrapper entirely. Both files are + validated before anything is removed so a user's replacement wrapper + or damaged state is left untouched. + """ + removed = [] + try: + with self._lock: + _document, sidecar_exists, _ = self._read_document() + wrapper_owned = self._assert_wrapper_owned_or_absent() + if wrapper_owned: + self.wrapper_path.unlink() + removed.append(str(self.wrapper_path)) + if sidecar_exists: + self.sidecar_path.unlink() + removed.append(str(self.sidecar_path)) + return { + "success": True, + "message": "Removed lsfg-vk workaround state", + "error": None, + "removed_files": removed, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "removed_files": removed or None, + } + + def neutralize(self) -> Dict[str, Any]: + """Leave an owned passthrough wrapper for Decky-level uninstall. + + Decky can remove this plugin without giving the frontend a chance to + clean Steam launch options first. Keeping a dependency-free wrapper + prevents those options from turning into a broken executable path. + """ + try: + with self._lock: + _document, sidecar_exists, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + self._write_file( + self.wrapper_path, + "#!/bin/sh\n" + f"{self.MARKER}\n" + "# Safe passthrough retained for existing Steam launch options.\n" + "exec \"$@\"\n", + 0o755, + ) + if sidecar_exists: + self.sidecar_path.unlink() + return { + "success": True, + "message": "Replaced lsfg-vk wrapper with a safe passthrough", + "error": None, + "removed_files": [str(self.sidecar_path)] if sidecar_exists else [], + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "removed_files": None, + } diff --git a/scripts/deploy-to-deck.sh b/scripts/deploy-to-deck.sh new file mode 100755 index 0000000..f27fe4e --- /dev/null +++ b/scripts/deploy-to-deck.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +deck_host="deck@192.168.0.241" +plugin_root="Decky LSFG-VK" +install_path="/home/deck/homebrew/plugins/Decky LSFG-VK" + +if [[ -z ${DEPLOY_EXPECT:-} && -f .env ]]; then + set -a + source .env + set +a +fi + +if [[ -n ${DECK_PASSWORD:-} && -z ${DEPLOY_EXPECT:-} ]]; then + export DEPLOY_EXPECT=1 + export DEPLOY_SCRIPT="$0" + exec expect <<'EXPECT' +set timeout -1 +spawn bash $env(DEPLOY_SCRIPT) + +expect { + -re {(?i)yes/no} { + send -- "yes\r" + exp_continue + } + -re {(?i)(password|passphrase).*:} { + send -- "$env(DECK_PASSWORD)\r" + exp_continue + } + eof +} + +catch wait result +exit [lindex $result 3] +EXPECT +fi + +package_dir="$(mktemp -d "${TMPDIR:-/tmp}/decky-plugin-package.XXXXXX")" +run_id="$(basename "$package_dir")" +remote_archive="/tmp/decky-lsfg-vk-${run_id}.zip" +remote_stage="/tmp/decky-lsfg-vk-stage-${run_id}" +trap 'rm -rf -- "$package_dir"' EXIT INT TERM + +pnpm build +./cli/decky plugin build . \ + --output-path "$package_dir" \ + --tmp-output-path "$package_dir/tmp" + +package_zip="$(find "$package_dir" -maxdepth 1 -type f -name '*.zip' -print -quit)" +test -n "$package_zip" + +scp "$package_zip" "$deck_host:$remote_archive" + +remote_script="$(cat <<'REMOTE' +set -euo pipefail + +archive="$1" +stage="$2" +install="$3" +plugin_root="$4" + +cleanup() { + rm -rf -- "$archive" "$stage" +} +trap cleanup EXIT INT TERM + +mkdir -p -- "$stage" +unzip -q "$archive" -d "$stage" +test -f "$stage/$plugin_root/plugin.json" +test -f "$stage/$plugin_root/dist/index.js" + +sudo -v +sudo rm -rf -- "$install" +sudo mv -- "$stage/$plugin_root" "$install" +sudo chown -R deck:deck -- "$install" +sudo systemctl restart plugin_loader.service +sleep 2 +sudo chown -R deck:deck -- "$install" + +test "$(systemctl is-active plugin_loader.service)" = active +echo "Deck is ready to test" +REMOTE +)" + +printf -v remote_command 'bash -c %q -- %q %q %q %q' \ + "$remote_script" "$remote_archive" "$remote_stage" "$install_path" "$plugin_root" + +ssh -tt "$deck_host" "$remote_command" diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index b96acec..44b72b5 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -30,10 +30,6 @@ export interface SteamBranchStatus extends ApiResult { } export type LsfgConfig = ConfigurationData; -export type TargetTransport = - | { kind: "host" } - | { kind: "flatpak"; flatpakAppId: string }; -export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; export interface GameConfigEntry { appid: string; @@ -45,11 +41,7 @@ export interface InstalledGame { appid: string; name: string; nonSteam: boolean; - transport: TargetTransport; - executable?: string; - arguments?: string; - startDir?: string; - flatpakSupport?: FlatpakTargetSupport; + isFlatpakShortcut?: boolean; } export interface GlobalConfig { @@ -57,15 +49,6 @@ export interface GlobalConfig { no_fp16: boolean; } -export interface FlatpakTargetSupport extends ApiResult { - flatpak_app_id?: string; - runtime?: string | null; - runtime_branch?: string | null; - support_status: FlatpakTargetSupportStatus; - extension_installed: boolean; - installed_branches: string[]; -} - export interface WorkaroundState { dxvkFrameRate: number; disableGamescopeWsi: boolean; @@ -77,12 +60,23 @@ export interface WorkaroundState { export interface WorkaroundStateResult extends ApiResult { appid?: string; + app_id?: string; state?: WorkaroundState | null; wrapper_path?: string; wrapper_owned?: boolean; - shortcut_exe?: string | null; command_token_added?: boolean; - transport?: TargetTransport | null; + non_steam?: boolean; +} + +export interface WorkaroundApp { + appid: string; + non_steam: boolean; + command_token_added: boolean; +} + +export interface WorkaroundAppsResult extends ApiResult { + apps?: WorkaroundApp[]; + wrapper_path?: string; } export interface GameConfigsResult extends ApiResult { @@ -90,6 +84,10 @@ export interface GameConfigsResult extends ApiResult { games?: GameConfigEntry[]; } +export interface GlobalConfigResult extends ApiResult { + global_config?: GlobalConfig; +} + export interface GameConfigResult extends ApiResult { appid?: string; exists?: boolean; @@ -105,19 +103,51 @@ export interface FileContentResult extends ApiResult { path?: string; } -export interface FlatpakExtensionStatus extends ApiResult { - message: string; - available: boolean; - extension_id: string; - supported_branches: string[]; - installed_branches: string[]; +export interface DebugFileContent { + id: string; + label: string; + path: string; + exists: boolean; + content?: string | null; + error?: string | null; } -export interface FlatpakExtensionToggleResult extends ApiResult { - message: string; - runtime_branch: string; +export interface DebugFileContentsResult extends ApiResult { + files?: DebugFileContent[]; +} + +export interface FlatpakApp { + app_id: string; + app_name: string; + runtime?: string | null; + runtime_branch?: string | null; + runtime_ready: boolean; + prepared: boolean; + owned: boolean; enabled: boolean; - installed: boolean; + profile: string; + config?: LsfgConfig | null; + workarounds: WorkaroundState; + error?: string | null; +} + +export interface RunningFlatpakApp { + app_id: string; + active: boolean; + pid?: string; + start_time?: number | null; +} + +export interface FlatpakAppsResult extends ApiResult { + apps?: FlatpakApp[]; +} + +export interface RunningFlatpakAppsResult extends ApiResult { + apps?: RunningFlatpakApp[]; +} + +export interface FlatpakAppResult extends ApiResult, Partial<FlatpakApp> { + app_id: string; } export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); @@ -125,21 +155,26 @@ export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_ export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); -export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); -export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); -export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); -export const setFlatpakExtensionEnabled = callable<[string, boolean], FlatpakExtensionToggleResult>("set_flatpak_extension_enabled"); +export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps"); +export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app"); +export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config"); +export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state"); +export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app"); +export const getRunningFlatpakApps = callable<[], RunningFlatpakAppsResult>("get_running_flatpak_apps"); export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); +export const resetGameConfigs = callable<[string[]], GameConfigsResult>("reset_game_configs"); export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); +export const updateGlobalConfig = callable<[GlobalConfig], GlobalConfigResult>("update_global_config"); export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state"); export const setWorkaroundState = callable<[ string, WorkaroundState, - string | null | undefined, boolean, - TargetTransport | null | undefined, + boolean, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); +export const getWorkaroundApps = callable<[], WorkaroundAppsResult>("get_workaround_apps"); +export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents"); diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx new file mode 100644 index 0000000..a4e4092 --- /dev/null +++ b/src/components/CollapsibleItemGroup.tsx @@ -0,0 +1,103 @@ +import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; +import { useEffect, useState, type RefObject } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +export interface CollapsibleItem { + id: string; + label: string; + description: string; + disabled?: boolean; +} + +export const collapsibleItemGroupStyles = ` + .LSFG_GameGroupCollapseButton_Container { + margin-top: -2px; + margin-bottom: 4px; + } + + .LSFG_GameGroupCollapseButton_Container > div > div > div > button, + .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_GameGroupCollapseButton_Container svg { + display: block; + margin: 0; + } +`; + +export function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch {} + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +interface Props { + title: string; + items: CollapsibleItem[]; + collapsed: boolean; + onToggle: () => void; + onSelect: (id: string) => void; + toggleRef?: RefObject<HTMLDivElement>; +} + +export function CollapsibleItemGroup({ + title, + items, + collapsed, + onToggle, + onSelect, + toggleRef, +}: Props) { + if (items.length === 0) return null; + + return ( + <> + <PanelSectionRow> + <Field label={`${title} (${items.length})`} bottomSeparator="none" /> + </PanelSectionRow> + <PanelSectionRow> + <div + ref={toggleRef} + className="LSFG_GameGroupCollapseButton_Container" + > + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={onToggle} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + {!collapsed && items.map((item) => ( + <PanelSectionRow key={item.id}> + <Field + label={item.label} + description={item.description} + disabled={item.disabled} + onActivate={item.disabled ? undefined : () => onSelect(item.id)} + highlightOnFocus + /> + </PanelSectionRow> + ))} + </> + ); +} diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx index e3cc09d..fd6d716 100644 --- a/src/components/ConfigFileTab.tsx +++ b/src/components/ConfigFileTab.tsx @@ -1,20 +1,86 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; import { useEffect, useState } from "react"; -import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; -import { getConfigFileContent, FileContentResult } from "../api/lsfgApi"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi"; import t from "../i18n/i18n"; +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch { + // Persisting the view preference is optional. + } + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +function DebugFileSection({ file }: { file: DebugFileContent }) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`); + const status = file.exists ? "Present" : "Not present"; + + return ( + <> + <PanelSectionRow> + <Field + label={file.label} + description={`${file.path} · ${status}`} + bottomSeparator="none" + /> + </PanelSectionRow> + <PanelSectionRow> + <div + className="LSFG_DebugFileCollapseButton_Container" + style={{ marginTop: "-2px", marginBottom: "4px" }} + > + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={toggleCollapsed} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + {!collapsed && ( + <PanelSectionRow> + {file.exists && file.content !== null && file.content !== undefined ? ( + <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> + {file.content} + </pre> + ) : ( + <Field + label="File unavailable" + description={file.error || "The file has not been created yet."} + /> + )} + </PanelSectionRow> + )} + </> + ); +} + export function ConfigFileTab() { - const [result, setResult] = useState<FileContentResult | null>(null); + const [result, setResult] = useState<DebugFileContentsResult | null>(null); useEffect(() => { - getConfigFileContent().then(setResult).catch((error) => { + getDebugFileContents().then(setResult).catch((error) => { setResult({ success: false, error: String(error) }); }); }, []); if (!result) { return ( - <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}> + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> <PanelSectionRow> <Spinner /> </PanelSectionRow> @@ -23,24 +89,35 @@ export function ConfigFileTab() { } return ( - <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}> - {result.error && ( - <PanelSectionRow> - <Field label="Error" description={result.error} /> - </PanelSectionRow> - )} - {result.success && result.content && ( - <> - <PanelSectionRow> - <Field label="Config file" description={result.path} /> - </PanelSectionRow> + <> + <style> + {` + .LSFG_DebugFileCollapseButton_Container > div > div > div > button, + .LSFG_DebugFileCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_DebugFileCollapseButton_Container svg { + display: block; + margin: 0; + } + `} + </style> + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> + {result.error && ( <PanelSectionRow> - <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> - {result.content} - </pre> + <Field label="Error" description={result.error} /> </PanelSectionRow> - </> - )} - </PanelSection> + )} + {result.success && result.files?.map((file) => ( + <DebugFileSection key={file.id} file={file} /> + ))} + </PanelSection> + </> ); } diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index 1f17264..6996bcd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,6 +1,6 @@ import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema"; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; @@ -13,9 +13,6 @@ export function ConfigurationSection({ config, onConfigChange }: ConfigurationSe <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} /> </PanelSectionRow> <PanelSectionRow> - <ToggleField label="FP16 Acceleration" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} /> - </PanelSectionRow> - <PanelSectionRow> <ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> </PanelSectionRow> <PanelSectionRow> diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 5ae4a87..37555e0 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,26 +1,36 @@ -import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; import { GameConfigurationControls } from "./GameConfigurationControls"; import { GameConfigurationSelector } from "./GameConfigurationSelector"; import { ProfileDetails } from "./ProfileDetails"; interface ConfigurationTabProps { + title: string; + source: KnownGameSource; config: ConfigurationData; targets: GameTarget[]; runningGame: GameTarget | null; onSelect: (appid: string) => void; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions?: boolean, + ) => Promise<void>; onEnable: (appid: string) => Promise<boolean>; - onEnableAll: () => Promise<void>; + onEnableAll: (source: KnownGameSource) => Promise<void>; + bulkOperationBusy: boolean; onRepair: (appid: string) => Promise<boolean>; onReset: () => Promise<void>; - onResetAll: () => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; } export function ConfigurationTab({ + title, + source, config, targets, runningGame, @@ -28,6 +38,7 @@ export function ConfigurationTab({ onConfigChange, onEnable, onEnableAll, + bulkOperationBusy, onRepair, onReset, onResetAll, @@ -63,33 +74,33 @@ export function ConfigurationTab({ if (detailAppId === null) { return ( - <PanelSection title="Games"> - <GameConfigurationSelector - targets={targets} - runningGame={runningGame} - onSelect={(appid) => { - setFocusConfiguredToggle(false); - setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); - onSelect(appid); - setDetailAppId(appid); - }} - onEnableAll={onEnableAll} - onResetAll={onResetAll} - focusConfiguredToggle={focusConfiguredToggle} - onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} - /> - </PanelSection> + <> + <PanelSection title={title}> + <GameConfigurationSelector + targets={targets} + runningGame={runningGame} + source={source} + bulkOperationBusy={bulkOperationBusy} + onSelect={(appid) => { + setFocusConfiguredToggle(false); + setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); + onSelect(appid); + setDetailAppId(appid); + }} + onEnableAll={onEnableAll} + onResetAll={onResetAll} + focusConfiguredToggle={focusConfiguredToggle} + onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} + /> + </PanelSection> + </> ); } const profileLabel = selectedTarget?.name || "Game profile"; - const profileTransport = selectedTarget - ? selectedTarget.transport.kind === "flatpak" - ? "Non-Steam · Flatpak" - : selectedTarget.nonSteam ? "Non-Steam" : "Steam" - : "Game"; + const profileTransport = selectedTarget ? sourceLabel(selectedTarget.source) : sourceLabel(source); const profileDescription = selectedTarget - ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` + ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}${selectedTarget.source === "unknown" ? " · Bulk actions exclude this profile" : ""}` : "Game is no longer available"; const enableProfile = async (appid: string, quitRunningGame = false) => { if (!(await onEnable(appid))) return; @@ -103,10 +114,8 @@ export function ConfigurationTab({ closeDetails(); } else if (detailAppId) { const isRunningUnconfigured = runningGame?.appid === detailAppId - && runningGame.nonSteam === false - && runningGame.transport.kind === "host" - && selectedTarget?.nonSteam === false - && selectedTarget?.transport.kind === "host" + && runningGame.source === "steam" + && selectedTarget?.source === "steam" && !runningGame.configured; if (isRunningUnconfigured) { showModal( @@ -131,22 +140,22 @@ export function ConfigurationTab({ <PanelSectionRow> <div style={{ display: "flex", alignItems: "center", width: "100%" }}> <Focusable noFocusRing style={{ flex: "none" }}> - <DialogButton - aria-label="Back to games" - onClick={closeDetails} - style={{ - width: "48px", - minWidth: "48px", - height: "24px", - minHeight: "24px", - padding: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <FaArrowLeft /> - </DialogButton> + <DialogButton + aria-label={`Back to ${title}`} + onClick={closeDetails} + style={{ + width: "48px", + minWidth: "48px", + height: "24px", + minHeight: "24px", + padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <FaArrowLeft /> + </DialogButton> </Focusable> <div className={gamepadDialogClasses.FieldLabel} @@ -160,33 +169,28 @@ export function ConfigurationTab({ <PanelSection> {!selectedTarget?.configured && selectedTarget && ( <PanelSectionRow> - <Focusable ref={enableRef} noFocusRing> - <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem> - </Focusable> + {selectedTarget.source === "unknown" ? ( + <Field + label="Target source unavailable" + description="Refresh Steam and try again before enabling this target." + /> + ) : ( + <Focusable ref={enableRef} noFocusRing> + <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem> + </Focusable> + )} </PanelSectionRow> )} </PanelSection> - {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && ( - <PanelSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={() => void onRepair(selectedTarget.appid)} - > - Repair Flatpak support - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} {selectedTarget?.configured && ( <GameConfigurationControls config={config} - onConfigChange={onConfigChange} + onConfigChange={(field, value) => onConfigChange(field, value, selectedTarget?.source !== "unknown")} autoFocusFpsMultiplier={focusFpsMultiplier} onFpsMultiplierFocused={clearFpsFocusRequest} - showWorkarounds - workaroundTarget={selectedTarget || undefined} - onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined} + showWorkarounds={selectedTarget.source !== "unknown"} + workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined} + onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined} /> )} {selectedTarget?.configured && ( diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 578f984..470db95 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,34 +1,75 @@ import { Tabs } from "@decky/ui"; -import { useEffect, useRef, useState } from "react"; -import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; +import { FaCube, FaExternalLinkAlt, FaFileAlt, FaGamepad, FaSteam, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; +import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallation } from "../hooks/useLsfgHooks"; import { tabStyles } from "../styles"; +import { targetsForSource } from "../utils/gameTargets"; +import { resolveNowPlayingTarget, type NowPlayingTarget } from "../utils/nowPlaying"; import { ConfigFileTab } from "./ConfigFileTab"; import { ConfigurationTab } from "./ConfigurationTab"; +import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; +import { FlatpakTab } from "./FlatpakTab"; import { NowPlayingTab } from "./NowPlayingTab"; -import { SetupTab } from "./SetupTab"; +import { SettingsTab } from "./SettingsTab"; const tabIcons = { nowPlaying: <FaGamepad size={18} />, - games: <FaList size={18} />, + steam: <FaSteam size={18} />, + nonSteam: <FaExternalLinkAlt size={18} />, + flatpak: <FaCube size={18} />, configFile: <FaFileAlt size={18} />, - setup: <FaTools size={18} />, + settings: <FaTools size={18} />, }; +const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1"; +type GameTabId = "Steam" | "NonSteam" | "Flatpak"; + +function tabForNowPlaying(target: NowPlayingTarget | null): GameTabId { + if (!target) return "Steam"; + if (target.kind === "flatpak") return target.launcher?.source === "nonSteam" ? "NonSteam" : "Flatpak"; + return target.game.source === "nonSteam" ? "NonSteam" : "Steam"; +} + +function usePersistentBoolean(key: string, defaultValue: boolean) { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored === null ? defaultValue : stored === "true"; + } catch { + return defaultValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(value)); + } catch {} + }, [key, value]); + + return [value, setValue] as const; +} + export function Content() { const { config, + runningConfig, + globalConfig, targets, runningGame, setSelectedAppId, save, + saveFor, + updateGlobal, enable, enableAll, + bulkOperationBusy, repair, resetSelected, resetAll, + cleanupAllWorkarounds, reload, } = useGameConfiguration(); const { @@ -41,47 +82,72 @@ export function Content() { isUninstalling, install, uninstall, - } = useInstallation(reload); - const [tab, setTab] = useState("Setup"); - const previousRunningAppId = useRef<string | null>(null); + } = useInstallation(reload, cleanupAllWorkarounds); const setupComplete = isInstalled && losslessScalingInstalled && steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; + const flatpak = useFlatpakConfiguration(setupComplete); + const [tab, setTab] = useState("Settings"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false); + const [contentFocused, setContentFocused] = useState(false); + const previousRunningWorkload = useRef<string | null>(null); + const previousNowPlayingTab = useRef<GameTabId>("Steam"); + const runningFlatpak = flatpak.runningApp; + const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak); + const hasNowPlaying = Boolean(nowPlayingTarget); + const runningWorkload = nowPlayingTarget + ? nowPlayingTarget.kind === "flatpak" + ? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}` + : `${nowPlayingTarget.game.source}:${nowPlayingTarget.game.appid}` + : null; + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); useEffect(() => { if (!setupComplete) { - setTab("Setup"); + setTab("Settings"); return; } - setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Games") : current); - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + setTab((current) => current === "Settings" ? (hasNowPlaying ? "NowPlaying" : "Steam") : current); + }, [hasNowPlaying, setupComplete]); useEffect(() => { if (!setupComplete) return; - const appid = runningGame?.appid || null; - const previous = previousRunningAppId.current; - previousRunningAppId.current = appid; - if (appid && appid !== previous) setTab(runningGame?.configured ? "NowPlaying" : "Games"); - else if (!appid && previous) { - setTab((current) => current === "NowPlaying" ? "Games" : current); + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) { + previousNowPlayingTab.current = tabForNowPlaying(nowPlayingTarget); + setTab("NowPlaying"); + } + else if (!runningWorkload && previous) { + setTab((current) => current === "NowPlaying" ? previousNowPlayingTab.current : current); + } + }, [runningWorkload, setupComplete]); + + useEffect(() => { + if (isInstalled) { + void reload(); + void flatpak.reload(); } - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + }, [isInstalled, reload, flatpak.reload]); useEffect(() => { - if (isInstalled) void reload(); - }, [isInstalled, reload]); + if (!showDebugTab && tab === "ConfigFile") setTab(setupComplete ? "Steam" : "Settings"); + }, [setupComplete, showDebugTab, tab]); const handleConfigChange = async ( fieldName: keyof ConfigurationData, value: boolean | number | string | string[], cleanupLaunchOptions = false, - ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + ) => { + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + }; - const setup = ( - <SetupTab + const settings = ( + <SettingsTab isInstalled={isInstalled} installationStatus={installationStatus} losslessScalingInstalled={losslessScalingInstalled} @@ -89,55 +155,125 @@ export function Content() { steamBranchStatus={steamBranchStatus} isInstalling={isInstalling} isUninstalling={isUninstalling} + globalConfig={globalConfig} + showDebugTab={showDebugTab} + onGlobalConfigChange={updateGlobal} + onShowDebugTabChange={setShowDebugTab} onInstall={() => void install()} onUninstall={() => void uninstall()} /> ); + const tabContent = (content: ReactNode) => ( + <div className="lsfg-vk-tab-content">{content}</div> + ); + + const nowPlaying = nowPlayingTarget?.kind === "flatpak" ? ( + <FlatpakNowPlayingTab + app={nowPlayingTarget.app} + launcher={nowPlayingTarget.launcher} + onConfigChange={flatpak.updateConfig} + /> + ) : nowPlayingTarget ? ( + <NowPlayingTab + game={nowPlayingTarget.game} + config={runningConfig} + onConfigChange={async (field, value) => { + await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true); + }} + /> + ) : null; + const tabs = setupComplete ? [ - ...(runningGame?.configured ? [{ - id: "NowPlaying", - title: tabIcons.nowPlaying, - content: ( - <NowPlayingTab - game={runningGame} + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []), + { + id: "Steam", + title: tabIcons.steam, + content: tabContent( + <ConfigurationTab + title="Steam games" + source="steam" config={config} - onConfigChange={(field, value) => handleConfigChange(field, value)} + targets={steamTargets} + runningGame={runningGame} + onSelect={setSelectedAppId} + onConfigChange={handleConfigChange} + onEnable={enable} + onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} onRepair={repair} - /> + onReset={resetSelected} + onResetAll={resetAll} + />, ), - }] : []), + }, { - id: "Games", - title: tabIcons.games, - content: ( + id: "NonSteam", + title: tabIcons.nonSteam, + content: tabContent( <ConfigurationTab + title="Non-Steam games" + source="nonSteam" config={config} - targets={targets} + targets={nonSteamTargets} runningGame={runningGame} onSelect={setSelectedAppId} - onConfigChange={(field, value) => handleConfigChange(field, value, true)} + onConfigChange={handleConfigChange} onEnable={enable} onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> ), }, - { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, - { id: "Setup", title: tabIcons.setup, content: setup }, + { + id: "Flatpak", + title: tabIcons.flatpak, + content: tabContent( + <FlatpakTab + apps={flatpak.apps} + runningApp={runningFlatpak} + loading={flatpak.loading} + busyAppId={flatpak.busyAppId} + onRefresh={flatpak.reload} + onEnable={flatpak.enableApp} + onEnableAll={flatpak.enableAll} + onRemove={flatpak.removeApp} + onRemoveAll={flatpak.removeAll} + onConfigChange={flatpak.updateConfig} + onWorkaroundChange={flatpak.updateWorkarounds} + />, + ), + }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []), + { id: "Settings", title: tabIcons.settings, content: tabContent(settings) }, ] - : [{ id: "Setup", title: tabIcons.setup, content: setup }]; + : [{ id: "Settings", title: tabIcons.settings, content: tabContent(settings) }]; + + const availableTabIds = new Set(tabs.map(({ id }) => id)); + const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Steam" : "Settings"; + const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => { + const focusedElement = event.target as HTMLElement | null; + setContentFocused(!focusedElement?.closest?.('[role="tab"]')); + }; return ( <div - className="lsfg-vk-tabs" + className={`lsfg-vk-tabs${contentFocused ? " lsfg-vk-tabs--content-focused" : ""}`} style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }} + onFocusCapture={handleFocusCapture} > <style>{tabStyles}</style> - <Tabs activeTab={tab} onShowTab={setTab} tabs={tabs} /> + <Tabs + activeTab={activeTab} + onShowTab={(nextTab: string) => { + if (availableTabIds.has(nextTab)) setTab(nextTab); + }} + tabs={tabs} + /> </div> ); } diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx new file mode 100644 index 0000000..449e4b5 --- /dev/null +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -0,0 +1,38 @@ +import { Focusable } from "@decky/ui"; +import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi"; +import type { GameTarget } from "../utils/gameTargets"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { NowPlayingSummary } from "./NowPlayingSummary"; + +interface Props { + app: FlatpakApp; + launcher: GameTarget | null; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; +} + +export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) { + if (!app.config) return null; + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + await onConfigChange(app.app_id, { ...app.config!, [field]: value }); + }; + + return ( + <Focusable> + <NowPlayingSummary + title={launcher?.name || app.app_name} + details={[ + launcher ? (launcher.source === "nonSteam" ? "Steam shortcut" : "Steam") : "Flatpak", + launcher && launcher.name !== app.app_name ? `Running in ${app.app_name}` : null, + launcher ? "Flatpak" : null, + `Controls: ${app.app_name} profile`, + ].filter((detail): detail is string => detail !== null)} + /> + <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} /> + <ConfigurationSection config={app.config} onConfigChange={changeConfig} /> + </Focusable> + ); +} diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx new file mode 100644 index 0000000..35721f6 --- /dev/null +++ b/src/components/FlatpakTab.tsx @@ -0,0 +1,247 @@ +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { useCallback, useMemo, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { ProfileDetails } from "./ProfileDetails"; + +interface Props { + apps: FlatpakApp[]; + runningApp: FlatpakApp | null; + loading: boolean; + busyAppId: string; + onRefresh: () => Promise<void>; + onEnable: (appId: string) => Promise<boolean>; + onEnableAll: () => Promise<void>; + onRemove: (appId: string) => Promise<boolean>; + onRemoveAll: () => Promise<void>; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>; +} + +const ENABLED_COLLAPSED_KEY = "lsfg-flatpak-enabled-collapsed-v2"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-flatpak-available-collapsed-v2"; + +export function FlatpakTab({ + apps, + runningApp, + loading, + busyAppId, + onRefresh, + onEnable, + onEnableAll, + onRemove, + onRemoveAll, + onConfigChange, + onWorkaroundChange, +}: Props) { + const [selectedAppId, setSelectedAppId] = useState<string | null>(null); + const selected = useMemo( + () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null, + [apps, selectedAppId], + ); + const close = useCallback(() => setSelectedAppId(null), []); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + + const enabledApps = useMemo( + () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const availableApps = useMemo( + () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const enableableApps = useMemo( + () => availableApps.filter((app) => !(app.prepared && !app.owned) && !app.error), + [availableApps], + ); + const confirmEnableAll = () => { + showModal( + <ConfirmModal + strTitle="Enable all available Flatpaks?" + strDescription="Create individual LSFG-VK profiles for every Flatpak app this plugin can manage. Apps prepared externally or unavailable will be skipped." + strOKButtonText="Enable all" + strCancelButtonText="Cancel" + onOK={() => void onEnableAll()} + onCancel={() => {}} + />, + ); + }; + const confirmRemoveAll = () => { + showModal( + <ConfirmModal + strTitle="Remove all Flatpak profiles?" + strOKButtonText="Remove all" + strCancelButtonText="Cancel" + onOK={() => void onRemoveAll()} + onCancel={() => {}} + />, + ); + }; + const itemFor = (app: FlatpakApp) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`, + }); + + if (!selectedAppId) { + return ( + <PanelSection title="Flatpak"> + <style>{collapsibleItemGroupStyles}</style> + <CollapsibleItemGroup + title="Enabled" + items={enabledApps.map((app) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`, + }))} + collapsed={enabledCollapsed} + onToggle={toggleEnabled} + onSelect={setSelectedAppId} + /> + <CollapsibleItemGroup + title="Available" + items={availableApps.map(itemFor)} + collapsed={availableCollapsed} + onToggle={toggleAvailable} + onSelect={setSelectedAppId} + /> + {enableableApps.length > 0 && ( + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={loading || Boolean(busyAppId)} + onClick={confirmEnableAll} + > + Enable all available Flatpaks + </ButtonItem> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={loading || Boolean(busyAppId) || enabledApps.length === 0} + onClick={confirmRemoveAll} + > + Remove all profiles + </ButtonItem> + </PanelSectionRow> + {apps.length === 0 && !loading && ( + <PanelSectionRow> + <Field label="No Flatpak applications found" /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem layout="below" disabled={loading || Boolean(busyAppId)} onClick={() => void onRefresh()}> + {loading ? "Refreshing..." : "Refresh Flatpaks"} + </ButtonItem> + </PanelSectionRow> + </PanelSection> + ); + } + + if (!selected) { + return ( + <PanelSection title="Flatpak"> + <PanelSectionRow> + <ButtonItem layout="below" onClick={close}>Back</ButtonItem> + </PanelSectionRow> + <PanelSectionRow> + <Field label="Flatpak application is no longer installed" /> + </PanelSectionRow> + </PanelSection> + ); + } + + const busy = busyAppId === selected.app_id; + const config = selected.config; + const external = selected.prepared && !selected.owned; + const profileDescription = [ + selected.app_id, + selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null, + selected.enabled ? `profile ${selected.profile}` : null, + selected.app_id === runningApp?.app_id ? "Running" : null, + ].filter(Boolean).join(" · "); + + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + if (!config) return; + await onConfigChange(selected.app_id, { ...config, [field]: value }); + }; + + return ( + <Focusable onCancelButton={close}> + <PanelSection> + <PanelSectionRow> + <div style={{ display: "flex", alignItems: "center", width: "100%" }}> + <Focusable noFocusRing style={{ flex: "none" }}> + <DialogButton + aria-label="Back to Flatpaks" + onClick={close} + style={{ + width: "48px", + minWidth: "48px", + height: "24px", + minHeight: "24px", + padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <FaArrowLeft /> + </DialogButton> + </Focusable> + <div + className={gamepadDialogClasses.FieldLabel} + style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {selected.app_name} + </div> + </div> + </PanelSectionRow> + </PanelSection> + {!selected.enabled && ( + <PanelSection> + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy || external || Boolean(selected.error)} + onClick={() => void onEnable(selected.app_id)} + > + {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"} + </ButtonItem> + </PanelSectionRow> + {selected.error && ( + <PanelSectionRow> + <Field label="Unavailable" description={selected.error} /> + </PanelSectionRow> + )} + </PanelSection> + )} + {selected.enabled && config && ( + <> + <FpsMultiplierControl config={config} onConfigChange={changeConfig} /> + <ConfigurationSection config={config} onConfigChange={changeConfig} /> + <FlatpakWorkaroundsSection + state={selected.workarounds} + disabled={busy} + onChange={(state) => onWorkaroundChange(selected.app_id, state)} + /> + <PanelSectionRow> + <ButtonItem layout="below" disabled={busy} onClick={() => void onRemove(selected.app_id)}> + {busy ? "Removing..." : "Remove Flatpak profile"} + </ButtonItem> + </PanelSectionRow> + </> + )} + <ProfileDetails description={profileDescription} /> + </Focusable> + ); +} diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx new file mode 100644 index 0000000..7d9d880 --- /dev/null +++ b/src/components/FlatpakWorkaroundsSection.tsx @@ -0,0 +1,144 @@ +import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import type { WorkaroundState } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface Props { + state: WorkaroundState; + disabled?: boolean; + onChange: (state: WorkaroundState) => Promise<boolean>; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1"; + +export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false"; + } catch { + return true; + } + }); + const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate); + const timer = useRef<number | null>(null); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed)); + } catch {} + }, [collapsed]); + + useEffect(() => { + setFpsValue(state.dxvkFrameRate); + }, [state.dxvkFrameRate]); + + useEffect(() => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, []); + + const update = (field: keyof WorkaroundState, value: boolean | number) => { + void onChange({ ...state, [field]: value }); + }; + + const updateFps = (value: number) => { + setFpsValue(value); + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { + timer.current = null; + void onChange({ ...state, dxvkFrameRate: value }); + }, 250); + }; + + const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + return ( + <> + <PanelSectionRow> + <div + style={{ + fontSize: "14px", + fontWeight: "bold", + marginTop: "8px", + marginBottom: "6px", + borderBottom: "1px solid rgba(255, 255, 255, 0.2)", + paddingBottom: "3px", + color: "white", + }} + > + {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} + </div> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={() => setCollapsed((value) => !value)} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </PanelSectionRow> + {!collapsed && ( + <> + <PanelSectionRow> + <SliderField + label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`} + description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires app restart to apply.")} + value={fpsValue} + min={0} + max={60} + step={1} + onChange={updateFps} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_STEAMDECK_MODE", "Disable Steam Deck Mode")} + description={t("CONFIG_DISABLE_STEAMDECK_MODE_DESC", "Disables a game-specific Steam Deck compatibility switch. Requires app restart to apply.")} + checked={state.disableSteamdeckMode} + onChange={(value) => update("disableSteamdeckMode", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_GAMESCOPE_WSI", "Disable Gamescope WSI")} + description={t("CONFIG_DISABLE_GAMESCOPE_WSI_DESC", "Adds ENABLE_GAMESCOPE_WSI=0. Requires app restart to apply.")} + checked={state.disableGamescopeWsi} + onChange={(value) => update("disableGamescopeWsi", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_HDR", "Disable HDR")} + description={t("CONFIG_DISABLE_HDR_DESC", "Prevents DXVK from exposing HDR to the app. Requires app restart to apply.")} + checked={state.disableHdr} + onChange={(value) => update("disableHdr", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_VKBASALT", "Disable vkBasalt")} + description={t("CONFIG_DISABLE_VKBASALT_DESC", "Disables vkBasalt which can conflict with LSFG-VK.")} + checked={state.disableVkbasalt} + onChange={(value) => update("disableVkbasalt", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_ENABLE_ZINK", "Force Zink for OpenGL Games")} + description={t("CONFIG_ENABLE_ZINK_DESC", "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes. Requires app restart to apply.")} + checked={state.enableZink} + onChange={(value) => update("enableZink", value)} + disabled={disabled} + /> + </PanelSectionRow> + </> + )} + </> + ); +} diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 2c50be1..523e8b3 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,4 +1,4 @@ -import { DialogButton, Focusable, PanelSectionRow } from "@decky/ui"; +import { Focusable, PanelSectionRow, SliderField } from "@decky/ui"; import { useEffect, useRef } from "react"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; @@ -31,63 +31,32 @@ export function FpsMultiplierControl({ return () => cancelAnimationFrame(frame); }, [autoFocus, onAutoFocus]); + const multiplierLabel = config.multiplier === 1 + ? t("MULTIPLIER_OFF", "Off") + : `${config.multiplier}x`; + return ( <PanelSectionRow> - <Focusable - ref={focusableRef} - noFocusRing - style={{ - marginTop: "6px", - marginBottom: "6px", - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - flow-children="horizontal" - > - <DialogButton - style={{ - marginLeft: "0px", - height: "30px", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "5px 0px 0px 0px", - minWidth: "40px", - }} - onClick={() => void onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} - disabled={config.multiplier <= 1} - > - − - </DialogButton> - <div - style={{ - marginLeft: "20px", - marginRight: "20px", - fontSize: "16px", - fontWeight: "bold", - color: config.multiplier > 4 ? "red" : "white", - minWidth: "60px", - textAlign: "center", - }} - > - {config.multiplier < 2 ? t("MULTIPLIER_OFF", "OFF") : `${config.multiplier}X`} - </div> - <DialogButton - style={{ - marginLeft: "0px", - height: "30px", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "5px 0px 0px 0px", - minWidth: "40px", - }} - onClick={() => void onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))} - disabled={config.multiplier >= 4} - > - + - </DialogButton> + <Focusable ref={focusableRef} noFocusRing> + <SliderField + label={`FPS multiplier · ${multiplierLabel}`} + value={config.multiplier} + min={1} + max={6} + step={1} + notchCount={6} + notchLabels={[ + { notchIndex: 0, label: "OFF", value: 1 }, + { notchIndex: 1, label: "2X", value: 2 }, + { notchIndex: 2, label: "3X", value: 3 }, + { notchIndex: 3, label: "4X", value: 4 }, + { notchIndex: 4, label: "5X", value: 5 }, + { notchIndex: 5, label: "6X", value: 6 }, + ]} + notchTicksVisible={true} + showValue={false} + onChange={(value) => void onConfigChange(MULTIPLIER, value)} + /> </Focusable> </PanelSectionRow> ); diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 6bea2e0..7025f78 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -10,7 +10,7 @@ interface Props { autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; - workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">; + workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">; onRepairWorkaround?: () => Promise<boolean>; } @@ -36,7 +36,6 @@ export function GameConfigurationControls({ <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} - transport={workaroundTarget.transport} onRepair={onRepairWorkaround} /> )} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 92eacba..91dc3df 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,14 +1,17 @@ import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; -import { useEffect, useRef, useState, type RefObject } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import { GameTarget } from "../hooks/useGameConfiguration"; +import { useEffect, useRef } from "react"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; interface Props { targets: GameTarget[]; runningGame: GameTarget | null; + source: KnownGameSource; + bulkOperationBusy: boolean; onSelect: (appid: string) => void; - onEnableAll: () => Promise<void>; - onResetAll: () => Promise<void>; + onEnableAll: (source: KnownGameSource) => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; focusConfiguredToggle?: boolean; onConfiguredToggleFocused?: () => void; } @@ -16,85 +19,18 @@ interface Props { const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; -function usePersistentCollapsed(key: string) { - const [collapsed, setCollapsed] = useState(() => { - try { - return localStorage.getItem(key) !== "false"; - } catch { - return true; - } - }); - - useEffect(() => { - try { - localStorage.setItem(key, String(collapsed)); - } catch { - // Persisting the view preference is optional. - } - }, [collapsed, key]); - - return [collapsed, () => setCollapsed((value) => !value)] as const; -} - function targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; - return game.nonSteam ? "Non-Steam" : "Steam"; -} - -function GameGroup({ - title, - games, - collapsed, - onToggle, - onSelect, - toggleRef, -}: { - title: string; - games: GameTarget[]; - collapsed: boolean; - onToggle: () => void; - onSelect: (appid: string) => void; - toggleRef?: RefObject<HTMLDivElement>; -}) { - if (games.length === 0) return null; - - return ( - <> - <PanelSectionRow> - <Field label={title + " (" + games.length + ")"} bottomSeparator="none" /> - </PanelSectionRow> - <PanelSectionRow> - <div - ref={toggleRef} - className="LSFG_GameGroupCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={collapsed ? "standard" : "none"} - onClick={onToggle} - > - {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} - </ButtonItem> - </div> - </PanelSectionRow> - {!collapsed && games.map((game) => ( - <PanelSectionRow key={game.appid}> - <Field - label={game.name} - description={targetDescription(game)} - onActivate={() => onSelect(game.appid)} - highlightOnFocus - /> - </PanelSectionRow> - ))} - </> - ); + if (game.isFlatpakShortcut) return "Non-Steam | Use Flatpak Tab"; + return game.source === "unknown" + ? "Unknown source · excluded from bulk actions" + : sourceLabel(game.source); } export function GameConfigurationSelector({ targets, runningGame, + source, + bulkOperationBusy, onSelect, onEnableAll, onResetAll, @@ -108,8 +44,20 @@ export function GameConfigurationSelector({ }); const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); - const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); - const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + const enableableGames = availableGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const removableGames = enabledGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const sourceName = source === "nonSteam" ? "non-Steam shortcuts" : "Steam games"; + const emptyDescription = source === "nonSteam" + ? "Steam has not reported any eligible non-Steam shortcuts" + : "Steam has not reported any eligible installed games"; + const toItem = (game: GameTarget) => ({ + id: game.appid, + label: game.name, + description: targetDescription(game), + disabled: game.isFlatpakShortcut, + }); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(`${ENABLED_COLLAPSED_KEY}-${source}`); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-${source}`); const enabledToggleRef = useRef<HTMLDivElement>(null); useEffect(() => { @@ -124,10 +72,10 @@ export function GameConfigurationSelector({ const confirmResetAll = () => { showModal( <ConfirmModal - strTitle="Remove all profiles?" + strTitle={`Remove all ${sourceName} profiles?`} strOKButtonText="Remove all" strCancelButtonText="Cancel" - onOK={() => void onResetAll()} + onOK={() => void onResetAll(source)} onCancel={() => {}} />, ); @@ -136,11 +84,11 @@ export function GameConfigurationSelector({ const confirmEnableAll = () => { showModal( <ConfirmModal - strTitle="Enable all available games?" - strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults. Flatpak targets will be provisioned as needed." + strTitle={`Enable all available ${sourceName}?`} + strDescription={`Create individual LSFG-VK profiles for every available ${sourceName}. Unknown-source profiles are excluded. Flatpak profiles are managed separately in the Flatpak tab.`} strOKButtonText="Enable all" strCancelButtonText="Cancel" - onOK={() => void onEnableAll()} + onOK={() => void onEnableAll(source)} onCancel={() => {}} />, ); @@ -149,47 +97,32 @@ export function GameConfigurationSelector({ return ( <> <style> - {` - .LSFG_GameGroupCollapseButton_Container > div > div > div > button, - .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { - height: 24px !important; - min-height: 24px !important; - padding: 0 !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - .LSFG_GameGroupCollapseButton_Container svg { - display: block; - margin: 0; - } - `} + {collapsibleItemGroupStyles} </style> {targets.length === 0 && ( <PanelSectionRow> - <Field label="No installed games" description="Steam has not reported any eligible games" /> + <Field label={`No ${sourceName} found`} description={emptyDescription} /> </PanelSectionRow> )} - <GameGroup + <CollapsibleItemGroup title="Enabled" - games={enabledGames} + items={enabledGames.map(toItem)} collapsed={enabledCollapsed} onToggle={toggleEnabled} onSelect={onSelect} toggleRef={enabledToggleRef} /> - <GameGroup + <CollapsibleItemGroup title="Available" - games={availableGames} + items={availableGames.map(toItem)} collapsed={availableCollapsed} onToggle={toggleAvailable} onSelect={onSelect} /> - {availableGames.length > 0 && ( + {enableableGames.length > 0 && ( <PanelSectionRow> - <ButtonItem layout="below" onClick={confirmEnableAll}> - Enable all available games + <ButtonItem layout="below" onClick={confirmEnableAll} disabled={bulkOperationBusy}> + {`Enable all ${sourceName}`} </ButtonItem> </PanelSectionRow> )} @@ -197,9 +130,9 @@ export function GameConfigurationSelector({ <ButtonItem layout="below" onClick={confirmResetAll} - disabled={!targets.some((target) => target.configured)} + disabled={bulkOperationBusy || removableGames.length === 0} > - Remove all profiles + {`Remove all ${sourceLabel(source)} profiles`} </ButtonItem> </PanelSectionRow> </> diff --git a/src/components/NowPlayingSummary.tsx b/src/components/NowPlayingSummary.tsx new file mode 100644 index 0000000..adc5d10 --- /dev/null +++ b/src/components/NowPlayingSummary.tsx @@ -0,0 +1,16 @@ +import { Field, PanelSection, PanelSectionRow } from "@decky/ui"; + +interface Props { + title: string; + details: string[]; +} + +export function NowPlayingSummary({ title, details }: Props) { + return ( + <PanelSection> + <PanelSectionRow> + <Field label={title} description={details.filter(Boolean).join(" | ")} /> + </PanelSectionRow> + </PanelSection> + ); +} diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 57858db..69e72d7 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,8 +1,9 @@ -import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useState } from "react"; +import { Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; import { GameConfigurationControls } from "./GameConfigurationControls"; +import { NowPlayingSummary } from "./NowPlayingSummary"; interface Props { game: GameTarget; @@ -11,57 +12,23 @@ interface Props { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], ) => Promise<void>; - onRepair: (appid: string) => Promise<boolean>; } function targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; - return game.nonSteam ? "Non-Steam" : "Steam"; + return sourceLabel(game.source); } export function NowPlayingTab({ game, config, onConfigChange, - onRepair, }: Props) { - const [busy, setBusy] = useState(false); - const supportNeedsRepair = - game.transport.kind === "flatpak" && - game.flatpakSupport?.support_status !== "ready"; - - const handleRepair = async () => { - if (busy) return; - setBusy(true); - try { - await onRepair(game.appid); - } finally { - setBusy(false); - } - }; - return ( <Focusable> - <PanelSection title="Now Playing"> - <PanelSectionRow> - <Field label={game.name} description={targetDescription(game)} /> - </PanelSectionRow> - </PanelSection> - {supportNeedsRepair && ( - <PanelSection> - <PanelSectionRow> - <Field - label="Flatpak support needs repair" - description={game.flatpakSupport?.error || "The target runtime extension is not ready."} - /> - </PanelSectionRow> - <PanelSectionRow> - <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}> - {busy ? "Repairing..." : "Repair Flatpak support"} - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} + <NowPlayingSummary + title={game.name} + details={[targetDescription(game), `Controls: ${game.name} profile`]} + /> <GameConfigurationControls config={config} onConfigChange={onConfigChange} diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx new file mode 100644 index 0000000..35a74cc --- /dev/null +++ b/src/components/SettingsTab.tsx @@ -0,0 +1,100 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface SettingsTabProps { + isInstalled: boolean; + installationStatus: string; + losslessScalingInstalled: boolean; + losslessScalingStatus: string; + steamBranchStatus: SteamBranchStatus | null; + isInstalling: boolean; + isUninstalling: boolean; + globalConfig: GlobalConfig; + showDebugTab: boolean; + onGlobalConfigChange: (config: GlobalConfig) => Promise<boolean>; + onShowDebugTabChange: (value: boolean) => void; + onInstall: () => void; + onUninstall: () => void; +} + +export function SettingsTab(props: SettingsTabProps) { + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + globalConfig, + showDebugTab, + onGlobalConfigChange, + onShowDebugTabChange, + onInstall, + onUninstall, + } = props; + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + const buttonLabel = isInstalling + ? t("INSTALL_INSTALLING", "Installing...") + : isUninstalling + ? t("INSTALL_UNINSTALLING", "Uninstalling...") + : isInstalled + ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") + : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); + + return ( + <> + <PanelSection title="Settings"> + <PanelSectionRow> + <Field + label="Lossless Scaling" + description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} + /> + </PanelSectionRow> + <PanelSectionRow> + <Field label="LSFG-VK" description={installationStatus} /> + </PanelSectionRow> + {steamBranchStatus?.installed && ( + <PanelSectionRow> + <Field + label="Steam branch" + description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} + /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={isInstalled ? onUninstall : onInstall} + disabled={isInstalling || isUninstalling} + > + {buttonLabel} + </ButtonItem> + </PanelSectionRow> + </PanelSection> + {isInstalled && ( + <> + <PanelSection title="Global settings"> + <PanelSectionRow> + <ToggleField + label="FP16 Acceleration" + checked={!globalConfig.no_fp16} + onChange={(value) => void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} + /> + </PanelSectionRow> + </PanelSection> + <PanelSection title="Advanced"> + <PanelSectionRow> + <ToggleField + label="Show config file tab" + checked={showDebugTab} + onChange={onShowDebugTabChange} + /> + </PanelSectionRow> + </PanelSection> + </> + )} + </> + ); +} diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx deleted file mode 100644 index d769200..0000000 --- a/src/components/SetupTab.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; -import { type SteamBranchStatus } from "../api/lsfgApi"; -import t from "../i18n/i18n"; - -interface SetupTabProps { - isInstalled: boolean; - installationStatus: string; - losslessScalingInstalled: boolean; - losslessScalingStatus: string; - steamBranchStatus: SteamBranchStatus | null; - isInstalling: boolean; - isUninstalling: boolean; - onInstall: () => void; - onUninstall: () => void; -} - -export function SetupTab(props: SetupTabProps) { - const { - isInstalled, - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - isInstalling, - isUninstalling, - onInstall, - onUninstall, - } = props; - const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; - const buttonLabel = isInstalling - ? t("INSTALL_INSTALLING", "Installing...") - : isUninstalling - ? t("INSTALL_UNINSTALLING", "Uninstalling...") - : isInstalled - ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") - : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); - - return ( - <PanelSection title="Setup"> - <PanelSectionRow> - <Field - label="Lossless Scaling" - description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} - /> - </PanelSectionRow> - <PanelSectionRow> - <Field label="LSFG-VK" description={installationStatus} /> - </PanelSectionRow> - {steamBranchStatus?.installed && ( - <PanelSectionRow> - <Field - label="Steam branch" - description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} - /> - </PanelSectionRow> - )} - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {buttonLabel} - </ButtonItem> - </PanelSectionRow> - </PanelSection> - ); -} diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index 5587392..3de5ec1 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -1,7 +1,6 @@ import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import type { TargetTransport } from "../api/lsfgApi"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; @@ -9,7 +8,6 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; - transport: TargetTransport; onRepair?: () => Promise<boolean>; } @@ -73,17 +71,15 @@ function usePersistentCollapsed() { useEffect(() => { try { localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); - } catch { - // Persisting the view preference is optional. - } + } catch {} }, [collapsed]); return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); - const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); const [repairing, setRepairing] = useState(false); const state = snapshot?.state; const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; diff --git a/src/components/index.ts b/src/components/index.ts index bca6f6f..5459974 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,7 +3,7 @@ export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; export { ConfigFileTab } from "./ConfigFileTab"; -export { SetupTab } from "./SetupTab"; +export { SettingsTab } from "./SettingsTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; export { NowPlayingTab } from "./NowPlayingTab"; diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts new file mode 100644 index 0000000..f936271 --- /dev/null +++ b/src/hooks/useFlatpakConfiguration.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + enableFlatpakApp, + getFlatpakApps, + getRunningFlatpakApps, + removeFlatpakApp, + setFlatpakWorkaroundState, + updateFlatpakConfig, + type FlatpakApp, + type LsfgConfig, + type RunningFlatpakApp, + type WorkaroundState, +} from "../api/lsfgApi"; +import { selectMostRecentRunningFlatpak } from "../utils/nowPlaying"; +import { showErrorToast } from "../utils/toastUtils"; + +type FlatpakOperationResult = { + success: boolean; + error?: string | null; + config?: LsfgConfig | null; + state?: WorkaroundState | null; +}; + +export function useFlatpakConfiguration(enabled: boolean) { + const [apps, setApps] = useState<FlatpakApp[]>([]); + const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]); + const [loading, setLoading] = useState(false); + const [busyAppId, setBusyAppId] = useState(""); + + const reload = useCallback(async () => { + if (!enabled) { + setApps([]); + return; + } + setLoading(true); + try { + const result = await getFlatpakApps(); + if (!result.success) throw new Error(result.error || "Could not list Flatpak applications"); + setApps(result.apps || []); + } catch (error) { + showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + } + }, [enabled]); + + const pollRunning = useCallback(async () => { + if (!enabled) { + setRunningApps([]); + return; + } + try { + const result = await getRunningFlatpakApps(); + if (result.success) setRunningApps(result.apps || []); + } catch {} + }, [enabled]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + void pollRunning(); + if (!enabled) return; + const interval = window.setInterval(() => void pollRunning(), 2000); + return () => window.clearInterval(interval); + }, [enabled, pollRunning]); + + const operate = useCallback(async ( + appId: string, + operation: () => Promise<FlatpakOperationResult>, + refresh = true, + ): Promise<FlatpakOperationResult> => { + if (busyAppId) return { success: false }; + setBusyAppId(appId); + try { + const result = await operation(); + if (!result.success) throw new Error(result.error || "Flatpak operation failed"); + if (refresh) { + await reload(); + await pollRunning(); + } + return result; + } catch (error) { + showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error)); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + setBusyAppId(""); + } + }, [busyAppId, pollRunning, reload]); + + const enableApp = useCallback(async (appId: string) => ( + await operate(appId, () => enableFlatpakApp(appId)) + ).success, [operate]); + const removeApp = useCallback(async (appId: string) => ( + await operate(appId, () => removeFlatpakApp(appId)) + ).success, [operate]); + const enableAll = useCallback(async (): Promise<void> => { + if (busyAppId) return; + const available = apps.filter((app) => ( + !app.enabled && !(app.prepared && !app.owned) && !app.error + )); + for (const app of available) { + const result = await operate(app.app_id, () => enableFlatpakApp(app.app_id), false); + if (!result.success) break; + } + await reload(); + await pollRunning(); + }, [apps, busyAppId, operate, pollRunning, reload]); + const removeAll = useCallback(async (): Promise<void> => { + if (busyAppId) return; + for (const app of apps.filter((item) => item.enabled)) { + const result = await operate(app.app_id, () => removeFlatpakApp(app.app_id), false); + if (!result.success) break; + } + await reload(); + await pollRunning(); + }, [apps, busyAppId, operate, pollRunning, reload]); + const updateConfig = useCallback( + async (appId: string, config: LsfgConfig) => { + const result = await operate(appId, () => updateFlatpakConfig(appId, config), false); + if (result.success) { + setApps((current) => current.map((app) => ( + app.app_id === appId ? { ...app, config: result.config || config } : app + ))); + } + return result.success; + }, + [operate], + ); + const updateWorkarounds = useCallback( + async (appId: string, state: WorkaroundState) => { + const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false); + if (result.success) { + setApps((current) => current.map((app) => ( + app.app_id === appId ? { ...app, workarounds: result.state || state } : app + ))); + } + return result.success; + }, + [operate], + ); + + const runningApp = useMemo(() => selectMostRecentRunningFlatpak(apps, runningApps), [apps, runningApps]); + + return { + apps, + runningApps, + runningApp, + loading, + busyAppId, + reload, + enableApp, + enableAll, + removeApp, + removeAll, + updateConfig, + updateWorkarounds, + }; +} diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index e120426..deb68ff 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundApp, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions"; +import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; +import { getTargetSource, mergeGameTargets, type GameTarget, type KnownGameSource } from "../utils/gameTargets"; import { showErrorToast } from "../utils/toastUtils"; -export interface GameTarget extends InstalledGame { configured: boolean; } +export type { GameSource, GameTarget, KnownGameSource } from "../utils/gameTargets"; async function getSteamShortcuts(): Promise<InstalledGame[]> { const apps = (globalThis as any).SteamClient?.Apps; @@ -23,7 +24,6 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> { appid: String(appid >>> 0), name, nonSteam: true, - transport: { kind: "host" }, }]; }); } catch { @@ -40,18 +40,6 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta return Array.from(games.values()); } -function selectShortcutExecutable( - target: GameTarget, - ...candidates: Array<string | null | undefined> -): string | undefined { - const absolute = candidates - .map((candidate) => candidate?.trim()) - .find((candidate) => candidate && candidate.startsWith("/")); - if (absolute) return absolute; - if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; - return candidates.map((candidate) => candidate?.trim()).find(Boolean); -} - const DEFAULT_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: true, @@ -69,20 +57,29 @@ export function useGameConfiguration() { const [games, setGames] = useState<GameConfigEntry[]>([]); const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false }); const [installedGames, setInstalledGames] = useState<InstalledGame[]>([]); + const [workaroundApps, setWorkaroundApps] = useState<WorkaroundApp[]>([]); const [configsLoaded, setConfigsLoaded] = useState(false); const [selectedAppId, setSelectedAppId] = useState(""); const [runningGame, setRunningGame] = useState<GameTarget | null>(null); + const [bulkOperationBusy, setBulkOperationBusy] = useState(false); + const bulkOperationLock = useRef(false); const previousRunningAppId = useRef<string | null>(null); const previousQuickAccessVisible = useRef<boolean | null>(null); const quickAccessVisible = useQuickAccessVisible(); const load = useCallback(async () => { - const [result, installed, shortcuts] = await Promise.all([getGameConfigs(), getInstalledGames(), getSteamShortcuts()]); + const [result, installed, shortcuts, workaroundResult] = await Promise.all([ + getGameConfigs(), + getInstalledGames(), + getSteamShortcuts(), + getWorkaroundApps(), + ]); if (result.success) { setGlobalConfig(result.global_config || { dll: "", no_fp16: false }); setGames(result.games || []); } setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts)); + setWorkaroundApps(workaroundResult.success ? workaroundResult.apps || [] : []); setConfigsLoaded(true); }, []); @@ -92,6 +89,7 @@ export function useGameConfiguration() { previousQuickAccessVisible.current = quickAccessVisible; if (initialLoad || becameVisible) void load(); }, [load, quickAccessVisible]); + useEffect(() => { const poll = () => { if (!configsLoaded) return; @@ -101,16 +99,29 @@ export function useGameConfiguration() { const installed = installedGames.find((game) => game.appid === appid); const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); - setRunningGame((current) => current?.appid === appid ? current : { - ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }), + const source = getTargetSource(appid, installedGames, workaroundApps); + const next: GameTarget = { + ...(installed || { appid, name, nonSteam: source === "nonSteam" }), name, + nonSteam: source === "nonSteam", + source, configured: games.some((game) => game.appid === appid), - }); + }; + setRunningGame((current) => ( + current?.appid === next.appid + && current.name === next.name + && current.nonSteam === next.nonSteam + && current.source === next.source + && current.configured === next.configured + ? current + : next + )); }; poll(); const interval = window.setInterval(poll, 2000); return () => window.clearInterval(interval); - }, [configsLoaded, games, installedGames]); + }, [configsLoaded, games, installedGames, workaroundApps]); + useEffect(() => { const appid = runningGame?.appid || null; if (appid !== previousRunningAppId.current) { @@ -120,162 +131,89 @@ export function useGameConfiguration() { }, [runningGame?.appid]); const targets = useMemo<GameTarget[]>(() => { - const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true }); - if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); - return configured; - }, [games, installedGames, runningGame]); + return mergeGameTargets(games, installedGames, workaroundApps, runningGame); + }, [games, installedGames, runningGame, workaroundApps]); const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; + const runningConfig = runningGame + ? games.find((game) => game.appid === runningGame.appid)?.config || template + : template; - const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise<boolean> => { - if (target.transport.kind !== "flatpak") return true; - const result = await ensureFlatpakSupport(target.transport.flatpakAppId); - if (!result.success || result.support_status !== "ready") { - showErrorToast( - "Flatpak support unavailable", - result.error || result.message || "The required Flatpak runtime extension is not ready", - ); + const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { + if (target.source === "unknown") { + showErrorToast("Could not initialize workarounds", "The target source is unknown; re-discover the game before enabling it"); return false; } - return true; - }, []); - - const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { if (!installedGames.some((game) => game.appid === target.appid)) return true; const appId = Number(target.appid); + let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; + let newState = false; + let stateWriteAttempted = false; + let wrapperPath = getDefaultWrapperPath(); try { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); - const current = await readSteamLaunchOptions(appId, target.nonSteam); - const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); - const oldState = existing.state; - const oldShortcutExe = existing.shortcut_exe || undefined; - const oldCommandTokenAdded = existing.command_token_added === true; - const oldTransport = existing.transport || target.transport; - const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; - if (usesShortcutTarget && oldState && current.target === wrapperPath && !oldShortcutExe) { - throw new Error("Managed shortcut Target has no saved original executable"); - } - if (usesShortcutTarget && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { - throw new Error("Shortcut Target changed externally; refusing to replace it"); - } - if (usesShortcutTarget && !oldState && current.target === wrapperPath) { - throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); - } - const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = usesShortcutTarget - ? selectShortcutExecutable( - target, - oldShortcutExe, - target.executable, - current.target, - ) - : undefined; - const initialIntegration = usesShortcutTarget - ? current.target === wrapperPath - : hasWrapperLaunchIntegration(current.options, wrapperPath); - const initialStateResult = await setWorkaroundState( + wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const state = existing.state || { ...DEFAULT_WORKAROUND_STATE }; + const commandTokenAdded = existing.command_token_added === true; + newState = !existing.state; + integration = await installWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + commandTokenAdded, + ); + stateWriteAttempted = true; + const saved = await setWorkaroundState( target.appid, state, - originalExecutable || null, - oldCommandTokenAdded, - target.transport, + integration.commandTokenAdded, + target.nonSteam, ); - if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state"); - - let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; - try { - integration = await installWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - oldCommandTokenAdded, - target.transport.kind, - ); - const finalStateResult = await setWorkaroundState( - target.appid, - state, - usesShortcutTarget - ? (selectShortcutExecutable( - target, - integration.originalExecutable, - originalExecutable, - target.executable, - ) || null) - : null, - integration.commandTokenAdded, - target.transport, - ); - if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state"); - return true; - } catch (error) { - let rollbackSucceeded = true; - if (!initialIntegration && integration) { - try { - await removeWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - usesShortcutTarget - ? (selectShortcutExecutable( - target, - integration?.originalExecutable, - originalExecutable, - target.executable, - ) || undefined) - : undefined, - integration?.commandTokenAdded ?? oldCommandTokenAdded, - target.transport.kind, - ); - } catch (rollbackError) { - showErrorToast("Workaround rollback failed", asError(rollbackError).message); - rollbackSucceeded = false; - } + if (!saved.success) throw new Error(saved.error || "Could not save workaround state"); + return true; + } catch (error) { + let rollbackSucceeded = true; + if (integration?.changed) { + try { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + integration.commandTokenAdded, + ); + } catch (rollbackError) { + showErrorToast("Workaround rollback failed", asError(rollbackError).message); + rollbackSucceeded = false; } - if (rollbackSucceeded) { - const restored = oldState - ? await setWorkaroundState( - target.appid, - oldState, - oldShortcutExe || null, - oldCommandTokenAdded, - oldTransport, - ) - : await removeWorkaroundState(target.appid); - if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state"); + } + if (rollbackSucceeded && newState && stateWriteAttempted) { + const restored = await removeWorkaroundState(target.appid); + if (!restored.success) { + showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state"); + rollbackSucceeded = false; } - throw error; } - } catch (error) { showErrorToast("Could not initialize workarounds", asError(error).message); return false; } }, [installedGames]); const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { - if (!installedGames.some((game) => game.appid === target.appid)) return true; + const installed = installedGames.some((game) => game.appid === target.appid); + if (target.source === "unknown" && installed) return true; const appId = Number(target.appid); try { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); - const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; - if (existing.state) { + if (installed) { await removeWrapperIntegration( appId, target.nonSteam, wrapperPath, - existing.shortcut_exe || undefined, existing.command_token_added === true, - target.transport.kind, ); - } else { - const current = await readSteamLaunchOptions(appId, target.nonSteam); - if (usesShortcutTarget && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { - throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); - } - await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); } const removed = await removeWorkaroundState(target.appid); if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); @@ -286,59 +224,113 @@ export function useGameConfiguration() { } }, [installedGames]); - const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { - const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (!selectedTarget?.name) return; - // The profile owns its wrapper integration. Keep this check on every - // configuration save so an external edit is detected before the profile - // is changed; toggles update the sidecar only. - if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return; - const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); + const acquireBulkOperation = useCallback(() => { + if (bulkOperationLock.current) return false; + bulkOperationLock.current = true; + setBulkOperationBusy(true); + return true; + }, []); + + const releaseBulkOperation = useCallback(() => { + bulkOperationLock.current = false; + setBulkOperationBusy(false); + }, []); + + const cleanupAllWorkarounds = useCallback(async (): Promise<boolean> => { + try { + const result = await getWorkaroundApps(); + if (!result.success) throw new Error(result.error || "Could not read workaround state"); + const cleaned = new Set<string>(); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + + for (const entry of result.apps || []) { + await removeWrapperIntegration( + Number(entry.appid), + entry.non_steam, + wrapperPath, + entry.command_token_added, + ); + const removed = await removeWorkaroundState(entry.appid); + if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); + cleaned.add(entry.appid); + } + + // Also clean configured targets whose sidecar entry was lost. This + // removes an old wrapper and only the plugin-managed launch pieces. + for (const target of targets.filter((item) => item.configured && installedGames.some((game) => game.appid === item.appid))) { + if (!cleaned.has(target.appid) && !(await removeTargetWorkarounds(target))) return false; + } + return true; + } catch (error) { + showErrorToast("Could not clean up game launch options", asError(error).message); + return false; + } + }, [installedGames, removeTargetWorkarounds, targets]); + + const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => { + const target = targets.find((item) => item.appid === appid); + if (!target?.name) return false; + if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false; + const result = await updateGameConfig(appid, target.name, next); if (result.success) await load(); - }, [ensureTargetWorkarounds, load, selectedAppId, targets]); + return result.success; + }, [ensureTargetWorkarounds, load, targets]); + + const save = useCallback( + async (next: ConfigurationData, cleanupLaunchOptions = false) => { + if (!selectedAppId) return false; + return saveFor(selectedAppId, next, cleanupLaunchOptions); + }, + [saveFor, selectedAppId], + ); + + const updateGlobal = useCallback(async (next: GlobalConfig): Promise<boolean> => { + const result = await saveGlobalConfig(next); + if (!result.success) return false; + setGlobalConfig(result.global_config || next); + return true; + }, []); const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; - if (!(await ensureTargetFlatpakSupport(target))) return false; if (!(await ensureTargetWorkarounds(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); else await removeTargetWorkarounds(target); return result.success; - }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); - const enableAll = useCallback(async (): Promise<void> => { - const available = targets.filter((target) => !target.configured && target.name); - if (available.length === 0) return; + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); - for (const target of available) { - if (!(await ensureTargetFlatpakSupport(target))) return; - if (!(await ensureTargetWorkarounds(target))) return; - const result = await updateGameConfig(target.appid, target.name, template); - if (!result.success) { - await removeTargetWorkarounds(target); - showErrorToast( - "Could not enable all games", - result.error || `Could not create a profile for ${target.name}`, - ); - return; + const enableAll = useCallback(async (source: KnownGameSource): Promise<void> => { + if (!acquireBulkOperation()) return; + try { + const available = targets.filter((target) => target.source === source && !target.configured && target.name); + if (available.length === 0) return; + for (const target of available) { + if (!(await ensureTargetWorkarounds(target))) { + await load(); + return; + } + const result = await updateGameConfig(target.appid, target.name, template); + if (!result.success) { + await removeTargetWorkarounds(target); + showErrorToast( + "Could not enable all games", + result.error || `Could not create a profile for ${target.name}`, + ); + await load(); + return; + } } + await load(); + } finally { + releaseBulkOperation(); } - await load(); - }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [acquireBulkOperation, ensureTargetWorkarounds, load, removeTargetWorkarounds, releaseBulkOperation, targets, template]); + const repair = useCallback(async (appid: string): Promise<boolean> => { const target = targets.find((item) => item.appid === appid); if (!target) return false; - if (target.transport.kind === "flatpak") { - const support = await repairFlatpakSupport(target.transport.flatpakAppId); - if (!support.success || support.support_status !== "ready") { - showErrorToast( - "Flatpak support unavailable", - support.error || support.message || "The required Flatpak runtime extension is not ready", - ); - return false; - } - } const success = await ensureTargetWorkarounds(target); if (success) await load(); return success; @@ -356,17 +348,31 @@ export function useGameConfiguration() { } } }, [load, removeTargetWorkarounds, selectedAppId, targets]); - const resetAll = useCallback(async () => { - for (const target of targets.filter((item) => item.configured)) { - if (!(await removeTargetWorkarounds(target))) return; - } - const result = await resetAllGameConfigs(); - if (result.success) { - setRunningGame((current) => current ? { ...current, configured: false } : current); + + const resetAll = useCallback(async (source: KnownGameSource) => { + if (!acquireBulkOperation()) return; + try { + const selectedTargets = targets.filter((item) => item.configured && item.source === source); + if (selectedTargets.length === 0) return; + for (const target of selectedTargets) { + if (!(await removeTargetWorkarounds(target))) { + await load(); + return; + } + } + const result = await resetGameConfigs(selectedTargets.map((target) => target.appid)); + if (!result.success) { + showErrorToast("Could not remove all profiles", result.error || "Could not remove the selected profiles"); + await load(); + return; + } + setRunningGame((current) => current?.source === source ? { ...current, configured: false } : current); setSelectedAppId(""); await load(); + } finally { + releaseBulkOperation(); } - }, [load, removeTargetWorkarounds, targets]); + }, [acquireBulkOperation, load, removeTargetWorkarounds, releaseBulkOperation, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; + return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, bulkOperationBusy, cleanupAllWorkarounds, reload: load }; } diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index 9beb749..0f51e90 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -13,7 +13,10 @@ import { showUninstallSuccessToast, } from "../utils/toastUtils"; -export function useInstallation(reloadConfig?: () => Promise<void>) { +export function useInstallation( + reloadConfig?: () => Promise<void>, + beforeUninstall?: () => Promise<boolean>, +) { const [isInstalled, setIsInstalled] = useState(false); const [installationStatus, setInstallationStatus] = useState(""); const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); @@ -77,6 +80,10 @@ export function useInstallation(reloadConfig?: () => Promise<void>) { setIsUninstalling(true); setInstallationStatus("Uninstalling lsfg-vk..."); try { + if (beforeUninstall && !(await beforeUninstall())) { + setInstallationStatus("Uninstallation cancelled: could not clean up launch options"); + return; + } const result = await uninstallLsfgVk(); if (!result.success) { setInstallationStatus(`Uninstallation failed: ${result.error}`); diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index ebb12e2..ab33bb2 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -3,14 +3,12 @@ import { getWorkaroundState, removeWorkaroundState, setWorkaroundState, - type TargetTransport, type WorkaroundState, } from "../api/lsfgApi"; import { getDefaultWrapperPath, - hasWrapperLaunchIntegration, installWrapperIntegration, - isLegacyWrapperToken, + isWrapperIntegrationInstalled, readSteamLaunchOptions, removeWrapperIntegration, subscribeSteamLaunchOptions, @@ -45,8 +43,6 @@ export interface WorkaroundSnapshot { wrapperOwned: boolean; integrationInstalled: boolean; commandTokenAdded: boolean; - shortcutExe?: string | null; - transport: TargetTransport; } interface PerAppWorkarounds { @@ -61,115 +57,50 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function selectShortcutExecutable( - transport: TargetTransport, - ...candidates: Array<string | null | undefined> -): string | undefined { - const absolute = candidates - .map((candidate) => candidate?.trim()) - .find((candidate) => candidate && candidate.startsWith("/")); - if (absolute) return absolute; - if (transport.kind === "flatpak") return "/usr/bin/flatpak"; - return candidates.map((candidate) => candidate?.trim()).find(Boolean); -} - -function usesShortcutTarget(nonSteam: boolean, transport: TargetTransport): boolean { - return nonSteam && transport.kind === "flatpak"; -} - -function integrationIsInstalled( - steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - transport: TargetTransport, - wrapperPath: string, -): boolean { - return usesShortcutTarget(nonSteam, transport) - ? steam.target === wrapperPath - : hasWrapperLaunchIntegration(steam.options, wrapperPath); -} - function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited<ReturnType<typeof getWorkaroundState>>, nonSteam: boolean, - transport: TargetTransport, ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); - const selectedTransport = result.transport || transport; - if (usesShortcutTarget(nonSteam, selectedTransport) && steam.target === wrapperPath && !result.shortcut_exe) { - throw new Error("Managed shortcut Target has no saved original executable"); - } return { steam, state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: integrationIsInstalled(steam, nonSteam, selectedTransport, wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath), commandTokenAdded: result.command_token_added === true, - shortcutExe: result.shortcut_exe, - transport: selectedTransport, }; } async function adoptWorkaroundState( appId: string, nonSteam: boolean, - transport: TargetTransport, - steam: SteamLaunchOptionsSnapshot, wrapperPath: string, ): Promise<WorkaroundSnapshot> { - const shortcutTarget = usesShortcutTarget(nonSteam, transport); - if (shortcutTarget && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { - throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); - } - const originalExecutable = shortcutTarget - ? selectShortcutExecutable(transport, steam.target) - : null; - const initial = await setWorkaroundState( - appId, - DEFAULT_WORKAROUND_STATE, - originalExecutable, - false, - transport, - ); - if (!initial.success) throw new Error(initial.error || "Could not create workaround state"); let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; try { - integration = await installWrapperIntegration( - Number(appId), - nonSteam, - wrapperPath, - false, - transport.kind, - ); + integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - shortcutTarget - ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) - : null, integration.commandTokenAdded, - transport, + nonSteam, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); - return makeSnapshot(integration.snapshot, finalized, nonSteam, transport); + return makeSnapshot(integration.snapshot, finalized, nonSteam); } catch (error) { let rollbackSucceeded = true; - if (integration) { + if (integration?.changed) { try { await removeWrapperIntegration( Number(appId), nonSteam, wrapperPath, - shortcutTarget - ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) - : undefined, - integration?.commandTokenAdded ?? false, - transport.kind, + integration.commandTokenAdded, ); } catch { - // Leave the owned integration in place rather than guessing at cleanup. rollbackSucceeded = false; } } @@ -181,11 +112,7 @@ async function adoptWorkaroundState( } } -export function usePerAppWorkarounds( - appId: string, - nonSteam: boolean, - transport: TargetTransport = { kind: "host" }, -): PerAppWorkarounds { +export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { const [status, setStatus] = useState<WorkaroundLoadStatus>("loading"); const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null); const [error, setError] = useState<string | null>(null); @@ -202,13 +129,11 @@ export function usePerAppWorkarounds( return adoptWorkaroundState( appId, nonSteam, - transport, - steam, result.wrapper_path || getDefaultWrapperPath(), ); } - return makeSnapshot(steam, result, nonSteam, transport); - }, [appId, nonSteam, numericAppId, transport]); + return makeSnapshot(steam, result, nonSteam); + }, [appId, nonSteam, numericAppId]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { setSnapshot(next); @@ -243,7 +168,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: integrationIsInstalled(steam, nonSteam, current.transport, current.wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath), } : current); }, (subscriptionError) => { @@ -281,9 +206,8 @@ export function usePerAppWorkarounds( const result = await setWorkaroundState( appId, nextState, - current.shortcutExe ?? null, current.commandTokenAdded, - current.transport, + nonSteam, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); applySnapshot({ @@ -291,9 +215,7 @@ export function usePerAppWorkarounds( state: result.state, wrapperPath: result.wrapper_path || current.wrapperPath, wrapperOwned: result.wrapper_owned === true, - shortcutExe: result.shortcut_exe, commandTokenAdded: result.command_token_added === true, - transport: result.transport || current.transport, }); return true; } catch (updateError) { diff --git a/src/styles.ts b/src/styles.ts index 1bc089b..58c6b01 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -10,6 +10,10 @@ export const tabStyles = ` padding-right: 8px !important; } + .lsfg-vk-tabs .lsfg-vk-tab-content { + padding-bottom: 96px; // workaround for in-game bottom bar padding behaving differently than in launcher, remove later? + } + .lsfg-vk-tabs [role="tablist"] { display: flex; flex-wrap: nowrap; @@ -31,4 +35,17 @@ export const tabStyles = ` display: block; margin: 0; } + + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"], + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div, + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div, + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] [role="tab"] { + animation: none !important; + transition: none !important; + } + + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div { + scroll-behavior: auto !important; + scroll-snap-type: none !important; + } `; diff --git a/src/types.d.ts b/src/types.d.ts index e5db40d..4adad61 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -16,8 +16,6 @@ declare module "*.jpg" { interface SteamAppDetails { strLaunchOptions?: string; strShortcutLaunchOptions?: string; - strShortcutExe?: string; - strShortcutStartDir?: string; } interface SteamAppDetailsRegistration { @@ -31,7 +29,6 @@ interface SteamApps { ): SteamAppDetailsRegistration; SetAppLaunchOptions(appId: number, options: string): void | Promise<void>; SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>; - SetShortcutExe(appId: number, executable: string): void | Promise<void>; TerminateApp(appId: string, param1: boolean): void; GetAllShortcuts?(): Promise<unknown[]>; } diff --git a/src/utils/gameTargets.ts b/src/utils/gameTargets.ts new file mode 100644 index 0000000..8922e98 --- /dev/null +++ b/src/utils/gameTargets.ts @@ -0,0 +1,75 @@ +import type { GameConfigEntry, InstalledGame, WorkaroundApp } from "../api/lsfgApi"; + +export type GameSource = "steam" | "nonSteam" | "unknown"; +export type KnownGameSource = Exclude<GameSource, "unknown">; + +export interface GameTarget extends InstalledGame { + configured: boolean; + source: GameSource; +} + +export function sourceFromNonSteam(nonSteam: boolean): KnownGameSource { + return nonSteam ? "nonSteam" : "steam"; +} + +export function getTargetSource( + appid: string, + installedGames: InstalledGame[], + workaroundApps: WorkaroundApp[], +): GameSource { + const workaround = workaroundApps.find((item) => item.appid === appid); + if (workaround) return sourceFromNonSteam(workaround.non_steam); + + const installed = installedGames.find((game) => game.appid === appid); + return installed ? sourceFromNonSteam(installed.nonSteam) : "unknown"; +} + +export function mergeGameTargets( + configs: GameConfigEntry[], + installedGames: InstalledGame[], + workaroundApps: WorkaroundApp[], + runningGame: GameTarget | null = null, +): GameTarget[] { + const configuredIds = new Set(configs.map((game) => game.appid)); + const targets = installedGames.map((game) => { + const source = getTargetSource(game.appid, installedGames, workaroundApps); + return { + ...game, + nonSteam: source === "nonSteam", + source, + configured: configuredIds.has(game.appid), + }; + }); + + for (const game of configs) { + if (targets.some((target) => target.appid === game.appid)) continue; + const source = getTargetSource(game.appid, installedGames, workaroundApps); + targets.push({ + appid: game.appid, + name: game.profile || `App ${game.appid}`, + nonSteam: source === "nonSteam", + source, + configured: true, + }); + } + + if ( + runningGame + && !targets.some((target) => target.appid === runningGame.appid) + && (runningGame.configured || runningGame.source !== "unknown") + ) { + targets.unshift(runningGame); + } + + return targets; +} + +export function targetsForSource(targets: GameTarget[], source: KnownGameSource): GameTarget[] { + return targets.filter((target) => target.source === source || (target.source === "unknown" && target.configured)); +} + +export function sourceLabel(source: GameSource): string { + if (source === "nonSteam") return "Non-Steam"; + if (source === "steam") return "Steam"; + return "Unknown source"; +} diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts new file mode 100644 index 0000000..a307f5c --- /dev/null +++ b/src/utils/nowPlaying.ts @@ -0,0 +1,86 @@ +import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi"; +import type { GameTarget } from "./gameTargets"; + +export type NowPlayingTarget = + | { + kind: "flatpak"; + app: FlatpakApp; + launcher: GameTarget | null; + } + | { + kind: "steam"; + game: GameTarget; + } + | { + kind: "nonSteam"; + game: GameTarget; + }; + +function numericValue(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : -1; +} + +function numericPid(value: string | undefined): number { + return value && /^\d+$/.test(value) ? Number(value) : -1; +} + +function compareRunningProcesses(a: RunningFlatpakApp, b: RunningFlatpakApp): number { + if (a.active !== b.active) return a.active ? -1 : 1; + const startDifference = numericValue(b.start_time) - numericValue(a.start_time); + if (startDifference !== 0) return startDifference; + return numericPid(b.pid) - numericPid(a.pid); +} + +export function selectMostRecentRunningFlatpak( + apps: FlatpakApp[], + runningApps: RunningFlatpakApp[], +): FlatpakApp | null { + const newestProcessByApp = new Map<string, RunningFlatpakApp>(); + for (const running of runningApps) { + const current = newestProcessByApp.get(running.app_id); + if (!current || compareRunningProcesses(running, current) < 0) { + newestProcessByApp.set(running.app_id, running); + } + } + + const candidates = Array.from(newestProcessByApp.values()) + .map((running) => ({ + running, + app: apps.find((app) => app.app_id === running.app_id) || null, + })) + .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null); + const activeCandidates = candidates.filter(({ running }) => running.active); + const eligibleCandidates = activeCandidates.length > 0 + ? activeCandidates + : candidates.length === 1 + ? candidates + : []; + + eligibleCandidates.sort((a, b) => { + const processDifference = compareRunningProcesses(a.running, b.running); + if (processDifference !== 0) return processDifference; + return a.running.app_id.localeCompare(b.running.app_id); + }); + + return eligibleCandidates[0]?.app || null; +} + +export function resolveNowPlayingTarget( + runningGame: GameTarget | null, + runningFlatpak: FlatpakApp | null, +): NowPlayingTarget | null { + if (runningGame?.source === "steam") { + return runningGame.configured ? { kind: "steam", game: runningGame } : null; + } + if (runningFlatpak) { + return { + kind: "flatpak", + app: runningFlatpak, + launcher: runningGame?.source === "nonSteam" ? runningGame : null, + }; + } + if (runningGame?.source === "nonSteam" && runningGame.configured) { + return { kind: "nonSteam", game: runningGame }; + } + return null; +} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 65541d8..83c46f2 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -24,13 +24,12 @@ export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; - target: string; details: SteamAppDetails; } export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; - originalExecutable?: string; commandTokenAdded: boolean; + changed: boolean; } function asError(error: unknown): Error { @@ -58,7 +57,6 @@ function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): S appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", - target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -155,7 +153,25 @@ function tokenize(options: string): LaunchToken[] { } const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); -const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +function isCommandToken(token: LaunchToken): boolean { + return token.value.toLowerCase() === COMMAND_TOKEN; +} + +function isMalformedCommandToken(token: LaunchToken): boolean { + const value = token.value.toLowerCase(); + return value === "%command" || value === "command%"; +} + +function normalizeCommandTokens(tokens: LaunchToken[]): void { + for (const token of tokens) { + if (isCommandToken(token) || isMalformedCommandToken(token)) { + token.raw = COMMAND_TOKEN; + token.value = COMMAND_TOKEN; + } + } +} + +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex(isCommandToken); const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); @@ -172,12 +188,12 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption( +function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, - allowCommandArgs = false, ) { const tokens = tokenize(options); + normalizeCommandTokens(tokens); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); if (command >= 0) { @@ -187,11 +203,15 @@ export function installWrapperLaunchOption( tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath }); return { options: serialize(tokens), commandTokenAdded: false }; } + + const existingWrapper = tokens.findIndex((token) => decodeToken(token.value) === wrapperPath); + if (existingWrapper >= 0) { + tokens.splice(existingWrapper + 1, 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + return { options: serialize(tokens), commandTokenAdded: true }; + } + let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { - throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); - } tokens.splice(insertion, 0, { raw: wrapperPath, value: wrapperPath }, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, @@ -199,12 +219,17 @@ export function installWrapperLaunchOption( return { options: serialize(tokens), commandTokenAdded: true }; } +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { + return installLaunchOption(options, wrapperPath); +} + export function removeWrapperLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, commandTokenAdded = false, ): string { const tokens = tokenize(options); + normalizeCommandTokens(tokens); if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) { const command = commandIndex(tokens); if (command >= 0) tokens.splice(command, 1); @@ -249,6 +274,14 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU return command > 0 && tokens[command - 1].value === wrapperPath; } +export function isWrapperIntegrationInstalled( + steam: SteamLaunchOptionsSnapshot, + _nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, +): boolean { + return hasWrapperLaunchIntegration(steam.options, wrapperPath); +} + const queues = new Map<string, Promise<unknown>>(); function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; @@ -286,18 +319,16 @@ async function writeVerified( previous: string, next: string, write: (value: string) => Promise<void>, - read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise<SteamLaunchOptionsSnapshot> { - const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; try { await write(next); - return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); + return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message); } catch (error) { const failure = asError(error); try { await write(previous); - await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options"); } catch (rollback) { throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); } @@ -305,19 +336,11 @@ async function writeVerified( } } -const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; -const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; - function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> { const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions; if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`)); return Promise.resolve(setter.call(apps(), appId, value)); } -function writeTarget(appId: number, value: string): Promise<void> { - const setter = apps()?.SetShortcutExe; - if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); - return Promise.resolve(setter.call(apps(), appId, value)); -} export function updateSteamLaunchOptions( appId: number, @@ -329,7 +352,7 @@ export function updateSteamLaunchOptions( const next = transform(current.options); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (value) => writeOptions(appId, nonSteam, value), readOptions, + (value) => writeOptions(appId, nonSteam, value), "Steam did not accept the launch options", ); }); @@ -340,43 +363,23 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", ): Promise<WrapperIntegrationResult> { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { - if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); - if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { - throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); - } - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not accept shortcut launch options", - ); - } - if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false }; - const originalExecutable = current.target; - const value = await writeVerified( - appId, true, originalExecutable, wrapperPath, - (target) => writeTarget(appId, target), readTarget, - "Steam did not accept the shortcut Target", - ); - return { snapshot: value, originalExecutable, commandTokenAdded: false }; - } - + const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); - if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + const rewrite = installLaunchOption(cleaned, wrapperPath); + if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false }; const value = await writeVerified( appId, nonSteam, current.options, rewrite.options, - (options) => writeOptions(appId, nonSteam, options), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); - return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + return { + snapshot: value, + commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded, + changed: true, + }; }); } @@ -384,38 +387,14 @@ export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, - originalExecutable?: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", ): Promise<SteamLaunchOptionsSnapshot> { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (current.target !== wrapperPath && current.target !== originalExecutable) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not clean shortcut launch options", - ); - } - if (current.target === originalExecutable) return current; - return writeVerified( - appId, true, wrapperPath, originalExecutable, - (target) => writeTarget(appId, target), readTarget, - "Steam did not restore the shortcut Target", - ); - } + const current = await readSteamLaunchOptions(appId, nonSteam); const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (options) => writeOptions(appId, nonSteam, options), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not clean the launch options", ); }); diff --git a/tests/gameTargets.test.ts b/tests/gameTargets.test.ts new file mode 100644 index 0000000..2eb8b7e --- /dev/null +++ b/tests/gameTargets.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getTargetSource, mergeGameTargets, targetsForSource } from "../src/utils/gameTargets.ts"; + +const config = (appid: string, profile: string) => ({ appid, profile, config: {} }); + +test("workaround sidecar source wins over current discovery metadata", () => { + const installed = [{ appid: "123", name: "Shortcut", nonSteam: false }]; + const workarounds = [{ appid: "123", non_steam: true, command_token_added: true }]; + + assert.equal(getTargetSource("123", installed, workarounds), "nonSteam"); + assert.equal(mergeGameTargets([config("123", "Shortcut")], installed, workarounds)[0].source, "nonSteam"); +}); + +test("configured profiles without reliable source are unknown", () => { + const targets = mergeGameTargets([config("456", "Missing Game")], [], []); + + assert.deepEqual(targets[0], { + appid: "456", + name: "Missing Game", + nonSteam: false, + source: "unknown", + configured: true, + }); +}); + +test("unknown configured profiles are visible in both source tabs", () => { + const targets = mergeGameTargets([ + config("123", "Steam Game"), + config("456", "Missing Game"), + ], [ + { appid: "123", name: "Steam Game", nonSteam: false }, + { appid: "789", name: "Shortcut", nonSteam: true }, + ], []); + + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); + + assert.deepEqual(steamTargets.map((target) => target.appid).sort(), ["123", "456"]); + assert.deepEqual(nonSteamTargets.map((target) => target.appid).sort(), ["456", "789"]); +}); + +test("direct Flatpak shortcuts remain non-Steam targets", () => { + const targets = mergeGameTargets([], [ + { appid: "123", name: "Flatpak shortcut", nonSteam: true, isFlatpakShortcut: true }, + ], []); + + assert.equal(targets[0].source, "nonSteam"); + assert.equal(targets[0].isFlatpakShortcut, true); +}); diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts new file mode 100644 index 0000000..a6b116a --- /dev/null +++ b/tests/nowPlaying.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveNowPlayingTarget, selectMostRecentRunningFlatpak } from "../src/utils/nowPlaying.ts"; + +const flatpak = (app_id: string, app_name = app_id) => ({ + app_id, + app_name, + runtime_ready: true, + prepared: true, + owned: true, + enabled: true, + profile: `flatpak:${app_id}`, + workarounds: { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, + }, +}); + +const game = (nonSteam = true, configured = true) => ({ + appid: "123456", + name: nonSteam ? "1080 Snowboarding" : "Native Game", + nonSteam, + source: nonSteam ? "nonSteam" : "steam", + configured, +}); + +test("selects the newest active managed Flatpak", () => { + const apps = [flatpak("org.example.old"), flatpak("org.example.new")]; + const running = [ + { app_id: "org.example.old", active: true, pid: "100", start_time: 500 }, + { app_id: "org.example.new", active: true, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.new"); +}); + +test("prefers active Flatpak status before process age", () => { + const apps = [flatpak("org.example.running"), flatpak("org.example.active")]; + const running = [ + { app_id: "org.example.running", active: false, pid: "900", start_time: 900 }, + { app_id: "org.example.active", active: true, pid: "100", start_time: 100 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.active"); +}); + +test("deduplicates multiple process rows for one managed Flatpak", () => { + const apps = [flatpak("com.heroicgameslauncher.hgl")]; + const running = [ + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228081", start_time: null }, + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228116", start_time: null }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "com.heroicgameslauncher.hgl"); +}); + +test("Flatpak runtime wins while a Steam shortcut is running", () => { + const target = resolveNowPlayingTarget(game(true), flatpak("org.libretro.RetroArch", "RetroArch")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher?.name : null, "1080 Snowboarding"); +}); + +test("native Steam game wins over an unrelated Flatpak", () => { + assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "steam"); +}); + +test("unconfigured native Steam game blocks unrelated Flatpak Now Playing", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), flatpak("org.example.Game")), null); +}); + +test("direct Flatpak launch creates a Flatpak Now Playing target", () => { + const target = resolveNowPlayingTarget(null, flatpak("org.example.Game")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher : null, null); +}); + +test("multiple inactive Flatpaks do not create an arbitrary Now Playing target", () => { + const apps = [flatpak("org.example.one"), flatpak("org.example.two")]; + const running = [ + { app_id: "org.example.one", active: false, pid: "100", start_time: 500 }, + { app_id: "org.example.two", active: false, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running), null); +}); + +test("one inactive Flatpak remains a usable fallback", () => { + const apps = [flatpak("org.example.one")]; + const running = [{ app_id: "org.example.one", active: false, pid: "100", start_time: 500 }]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.one"); +}); + +test("configured Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(false), null); + + assert.equal(target?.kind, "steam"); +}); + +test("configured non-Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(true), null); + + assert.equal(target?.kind, "nonSteam"); +}); + +test("unconfigured Steam target has no Now Playing controls", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), null), null); +}); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 215e3f7..1d2f762 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -28,7 +28,7 @@ test("inserts one wrapper immediately before an existing command macro", () => { }); }); -test("normalizes blank and argument-only fields while refusing ambiguous launchers", () => { +test("normalizes blank, malformed, and argument-only launch fields", () => { assert.deepEqual(installWrapperLaunchOption("", wrapper), { options: `${wrapper} %command%`, commandTokenAdded: true, @@ -37,15 +37,25 @@ test("normalizes blank and argument-only fields while refusing ambiguous launche options: `FOO=bar ${wrapper} %command% --windowed`, commandTokenAdded: true, }); - assert.deepEqual(installWrapperLaunchOption('FOO=bar "/home/deck/game.AppImage"', wrapper, true), { - options: 'FOO=bar ~/.lsfg %command% "/home/deck/game.AppImage"', + assert.deepEqual(installWrapperLaunchOption("gamemoderun --windowed", wrapper), { + options: `${wrapper} %command% gamemoderun --windowed`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption('"%command%"', wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} %command`, wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} --windowed`, wrapper), { + options: `${wrapper} %command% --windowed`, commandTokenAdded: true, }); - assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/); - assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); }); -test("preserves assignments, quoting, suffixes, and unrelated values", () => { +test("preserves assignments quoting suffixes and released wrapper cleanup", () => { const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"'; assert.equal( installWrapperLaunchOption(options, wrapper).options, @@ -53,50 +63,40 @@ test("preserves assignments, quoting, suffixes, and unrelated values", () => { ); assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`); assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag"); -}); - -test("cleans current, legacy, and bare Mako wrappers without touching suffix arguments", () => { for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) { assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`); } - assert.equal(cleanupLegacyWrapper(`FOO=bar ${wrapper} %command%`), "FOO=bar %command%"); assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true); assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false); - assert.equal(removeWrapperLaunchOption(`FOO=bar ${wrapper} %command% --arg`, wrapper), "FOO=bar %command% --arg"); }); -test("removes only old plugin assignments and preserves DXVK settings", () => { +test("removes only managed assignments and preserves unrelated values", () => { assert.equal( cleanupPluginAssignments( 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%', ), 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%', ); - assert.equal( - cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), - "%command%", - ); + assert.equal(cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), "%command%"); assert.equal( cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"), "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%", ); }); -test("reads the matching app-details field and installs/removes Steam integration", async () => { +test("uses launch options for Steam and non-Steam shortcuts without a Target API", async () => { const previousWindow = (globalThis as Record<string, unknown>).window; const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; let appOptions = "FOO=bar %command%"; let shortcutOptions = "--windowed"; - let shortcutTarget = "/usr/bin/example-game"; const appWrites: string[] = []; const shortcutWrites: string[] = []; - const targetWrites: string[] = []; const unregisters: number[] = []; const apps = { RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { callback(appId === 42 ? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" } - : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); + : { strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); return { unregister: () => unregisters.push(appId) }; }, SetAppLaunchOptions(appId: number, options: string) { @@ -109,11 +109,6 @@ test("reads the matching app-details field and installs/removes Steam integratio shortcutWrites.push(options); shortcutOptions = options; }, - SetShortcutExe(appId: number, executable: string) { - assert.equal(appId, 43); - targetWrites.push(executable); - shortcutTarget = executable; - }, }; (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; (globalThis as Record<string, unknown>).SteamClient = { Apps: apps }; @@ -124,18 +119,15 @@ test("reads the matching app-details field and installs/removes Steam integratio assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " ")); assert.equal(installed.commandTokenAdded, false); assert.equal(appWrites.length, 1); - assert.equal(shortcutWrites.length, 0); - const shortcut = await installWrapperIntegration(43, true, wrapper, false, "flatpak"); - assert.equal(shortcut.originalExecutable, "/usr/bin/example-game"); - assert.equal(shortcut.snapshot.target, wrapper); - assert.deepEqual(targetWrites, [wrapper]); - const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, "flatpak"); - assert.equal(restored.target, "/usr/bin/example-game"); - assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]); - assert.equal(shortcutWrites.length, 0); + const shortcut = await installWrapperIntegration(43, true, wrapper); + assert.equal(shortcut.snapshot.options, `~/.lsfg %command% --windowed`); + assert.deepEqual(shortcutWrites, [`~/.lsfg %command% --windowed`]); + + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.commandTokenAdded); + assert.equal(restored.options, "--windowed"); - const cleaned = await removeWrapperIntegration(42, false, wrapper, undefined, installed.commandTokenAdded); + const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded); assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " ")); assert.ok(unregisters.includes(42)); assert.ok(unregisters.includes(43)); @@ -147,46 +139,46 @@ test("reads the matching app-details field and installs/removes Steam integratio } }); -test("uses shortcut launch options for a host shortcut without changing its Target", async () => { +test("AppImage EmuDeck and direct Flatpak shortcuts all stay launch-option based", async () => { const previousWindow = (globalThis as Record<string, unknown>).window; const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; - const originalOptions = 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"'; - let shortcutOptions = originalOptions; - let shortcutTarget = "env"; - const shortcutWrites: string[] = []; - const targetWrites: string[] = []; - const apps = { - RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions }); - return { unregister() {} }; + const cases = [ + { + options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"', + expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"', }, - SetShortcutLaunchOptions(_appId: number, options: string) { - shortcutWrites.push(options); - shortcutOptions = options; + { + options: "", + expected: "~/.lsfg %command%", }, - SetShortcutExe(_appId: number, executable: string) { - targetWrites.push(executable); - shortcutTarget = executable; + { + options: "run org.example.Game", + expected: "~/.lsfg %command% run org.example.Game", }, - }; + ]; (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; - (globalThis as Record<string, unknown>).SteamClient = { Apps: apps }; try { - const installed = await installWrapperIntegration(44, true, wrapper, false, "host"); - assert.equal(installed.originalExecutable, undefined); - assert.equal(installed.snapshot.target, "env"); - assert.equal(installed.snapshot.options, 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"'); - assert.deepEqual(targetWrites, []); - assert.deepEqual(shortcutWrites, [installed.snapshot.options]); - - const secondInstall = await installWrapperIntegration(44, true, wrapper, false, "host"); - assert.equal(secondInstall.snapshot.options, installed.snapshot.options); - assert.deepEqual(shortcutWrites, [installed.snapshot.options]); - - const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, "host"); - assert.equal(restored.target, "env"); - assert.equal(restored.options, originalOptions); - assert.deepEqual(targetWrites, []); + for (const [index, item] of cases.entries()) { + let shortcutOptions = item.options; + const shortcutWrites: string[] = []; + (globalThis as Record<string, unknown>).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutLaunchOptions: shortcutOptions }); + return { unregister() {} }; + }, + SetShortcutLaunchOptions(_appId: number, options: string) { + shortcutWrites.push(options); + shortcutOptions = options; + }, + }, + }; + const installed = await installWrapperIntegration(100 + index, true, wrapper); + assert.equal(installed.snapshot.options, item.expected); + assert.deepEqual(shortcutWrites, [item.expected]); + const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded); + assert.equal(restored.options, item.options); + } } finally { if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; else (globalThis as Record<string, unknown>).window = previousWindow; @@ -195,64 +187,29 @@ test("uses shortcut launch options for a host shortcut without changing its Targ } }); -test("fails closed when shortcut Target ownership or setters are unavailable", async () => { +test("launch option write failure rolls back the original value", async () => { const previousWindow = (globalThis as Record<string, unknown>).window; const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + let appOptions = "FOO=bar %command%"; + const writes: string[] = []; (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; (globalThis as Record<string, unknown>).SteamClient = { Apps: { RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { - callback({ strShortcutExe: "/usr/bin/other", strShortcutLaunchOptions: "" }); + callback({ strLaunchOptions: appOptions }); return { unregister() {} }; }, + SetAppLaunchOptions(_appId: number, options: string) { + writes.push(options); + appOptions = options; + if (options.includes(wrapper)) throw new Error("simulated launch option failure"); + }, }, }; try { - await assert.rejects(installWrapperIntegration(99, true, wrapper, false, "flatpak"), /Target API is unavailable/); - await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, "flatpak"), /Target changed externally/); - } finally { - if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; - else (globalThis as Record<string, unknown>).window = previousWindow; - if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; - else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; - } -}); - -test("restores launch options and shortcut Target when a setter fails after changing them", async () => { - const previousWindow = (globalThis as Record<string, unknown>).window; - const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; - let appOptions = "FOO=bar %command%"; - let shortcutTarget = "/usr/bin/original"; - const appWrites: string[] = []; - const targetWrites: string[] = []; - const apps = { - RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { - callback(appId === 42 - ? { strLaunchOptions: appOptions } - : { strShortcutExe: shortcutTarget, strShortcutLaunchOptions: "" }); - return { unregister() {} }; - }, - SetAppLaunchOptions(_appId: number, options: string) { - appWrites.push(options); - appOptions = options; - if (options.includes(wrapper)) throw new Error("simulated launch-option write failure"); - }, - SetShortcutExe(_appId: number, executable: string) { - targetWrites.push(executable); - shortcutTarget = executable; - if (executable === wrapper) throw new Error("simulated Target write failure"); - }, - }; - (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; - (globalThis as Record<string, unknown>).SteamClient = { Apps: apps }; - try { - await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch-option write failure/); + await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch option failure/); assert.equal(appOptions, "FOO=bar %command%"); - assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); - - await assert.rejects(installWrapperIntegration(43, true, wrapper, false, "flatpak"), /simulated Target write failure/); - assert.equal(shortcutTarget, "/usr/bin/original"); - assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]); + assert.deepEqual(writes, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); } finally { if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; else (globalThis as Record<string, unknown>).window = previousWindow; diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py new file mode 100644 index 0000000..898a9e7 --- /dev/null +++ b/tests/test_configuration_profiles.py @@ -0,0 +1,114 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.config_schema import ConfigurationManager +from lsfg_vk.configuration import ConfigurationService + + +class ConfigurationProfileTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.runtime = Mock() + self.service = ConfigurationService(runtime_service=self.runtime) + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + + def tearDown(self): + self.tempdir.cleanup() + + def test_selector_only_profile_survives_round_trip(self): + content = """version = 2 + +[global] +allow_fp16 = true + +[[profile]] +name = "flatpak:org.example.Game" +pacing_mode = "vsync" +multiplier = 3 +flow_scale = 0.8 +performance_mode = false +override_present_mode = true +preserve_swapchain_image_count = false +""" + parsed = ConfigurationManager.parse_toml_content_multi_profile(content) + self.assertIn("flatpak:org.example.Game", parsed["profiles"]) + self.assertEqual(parsed["profiles"]["flatpak:org.example.Game"]["active_in"], []) + rendered = ConfigurationManager.generate_toml_content_multi_profile(parsed) + reparsed = ConfigurationManager.parse_toml_content_multi_profile(rendered) + self.assertEqual(reparsed["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_game_reset_all_preserves_flatpak_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_game_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_scoped_game_reset_is_one_write_and_preserves_other_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_game_config("456", "Non-Steam Game", {"multiplier": 3}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 4}) + + with patch.object(self.service, "_save_profile_data", wraps=self.service._save_profile_data) as save: + result = self.service.reset_game_configs(["123"]) + + data = self.service._get_profile_data() + self.assertTrue(result["success"]) + self.assertEqual(save.call_count, 1) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("Non-Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + + def test_flatpak_reset_all_preserves_steam_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_flatpak_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertIn("Steam Game", data["profiles"]) + self.assertNotIn("flatpak:org.example.Game", data["profiles"]) + + def test_global_config_update_does_not_change_profile_values(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + + result = self.service.update_global_config({"no_fp16": True}) + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertTrue(result["global_config"]["no_fp16"]) + self.assertTrue(data["global_config"]["no_fp16"]) + self.assertEqual(data["profiles"]["Steam Game"]["multiplier"], 2) + + def test_profile_update_cannot_overwrite_global_fp16_setting(self): + self.service.update_global_config({"no_fp16": True}) + + self.service.update_game_config("123", "Steam Game", {"multiplier": 3, "no_fp16": False}) + + data = self.service._get_profile_data() + self.assertTrue(data["global_config"]["no_fp16"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py new file mode 100644 index 0000000..2b9933b --- /dev/null +++ b/tests/test_flatpak_profile_service.py @@ -0,0 +1,233 @@ +import hashlib +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.configuration import ConfigurationService +from lsfg_vk.flatpak_service import FlatpakService +from lsfg_vk.flatpak_profile_service import FlatpakProfileService + + +class FakeFlatpakService: + def __init__(self, home: Path): + self.user_home = home + self.config_dir = home / ".config/lsfg-vk" + self.config_file_path = self.config_dir / "conf.toml" + self.backup_dir = self.config_dir / "flatpak-overrides" + self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}} + self.commands = [] + self.running = "" + self.start_times = {} + + def _read_state(self): + return self.state + + def _write_state(self, state): + self.state = state + + def _override_path(self, app_id): + return self.user_home / ".local/share/flatpak/overrides" / app_id + + def _backup_path(self, app_id): + return self.backup_dir / f"{app_id}.ini" + + @staticmethod + def _sha256(content): + return hashlib.sha256(content).hexdigest() + + def _snapshot_override(self, app_id): + path = self._override_path(app_id) + if not path.exists(): + return False, b"" + return True, path.read_bytes() + + def _process_start_time(self, pid): + return self.start_times.get(pid) + + @staticmethod + def _write_file(path, content, mode=0o644): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + path.chmod(mode) + + def prepare_app(self, app_id): + apps = self.state["prepared_apps"] + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + if existed: + self._write_file(self._backup_path(app_id), original.decode("utf-8")) + apps[app_id] = {"override_existed": existed, "managed_sha256": ""} + entry = apps[app_id] + baseline = "" + if entry["override_existed"]: + baseline = self._backup_path(app_id).read_text(encoding="utf-8") + managed = baseline + "\n[Context]\nfilesystems=/config:ro;/dll:ro;\n[Environment]\nLSFGVK_CONFIG=/config/conf.toml\nLSFGVK_FLATPAK=1\n" + self._write_file(self._override_path(app_id), managed) + entry["managed_sha256"] = self._sha256(managed.encode()) + return {"success": True, "owned": True, "prepared": True, "runtime": "org.freedesktop.Platform/x86_64/24.08", "runtime_branch": "24.08"} + + def remove_app_override(self, app_id): + entry = self.state["prepared_apps"].get(app_id) + if entry is None: + return {"success": True, "prepared": False, "owned": False} + existed, current = self._snapshot_override(app_id) + current_hash = self._sha256(current) if existed else self._sha256(b"") + if current_hash != entry["managed_sha256"]: + return {"success": False, "error": "Flatpak override changed after preparation"} + path = self._override_path(app_id) + backup = self._backup_path(app_id) + if entry["override_existed"]: + self._write_file(path, backup.read_text(encoding="utf-8")) + else: + path.unlink(missing_ok=True) + backup.unlink(missing_ok=True) + self.state["prepared_apps"].pop(app_id) + return {"success": True, "prepared": False, "owned": False} + + def get_flatpak_apps(self): + app_id = "org.example.Game" + return { + "success": True, + "apps": [{ + "app_id": app_id, + "app_name": "Example Game", + "runtime": "org.freedesktop.Platform/x86_64/24.08", + "runtime_branch": "24.08", + "runtime_ready": True, + "prepared": app_id in self.state["prepared_apps"], + "owned": app_id in self.state["prepared_apps"], + "error": None, + }], + } + + def _run_flatpak_command(self, args, **_kwargs): + self.commands.append(args) + if args[:3] == ["override", "--user", "--show"]: + path = self._override_path(args[3]) + return types.SimpleNamespace(returncode=0, stdout=path.read_text(encoding="utf-8") if path.exists() else "", stderr="") + if args[0] == "override": + app_id = args[-1] + path = self._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + env = [item.removeprefix("--env=") for item in args if item.startswith("--env=")] + unset = [item.removeprefix("--unset-env=") for item in args if item.startswith("--unset-env=")] + if unset: + content += "\n[Context]\nunset-environment=" + ";".join(unset) + ";\n" + if env: + content += "\n[Environment]\n" + "\n".join(env) + "\n" + self._write_file(path, content) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + if args[0] == "ps": + return types.SimpleNamespace(returncode=0, stdout=self.running, stderr="") + raise AssertionError(args) + + +class FlatpakProfileServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.flatpak = FakeFlatpakService(self.home) + self.runtime = Mock() + self.configuration = ConfigurationService(runtime_service=self.runtime) + self.configuration.user_home = self.home + self.configuration.config_dir = self.flatpak.config_dir + self.configuration.config_file_path = self.flatpak.config_file_path + self.service = FlatpakProfileService(self.flatpak, self.configuration) + self.app_id = "org.example.Game" + + def tearDown(self): + self.tempdir.cleanup() + + def test_enable_creates_selector_profile_and_default_workarounds(self): + result = self.service.enable_app(self.app_id) + config = self.configuration.get_flatpak_config(self.app_id) + content = self.flatpak._override_path(self.app_id).read_text(encoding="utf-8") + + self.assertTrue(result["success"]) + self.assertTrue(config["exists"]) + self.assertEqual(config["profile"], "flatpak:org.example.Game") + self.assertEqual(config["config"]["active_in"], []) + self.assertIn("LSFGVK_PROFILE=flatpak:org.example.Game", content) + self.assertIn("ENABLE_GAMESCOPE_WSI=0", content) + self.assertIn("DXVK_HDR=0", content) + + def test_workaround_update_rebuilds_from_original_override(self): + baseline = "[Environment]\nDXVK_CONFIG=dxgi.syncInterval = 0\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + state = self.service.default_state() + state.update({"dxvkFrameRate": 30, "disableHdr": False, "enableZink": True}) + result = self.service.set_workaround_state(self.app_id, state) + command = next( + args for args in reversed(self.flatpak.commands) + if args[0] == "override" and any(item.startswith("--env=LSFGVK_PROFILE=") for item in args) + ) + + self.assertTrue(result["success"]) + self.assertIn("--env=DXVK_CONFIG=dxgi.syncInterval = 0; dxvk.maxFrameRate = 30", command) + self.assertNotIn("--env=DXVK_HDR=0", command) + self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command) + + def test_config_update_returns_without_relisting_flatpaks(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.service.get_app = Mock(side_effect=AssertionError("config updates must not relist Flatpaks")) + + result = self.service.update_config(self.app_id, {"multiplier": 4}) + + self.assertTrue(result["success"]) + self.assertEqual(result["app_id"], self.app_id) + self.assertEqual(result["config"]["multiplier"], 4) + self.service.get_app.assert_not_called() + + def test_remove_restores_exact_original_override_and_profile(self): + baseline = "[Environment]\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + removed = self.service.remove_app(self.app_id) + + self.assertTrue(removed["success"]) + self.assertEqual(self.flatpak._override_path(self.app_id).read_text(encoding="utf-8"), baseline) + self.assertFalse(self.configuration.get_flatpak_config(self.app_id)["exists"]) + + def test_external_override_change_fails_closed(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + path = self.flatpak._override_path(self.app_id) + path.write_text(path.read_text(encoding="utf-8") + "EXTERNAL=yes\n", encoding="utf-8") + + result = self.service.set_workaround_state(self.app_id, self.service.default_state()) + + self.assertFalse(result["success"]) + self.assertIn("changed after preparation", result["error"]) + + def test_running_detection_uses_owned_selector_state(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n" + self.flatpak.start_times["1234"] = 200 + + result = self.service.get_running_apps() + + self.assertTrue(result["success"]) + self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234", "start_time": 200}]) + + def test_process_start_time_parser_handles_parentheses_in_command_name(self): + fields = ["S"] + ["0"] * 18 + ["4242"] + stat = "1234 (retro)arch) " + " ".join(fields) + + self.assertEqual(FlatpakService._parse_process_start_time(stat), 4242) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index d5baf61..17d0016 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -25,16 +25,17 @@ class FlatpakServiceTests(unittest.TestCase): self.service.user_home = self.home self.service.config_dir = self.home / ".config/lsfg-vk" self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) self.service.check_flatpak_available = Mock(return_value=True) self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command) self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08" self.runtime_metadata = "" self.user_branches = set() self.system_branches = set() - self.install_branch = "24.08" - self.bundle = self.home / "lsfg-vk-24.08.flatpak" - self.bundle.write_bytes(b"bundle") - self.service._bundled_extension_path = Mock(return_value=self.bundle) + self.user_extension_origin = "flathub" + self.apps = {"com.example.Game": "Example Game"} + self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling" + self.service._dll_directory = Mock(return_value=self.dll_dir) def tearDown(self): self.tempdir.cleanup() @@ -47,252 +48,272 @@ class FlatpakServiceTests(unittest.TestCase): def _extension_line(branch): return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + @staticmethod + def _parse_override(content): + section = None + filesystems = [] + unset_environment = [] + environment = {} + other = [] + for raw in content.splitlines(): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + key, separator, value = line.partition("=") + if not separator: + continue + if section == "Context" and key == "filesystems": + filesystems.extend(item for item in value.split(";") if item) + elif section == "Context" and key == "unset-environment": + unset_environment.extend(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + else: + other.append((section, key, value)) + return filesystems, unset_environment, environment, other + + @staticmethod + def _serialize_override(filesystems, unset_environment, environment): + lines = ["[Context]"] + if filesystems: + lines.append("filesystems=" + ";".join(filesystems) + ";") + if unset_environment: + lines.append("unset-environment=" + ";".join(unset_environment) + ";") + if environment: + lines.append("") + lines.append("[Environment]") + lines.extend(f"{key}={value}" for key, value in environment.items()) + return "\n".join(lines) + "\n" + + def _apply_override(self, args): + app_id = args[-1] + path = self.service._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + filesystems, unset_environment, environment, _ = self._parse_override(content) + for arg in args[2:-1]: + if arg.startswith("--filesystem="): + value = arg.split("=", 1)[1] + if value not in filesystems: + filesystems.append(value) + elif arg.startswith("--env="): + key, value = arg.split("=", 1)[1].split("=", 1) + environment[key] = value + if key in unset_environment: + unset_environment.remove(key) + elif arg.startswith("--unset-env="): + key = arg.split("=", 1)[1] + environment.pop(key, None) + if key not in unset_environment: + unset_environment.append(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override(filesystems, unset_environment, environment), + encoding="utf-8", + ) + return self._result() + def _run_flatpak_command(self, args, **_kwargs): - if args[0] == "info" and args[1] == "--show-runtime": + if args[:2] == ["info", "--show-runtime"]: return self._result(self.runtime_ref + "\n") - if args[0] == "info" and args[1] == "--show-metadata": + if args[:2] == ["info", "--show-metadata"]: return self._result(self.runtime_metadata) + if args[:3] == ["info", "--user", "--show-origin"]: + return self._result(self.user_extension_origin) + if args[:2] == ["list", "--app"]: + return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items())) if args[0] == "list": branches = self.user_branches if "--user" in args else self.system_branches return self._result("".join(self._extension_line(branch) for branch in sorted(branches))) if args[0] == "install": - self.user_branches.add(self.install_branch) + self.user_branches.add(self.runtime_ref.rsplit("/", 1)[-1]) return self._result() if args[0] == "uninstall": self.user_branches.discard(args[-1].rsplit("/", 1)[-1]) return self._result() + if args[:3] == ["override", "--user", "--show"]: + path = self.service._override_path(args[-1]) + return self._result(path.read_text(encoding="utf-8") if path.exists() else "") + if args[:2] == ["override", "--user"]: + return self._apply_override(args) raise AssertionError(f"Unexpected Flatpak command: {args}") - def test_runtime_branch_mapping_is_strict_and_branch_specific(self): - self.assertEqual( - FlatpakService.runtime_branch_from_ref( - "org.freedesktop.Platform/x86_64/24.08" - ), - "24.08", - ) - self.assertEqual( - FlatpakService.runtime_branch_from_ref( - "org.freedesktop.Platform//25.08" - ), - "25.08", - ) - with self.assertRaises(ValueError): - FlatpakService.runtime_branch_from_ref("org.gnome.Sdk/x86_64/46") - with self.assertRaises(ValueError): - FlatpakService.runtime_branch_from_ref( - "org.freedesktop.Platform/x86_64/26.08" - ) - - def test_runtime_branch_mapping_reads_documented_gl_metadata(self): - metadata = """ -[Extension org.freedesktop.Platform.GL] -versions=25.08;25.08-extra;1.4 -version=1.4 -""" - self.assertEqual(FlatpakService.runtime_branch_from_metadata(metadata), "25.08") - with self.assertRaises(ValueError): - FlatpakService.runtime_branch_from_metadata( - "[Extension org.freedesktop.Platform.GL]\nversions=26.08;26.08-extra;1.4\n" - ) - - def test_resolve_reads_required_runtime_instead_of_any_installed_branch(self): - self.user_branches = {"23.08"} - - response = self.service.resolve_app_support("com.example.Game") + def test_resolves_freedesktop_and_derived_runtimes(self): + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "24.08") - self.assertTrue(response["success"]) - self.assertEqual(response["runtime_branch"], "24.08") - self.assertEqual(response["support_status"], "needs-runtime") - self.assertFalse(response["extension_installed"]) - self.assertEqual( - self.service._run_flatpak_command.call_args_list[0].args[0], - ["info", "--show-runtime", "com.example.Game"], - ) - self.assertEqual( - self.service._run_flatpak_command.call_args_list[1].args[0], - ["list", "--user", "--runtime", "--columns=application,arch,branch"], - ) - self.assertEqual( - self.service._run_flatpak_command.call_args_list[2].args[0], - ["list", "--system", "--runtime", "--columns=application,arch,branch"], - ) + self.runtime_ref = "org.kde.Platform/x86_64/6.10" + self.runtime_metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "25.08") - def test_resolve_maps_kde_and_gnome_runtimes_from_gl_metadata(self): - metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" - for runtime in ("org.kde.Platform/x86_64/6.10", "org.gnome.Platform/x86_64/49"): - with self.subTest(runtime=runtime): - self.service._run_flatpak_command.reset_mock() - self.runtime_ref = runtime - self.runtime_metadata = metadata - response = self.service.resolve_app_support("com.example.Game") - self.assertEqual(response["runtime_branch"], "25.08") - self.assertEqual(response["support_status"], "needs-runtime") - self.assertEqual( - self.service._run_flatpak_command.call_args_list[1].args[0], - ["info", "--show-metadata", runtime], - ) - - def test_system_extension_is_ready_without_installing_a_user_copy(self): - self.system_branches = {"24.08"} + def test_clean_env_targets_deck_user_session_bus(self): + env = self.service._clean_env() + user_id = self.home.stat().st_uid + self.assertEqual(env["XDG_RUNTIME_DIR"], f"/run/user/{user_id}") + self.assertEqual(env["DBUS_SESSION_BUS_ADDRESS"], f"unix:path=/run/user/{user_id}/bus") - response = self.service.ensure_app_support("com.example.Game") + def test_prepare_app_installs_runtime_and_persists_narrow_override(self): + response = self.service.prepare_app("com.example.Game") self.assertTrue(response["success"]) - self.assertEqual(response["support_status"], "ready") + self.assertTrue(response["prepared"]) + self.assertTrue(response["owned"]) + self.assertEqual(response["runtime_branch"], "24.08") + self.assertEqual(self.user_branches, {"24.08"}) + install_calls = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ] self.assertEqual( - [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["info", "list", "list"], + install_calls, + [[ + "install", + "--user", + "--noninteractive", + "--or-update", + "flathub", + "org.freedesktop.Platform.VulkanLayer.lsfgvk//24.08", + ]], ) - self.assertFalse(any(call.args[0][0] == "install" for call in self.service._run_flatpak_command.call_args_list)) + status = self.service._app_override_status("com.example.Game") + self.assertTrue(status["prepared"]) + content = self.service._override_path("com.example.Game").read_text(encoding="utf-8") + self.assertIn(str(self.service.config_dir) + ":ro", content) + self.assertIn(str(self.dll_dir) + ":ro", content) + self.assertIn("LSFGVK_CONFIG=" + str(self.service.config_file_path), content) + self.assertIn("LSFGVK_FLATPAK=1", content) + self.assertNotIn("ENABLE_GAMESCOPE_WSI", content) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], ["24.08"]) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_prepare_is_idempotent(self): + first = self.service.prepare_app("com.example.Game") + first_content = self.service._override_path("com.example.Game").read_bytes() + second = self.service.prepare_app("com.example.Game") + + self.assertTrue(first["success"]) + self.assertTrue(second["success"]) + self.assertEqual(first_content, self.service._override_path("com.example.Game").read_bytes()) + install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"] + self.assertEqual(len(install_calls), 1) + + def test_replaces_extension_from_another_remote(self): + self.user_branches = {"24.08"} + self.user_extension_origin = "lsfgvk-origin" - def test_install_records_only_a_new_user_owned_branch(self): response = self.service.install_extension("24.08") self.assertTrue(response["success"]) - self.assertTrue(response["enabled"]) - self.assertTrue(response["installed"]) - install_args = self.service._run_flatpak_command.call_args_list[2].args[0] - self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"]) - self.assertEqual( - json.loads(self.service.ownership_path.read_text(encoding="utf-8")), - {"version": 1, "plugin_owned_branches": ["24.08"]}, + commands = [call.args[0] for call in self.service._run_flatpak_command.call_args_list] + self.assertIn( + [ + "uninstall", + "--user", + "--noninteractive", + "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08", + ], + commands, ) + self.assertEqual(self.user_branches, {"24.08"}) - def test_preexisting_branch_is_not_claimed_or_removed(self): - self.user_branches = {"24.08"} + def test_preinstalled_runtime_is_not_owned(self): + self.system_branches = {"24.08"} + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], []) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_external_preparation_is_preserved(self): + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override( + [str(self.service.config_dir) + ":ro", str(self.dll_dir) + ":ro"], + ["DISABLE_LSFGVK", "DISABLE_LSFG"], + { + "LSFGVK_CONFIG": str(self.service.config_file_path), + "LSFGVK_FLATPAK": "1", + }, + ), + encoding="utf-8", + ) + self.system_branches = {"24.08"} - install_response = self.service.install_extension("24.08") - cleanup_response = self.service.remove_plugin_owned_extensions() + response = self.service.prepare_app("com.example.Game") - self.assertTrue(install_response["success"]) - self.assertTrue(install_response["enabled"]) - self.assertTrue(install_response["installed"]) + self.assertTrue(response["success"]) + self.assertTrue(response["prepared"]) + self.assertFalse(response["owned"]) self.assertFalse(self.service.ownership_path.exists()) - self.assertTrue(cleanup_response["success"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 2) - def test_extension_toggle_preserves_preexisting_branch(self): - self.user_branches = {"24.08"} + def test_remove_restores_exact_previous_override(self): + self.system_branches = {"24.08"} + original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n" + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(original, encoding="utf-8") + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) - enable_response = self.service.set_extension_enabled("24.08", True) - disable_response = self.service.set_extension_enabled("24.08", False) + response = self.service.remove_app_override("com.example.Game") - self.assertTrue(enable_response["success"]) - self.assertTrue(enable_response["enabled"]) - self.assertTrue(enable_response["installed"]) - self.assertTrue(disable_response["success"]) - self.assertTrue(disable_response["enabled"]) - self.assertTrue(disable_response["installed"]) - self.assertFalse(disable_response["removed"]) - self.assertEqual(self.user_branches, {"24.08"}) - self.assertEqual( - [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["list", "list", "list", "list"], - ) + self.assertTrue(response["success"]) + self.assertEqual(path.read_text(encoding="utf-8"), original) + self.assertFalse(self.service.ownership_path.exists()) - def test_extension_toggle_removes_owned_user_branch_but_preserves_system_branch(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.write_text( - json.dumps({"version": 1, "plugin_owned_branches": ["24.08"]}), - encoding="utf-8", - ) - self.user_branches = {"24.08"} + def test_remove_deletes_override_created_by_plugin(self): self.system_branches = {"24.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + self.assertTrue(path.exists()) - disable_response = self.service.set_extension_enabled("24.08", False) - repeat_response = self.service.set_extension_enabled("24.08", False) + response = self.service.remove_app_override("com.example.Game") - self.assertTrue(disable_response["success"]) - self.assertTrue(disable_response["enabled"]) - self.assertTrue(disable_response["removed"]) - self.assertTrue(repeat_response["success"]) - self.assertTrue(repeat_response["enabled"]) - self.assertTrue(repeat_response["installed"]) - self.assertEqual(self.user_branches, set()) - self.assertEqual(self.system_branches, {"24.08"}) + self.assertTrue(response["success"]) + self.assertFalse(path.exists()) self.assertFalse(self.service.ownership_path.exists()) - uninstall_commands = [ - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "uninstall" - ] - self.assertEqual(len(uninstall_commands), 1) - def test_corrupt_ownership_metadata_fails_closed(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.write_text("{not-json", encoding="utf-8") + def test_remove_fails_closed_after_external_change(self): + self.system_branches = {"24.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + with path.open("a", encoding="utf-8") as handle: + handle.write("EXTERNAL=1\n") - response = self.service.remove_plugin_owned_extensions() + response = self.service.remove_app_override("com.example.Game") self.assertFalse(response["success"]) - self.assertTrue(response["ownership_uncertain"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 0) - - def test_dangling_ownership_symlink_fails_closed(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.symlink_to(self.home / "missing-metadata") + self.assertIn("changed after preparation", response["error"]) + self.assertTrue(path.exists()) + self.assertTrue(self.service.ownership_path.exists()) - response = self.service.remove_plugin_owned_extensions() - - self.assertFalse(response["success"]) - self.assertTrue(response["ownership_uncertain"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 0) + def test_full_cleanup_removes_only_owned_state(self): + self.system_branches = {"23.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + self.assertEqual(self.user_branches, {"24.08"}) - def test_ensure_app_support_installs_only_the_app_runtime_branch(self): - response = self.service.ensure_app_support("com.example.Game") + response = self.service.remove_plugin_owned_environment() self.assertTrue(response["success"]) - self.assertEqual(response["support_status"], "ready") - self.assertEqual(response["runtime_branch"], "24.08") - install_args = next( - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "install" - ) - self.assertEqual(install_args[0], "install") - self.assertIn("--user", install_args) - self.assertNotIn("23.08", install_args) - self.assertEqual( - json.loads(self.service.ownership_path.read_text(encoding="utf-8")), - {"version": 1, "plugin_owned_branches": ["24.08"]}, - ) - - def test_two_shortcuts_using_one_flatpak_share_one_extension_branch(self): - first = self.service.ensure_app_support("net.pcsx2.PCSX2") - second = self.service.ensure_app_support("net.pcsx2.PCSX2.Dev") - - self.assertEqual(first["support_status"], "ready") - self.assertEqual(second["support_status"], "ready") - install_commands = [ - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "install" - ] - self.assertEqual(len(install_commands), 1) - self.assertEqual( - json.loads(self.service.ownership_path.read_text(encoding="utf-8")), - {"version": 1, "plugin_owned_branches": ["24.08"]}, - ) + self.assertEqual(response["removed_apps"], ["com.example.Game"]) + self.assertEqual(response["removed_branches"], ["24.08"]) + self.assertEqual(self.user_branches, set()) + self.assertEqual(self.system_branches, {"23.08"}) + self.assertFalse(self.service.ownership_path.exists()) - def test_cleanup_removes_all_owned_branches_without_reusing_stale_metadata(self): - self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) - self.service.ownership_path.write_text( - json.dumps({"version": 1, "plugin_owned_branches": ["23.08", "24.08"]}), - encoding="utf-8", - ) - self.user_branches = {"23.08", "24.08"} + def test_corrupt_ownership_metadata_fails_closed(self): + self.service.ownership_path.write_text("{not-json", encoding="utf-8") - response = self.service.remove_plugin_owned_extensions() + response = self.service.remove_plugin_owned_environment() - self.assertTrue(response["success"]) - self.assertEqual(response["removed_branches"], ["23.08", "24.08"]) - self.assertFalse(self.service.ownership_path.exists()) - uninstall_commands = [ - call.args[0] - for call in self.service._run_flatpak_command.call_args_list - if call.args[0][0] == "uninstall" - ] - self.assertEqual(len(uninstall_commands), 2) + self.assertFalse(response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 0) if __name__ == "__main__": diff --git a/tests/test_installation_cleanup.py b/tests/test_installation_cleanup.py new file mode 100644 index 0000000..3336505 --- /dev/null +++ b/tests/test_installation_cleanup.py @@ -0,0 +1,63 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.base_service import BaseService +from lsfg_vk.installation import InstallationService + + +class InstallationCleanupTests(unittest.TestCase): + def test_uninstall_removes_legacy_files_and_prunes_only_empty_directories(self): + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" / "deck" + service = InstallationService.__new__(InstallationService) + BaseService.__init__(service) + service.log = Mock() + service.user_home = home + service.local_bin_dir = home / ".local/bin" + service.local_lib_dir = home / ".local/lib" + service.local_share_dir = home / ".local/share/vulkan/implicit_layer.d" + service.config_dir = home / ".config/lsfg-vk" + service.config_file_path = service.config_dir / "conf.toml" + service.legacy_script_path = home / "lsfg" + service.lib_file = service.local_lib_dir / "liblsfg-vk-layer.so" + service.lib_x86_file = service.local_lib_dir / "liblsfg-vk-layer.x86.so" + service.json_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.json" + service.json_x86_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.x86.json" + service.cli_file = service.local_bin_dir / "lsfg-vk-cli" + service.legacy_lib_file = service.local_lib_dir / "liblsfg-vk.so" + service.legacy_json_file = service.local_share_dir / "VkLayer_LS_frame_generation.json" + + for path in ( + service.lib_file, + service.config_file_path, + service.legacy_script_path, + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("owned", encoding="utf-8") + unrelated = home / ".local/bin/keep-me" + unrelated.parent.mkdir(parents=True, exist_ok=True) + unrelated.write_text("user file", encoding="utf-8") + + result = service.uninstall() + + self.assertTrue(result["success"]) + self.assertFalse(service.lib_file.exists()) + self.assertFalse(service.config_file_path.exists()) + self.assertFalse(service.legacy_script_path.exists()) + self.assertTrue(unrelated.exists()) + self.assertTrue(service.local_bin_dir.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py index a392f85..fc51470 100644 --- a/tests/test_plugin_migration.py +++ b/tests/test_plugin_migration.py @@ -6,7 +6,7 @@ from unittest.mock import Mock class PluginMigrationTests(unittest.TestCase): - def test_migration_only_runs_decky_path_migrations(self): + def _load_plugin(self): decky = types.SimpleNamespace( DECKY_HOME="/decky", DECKY_USER_HOME="/home/deck", @@ -17,12 +17,31 @@ class PluginMigrationTests(unittest.TestCase): ) previous_decky = sys.modules.get("decky") previous_tomllib = sys.modules.get("tomllib") + previous_plugin = sys.modules.pop("lsfg_vk.plugin", None) sys.modules["decky"] = decky sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock()) - try: - sys.path.insert(0, "py_modules") - from lsfg_vk.plugin import Plugin + sys.path.insert(0, "py_modules") + from lsfg_vk.plugin import Plugin + return Plugin, decky, previous_decky, previous_tomllib, previous_plugin + + def _restore(self, previous_decky, previous_tomllib, previous_plugin): + sys.path.remove("py_modules") + if previous_decky is None: + sys.modules.pop("decky", None) + else: + sys.modules["decky"] = previous_decky + if previous_tomllib is None: + sys.modules.pop("tomllib", None) + else: + sys.modules["tomllib"] = previous_tomllib + if previous_plugin is None: + sys.modules.pop("lsfg_vk.plugin", None) + else: + sys.modules["lsfg_vk.plugin"] = previous_plugin + def test_migration_only_runs_decky_path_migrations(self): + Plugin, decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: plugin = Plugin.__new__(Plugin) plugin.installation_service = Mock() plugin.flatpak_service = Mock() @@ -33,17 +52,49 @@ class PluginMigrationTests(unittest.TestCase): decky.migrate_settings.assert_called_once() decky.migrate_runtime.assert_called_once() plugin.installation_service.install.assert_not_called() - plugin.flatpak_service.migrate_v2.assert_not_called() + plugin.flatpak_service.prepare_app.assert_not_called() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + def test_uninstall_cleans_owned_flatpak_state_and_profiles(self): + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} + plugin.configuration_service.reset_all_flatpak_configs.return_value = {"success": True} + plugin.wrapper_service.neutralize.return_value = {"success": True} + + asyncio.run(plugin._uninstall()) + + plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() + plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() + plugin.wrapper_service.neutralize.assert_called_once_with() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self): + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"} + + asyncio.run(plugin._uninstall()) + + plugin.configuration_service.reset_all_flatpak_configs.assert_not_called() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_not_called() finally: - sys.path.remove("py_modules") - if previous_decky is None: - sys.modules.pop("decky", None) - else: - sys.modules["decky"] = previous_decky - if previous_tomllib is None: - sys.modules.pop("tomllib", None) - else: - sys.modules["tomllib"] = previous_tomllib + self._restore(previous_decky, previous_tomllib, previous_plugin) if __name__ == "__main__": diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py index 9924186..75cdabf 100644 --- a/tests/test_steam_service.py +++ b/tests/test_steam_service.py @@ -11,66 +11,11 @@ sys.modules.setdefault( ) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) -from lsfg_vk.steam_service import SteamService, classify_shortcut_transport +from lsfg_vk.steam_service import SteamService -class SteamTransportTests(unittest.TestCase): - def test_only_direct_canonical_flatpak_forms_are_classified(self): - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak", - "run com.example.PCSX2 --fullscreen", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak run com.example.PCSX2", - "--fullscreen", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/bash", - "~/launch-game.sh --fullscreen", - ), - {"kind": "host"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak", - "--user run com.example.PCSX2", - ), - {"kind": "host"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/usr/bin/flatpak", - "run bash ~/launch-game.sh", - ), - {"kind": "host"}, - ) - self.assertEqual( - classify_shortcut_transport( - "~/.lsfg", - "run --branch=stable --arch=x86_64 com.example.PCSX2", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport( - "/home/deck/.lsfg", - "run com.example.PCSX2", - ), - {"kind": "flatpak", "flatpakAppId": "com.example.PCSX2"}, - ) - self.assertEqual( - classify_shortcut_transport("~/.lsfg", "--profile high"), - {"kind": "host"}, - ) - - def test_shortcut_data_preserves_transport_inputs(self): +class SteamShortcutTests(unittest.TestCase): + def test_direct_flatpak_shortcut_is_marked(self): game = SteamService._shortcut_game( { "appid": 123456, @@ -81,30 +26,51 @@ class SteamTransportTests(unittest.TestCase): } ) - self.assertEqual(game["appid"], "123456") - self.assertEqual(game["transport"], { - "kind": "flatpak", - "flatpakAppId": "net.pcsx2.PCSX2", + self.assertEqual(game, { + "appid": "123456", + "name": "PCSX2 shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, }) - self.assertEqual(game["executable"], "/usr/bin/flatpak") - self.assertEqual(game["arguments"], "run net.pcsx2.PCSX2 --fullscreen") - self.assertEqual(game["startDir"], "/home/deck/Games") - def test_wrapped_flatpak_shortcut_remains_a_flatpak_target(self): + def test_bare_flatpak_shortcut_is_marked(self): + game = SteamService._shortcut_game( + { + "appid": 654321, + "AppName": "Faugus shortcut", + "Exe": '"flatpak"', + "LaunchOptions": "run io.github.Faugus.faugus-launcher --game elliot", + } + ) + + self.assertEqual(game, { + "appid": "654321", + "name": "Faugus shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, + }) + + def test_emudeck_launcher_is_ordinary_non_steam_metadata(self): game = SteamService._shortcut_game( { "appid": 987654, - "AppName": "Wrapped Flatpak", - "Exe": "~/.lsfg", - "LaunchOptions": "run --branch=stable --arch=x86_64 com.example.Game", + "AppName": "1080 Snowboarding", + "Exe": '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', + "LaunchOptions": "", } ) - self.assertEqual(game["transport"], { - "kind": "flatpak", - "flatpakAppId": "com.example.Game", + self.assertEqual(game, { + "appid": "987654", + "name": "1080 Snowboarding", + "nonSteam": True, }) + def test_shortcut_rejects_invalid_identity(self): + self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"})) + self.assertIsNone(SteamService._shortcut_game({"appid": 1, "AppName": ""})) + self.assertIsNone(SteamService._shortcut_game("bad")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py index 5010d23..46e029b 100644 --- a/tests/test_wrapper_service.py +++ b/tests/test_wrapper_service.py @@ -1,4 +1,3 @@ -import os import subprocess import sys import tempfile @@ -24,10 +23,10 @@ class WrapperServiceTests(unittest.TestCase): self.home.mkdir(parents=True) self.service = WrapperService() self.service.user_home = self.home - self.service.local_bin_dir = self.home / ".local/bin" self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" self.service.sidecar_path = self.service.config_dir / "workarounds.json" - self.service.wrapper_path = self.service.local_bin_dir / "lsfg" + self.service.wrapper_path = self.home / ".lsfg" def tearDown(self): self.tempdir.cleanup() @@ -59,7 +58,7 @@ class WrapperServiceTests(unittest.TestCase): self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8")) self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True)) - def test_dispatch_clears_managed_values_preserves_other_environment_and_appends_config(self): + def test_dispatch_exports_appid_config_and_workarounds(self): self.service.set( "123", self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True), @@ -71,12 +70,16 @@ class WrapperServiceTests(unittest.TestCase): "DXVK_CONFIG": "dxgi.syncInterval = 0", "DXVK_FRAME_RATE": "5", "ENABLE_GAMESCOPE_WSI": "1", + "DISABLE_LSFGVK": "1", + "DISABLE_LSFG": "1", "DISABLE_VKBASALT": "0", "MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe", "MANGOHUD": "1", }, ) values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + self.assertEqual(values["SteamAppId"], "123") + self.assertEqual(values["LSFGVK_CONFIG"], str(self.service.config_file_path)) self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0") self.assertEqual(values["DXVK_HDR"], "0") self.assertEqual(values["SteamDeck"], "0") @@ -88,6 +91,20 @@ class WrapperServiceTests(unittest.TestCase): self.assertEqual(values["MANGOHUD"], "1") self.assertNotIn("DXVK_FRAME_RATE", values) self.assertNotIn("ENABLE_VKBASALT", values) + self.assertNotIn("DISABLE_LSFGVK", values) + self.assertNotIn("DISABLE_LSFG", values) + + def test_wrapper_is_transport_agnostic(self): + self.service.set("123", self._state()) + fake = self.home / "target" + fake.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n", encoding="utf-8") + fake.chmod(0o755) + result = self._run(123, str(fake), "run", "org.example.Game") + self.assertEqual(result.stdout.splitlines(), ["run", "org.example.Game"]) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertNotIn("flatpakAppId", content) + self.assertNotIn("shortcut_exe", content) + self.assertNotIn("--filesystem", content) def test_appid_fallback_and_unmatched_passthrough(self): self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False)) @@ -101,7 +118,7 @@ class WrapperServiceTests(unittest.TestCase): ) fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line) self.assertEqual(fallback_values["SteamDeck"], "0") - self.assertEqual(fallback_values["SteamGameId"], "456") + self.assertEqual(fallback_values["SteamAppId"], "456") passthrough = subprocess.run( [str(self.service.wrapper_path), "/usr/bin/env"], @@ -114,120 +131,19 @@ class WrapperServiceTests(unittest.TestCase): self.assertEqual(passthrough_values["KEEP"], "yes") self.assertEqual(passthrough_values["DXVK_HDR"], "1") - def test_flatpak_shortcut_receives_env_arguments_and_original_target(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text( - "#!/bin/sh\n" - "printf 'ARG:%s\\n' \"$@\"\n", - encoding="utf-8", - ) - fake_flatpak.chmod(0o755) - self.service.set( - "123", - self._state(dxvkFrameRate=20, enableZink=True), - str(fake_flatpak), - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - result = self._run(123, "run", "com.example.Game", "--windowed", env={"DXVK_CONFIG": "foo=1"}) - args = result.stdout.splitlines() - self.assertEqual(args[0], "ARG:run") - self.assertIn("ARG:--filesystem=" + str(self.service.config_dir) + ":rw", args) - self.assertIn("ARG:--filesystem=" + str(self.home / ".local/share/Steam/steamapps/common/Lossless Scaling") + ":ro", args) - self.assertIn("ARG:--env=LSFGVK_CONFIG=" + str(self.service.config_file_path), args) - self.assertIn("ARG:--env=LSFGVK_FLATPAK=1", args) - self.assertIn("ARG:--env=SteamAppId=123", args) - self.assertIn("ARG:--env=ENABLE_GAMESCOPE_WSI=0", args) - self.assertIn("ARG:--env=DXVK_HDR=0", args) - self.assertIn("ARG:--env=__GLX_VENDOR_LIBRARY_NAME=mesa", args) - self.assertIn("ARG:--env=MESA_LOADER_DRIVER_OVERRIDE=zink", args) - self.assertIn("ARG:--env=GALLIUM_DRIVER=zink", args) - self.assertIn("ARG:--env=DXVK_CONFIG=foo=1; dxvk.maxFrameRate = 20", args) - self.assertIn("ARG:com.example.Game", args) - self.assertIn("ARG:--windowed", args) - - def test_flatpak_full_executable_form_is_preserved(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text( - "#!/bin/sh\n" - "printf 'ARG:%s\\n' \"$@\"\n", - encoding="utf-8", - ) - fake_flatpak.chmod(0o755) - response = self.service.set( - "123", - self._state(), - f"{fake_flatpak} run com.example.Game", - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - self.assertTrue(response["success"]) - result = self._run(123, "--windowed") - args = result.stdout.splitlines() - self.assertEqual(args[0], "ARG:run") - self.assertIn("ARG:com.example.Game", args) - self.assertIn("ARG:--windowed", args) - - def test_flatpak_transport_rejects_non_run_invocation(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - fake_flatpak.chmod(0o755) - response = self.service.set( - "123", - self._state(), - str(fake_flatpak), - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - self.assertTrue(response["success"]) - result = subprocess.run( - [str(self.service.wrapper_path), "bash", "launch-game.sh"], - env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 64) - self.assertIn("direct flatpak run", result.stderr) - - def test_flatpak_transport_rejects_external_app_id_change(self): - fake_flatpak = self.home / ".local/bin/flatpak" - fake_flatpak.parent.mkdir(parents=True, exist_ok=True) - fake_flatpak.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - fake_flatpak.chmod(0o755) - response = self.service.set( - "123", - self._state(), - str(fake_flatpak), - False, - {"kind": "flatpak", "flatpakAppId": "com.example.Game"}, - ) - self.assertTrue(response["success"]) - result = subprocess.run( - [str(self.service.wrapper_path), "run", "com.other.Game"], - env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 64) - self.assertIn("application ID changed externally", result.stderr) - def test_invalid_state_and_foreign_wrapper_fail_closed(self): invalid = self.service.set("0", self.service.default_state()) self.assertFalse(invalid["success"]) invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61}) self.assertFalse(invalid["success"]) - self.service.local_bin_dir.mkdir(parents=True, exist_ok=True) self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") response = self.service.set("123", self.service.default_state()) self.assertFalse(response["success"]) self.assertIn("unowned", response["error"]) self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") - def test_remove_keeps_a_safe_owned_passthrough_wrapper(self): + def test_remove_keeps_safe_passthrough_wrapper(self): self.service.set("123", self.service.default_state()) response = self.service.remove("123") self.assertTrue(response["success"]) @@ -242,6 +158,53 @@ class WrapperServiceTests(unittest.TestCase): ) self.assertEqual(result.stdout, "ok") + def test_purge_removes_owned_wrapper_and_state(self): + self.service.set("123", self._state(), non_steam=True) + self.assertTrue(self.service.get("123")["non_steam"]) + self.assertEqual(self.service.list_apps()["apps"][0]["non_steam"], True) + response = self.service.purge() + self.assertTrue(response["success"]) + self.assertEqual(response["removed_files"], [str(self.service.wrapper_path), str(self.service.sidecar_path)]) + self.assertFalse(self.service.wrapper_path.exists()) + self.assertFalse(self.service.sidecar_path.exists()) + + def test_purge_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertTrue(self.service.wrapper_path.exists()) + + def test_purge_refuses_invalid_state(self): + self.service.config_dir.mkdir(parents=True, exist_ok=True) + self.service.sidecar_path.write_text("not json", encoding="utf-8") + self.service.wrapper_path.write_text( + f"#!/bin/sh\n{self.service.MARKER}\nexec \"$@\"\n", + encoding="utf-8", + ) + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertTrue(self.service.wrapper_path.exists()) + self.assertTrue(self.service.sidecar_path.exists()) + + def test_neutralize_leaves_dependency_free_passthrough_wrapper(self): + self.service.set("123", self._state()) + response = self.service.neutralize() + self.assertTrue(response["success"]) + self.assertFalse(self.service.sidecar_path.exists()) + self.assertTrue(self.service.wrapper_path.exists()) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertIn(self.service.MARKER, content) + self.assertNotIn("LSFGVK_CONFIG", content) + self.assertEqual(self._run(123, "/usr/bin/printf", "ok").stdout, "ok") + + def test_neutralize_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.neutralize() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + if __name__ == "__main__": unittest.main() |
