From 89e64a7dbddb8f713dd2ca37131924e8bd8ab51f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sat, 5 Sep 2026 23:58:16 -0400 Subject: feat: select Lossless Scaling lsfg-vk branch --- py_modules/lsfg_vk/steam_service.py | 273 ++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 py_modules/lsfg_vk/steam_service.py (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py new file mode 100644 index 0000000..3278f3c --- /dev/null +++ b/py_modules/lsfg_vk/steam_service.py @@ -0,0 +1,273 @@ +import os +import re +import tempfile +from pathlib import Path +from typing import Dict, Optional, Tuple + +from .base_service import BaseService +from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH + + +class SteamService(BaseService): + DEFAULT_BRANCH = "public" + MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" + + def _steam_library_roots(self): + candidates = ( + self.user_home / ".local/share/Steam", + self.user_home / ".steam/steam", + self.user_home / ".steam/root", + self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ) + seen = set() + + for candidate in candidates: + yield from self._unique_existing_root(candidate, seen) + + library_file = candidate / "steamapps/libraryfolders.vdf" + try: + content = library_file.read_text(encoding="utf-8") + except OSError: + continue + + for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): + path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') + yield from self._unique_existing_root(Path(path), seen) + + @staticmethod + def _unique_existing_root(path: Path, seen: set[str]): + if not path.exists(): + return + try: + resolved = str(path.resolve()) + except OSError: + resolved = str(path) + if resolved in seen: + return + seen.add(resolved) + yield path + + def _manifest_path(self) -> Optional[Path]: + for library_root in self._steam_library_roots(): + manifest = library_root / "steamapps" / self.MANIFEST_FILENAME + if manifest.is_file(): + return manifest + return None + + @staticmethod + def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]: + section = re.search( + rf'(?m)^(?P[ \t]*)"{re.escape(section_name)}"[ \t\r\n]*\{{', + content, + ) + if section is None: + return None + + depth = 1 + in_string = False + escaped = False + for index in range(section.end(), len(content)): + character = content[index] + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + continue + + if character == '"': + in_string = True + elif character == "{": + depth += 1 + elif character == "}": + depth -= 1 + if depth == 0: + return section.end(), index, section.group("indent") + return None + + @classmethod + def _section_value(cls, content: str, section_name: str, key: str) -> Optional[str]: + bounds = cls._section_bounds(content, section_name) + if bounds is None: + return None + body_start, body_end, _ = bounds + pattern = re.compile( + r'(?m)^[ \t]*"(?P[^"]+)"[ \t]+"(?P(?:\\.|[^"\\])*)"' + ) + for match in pattern.finditer(content, body_start, body_end): + if match.group("key") == key: + return match.group("value") + return None + + @classmethod + def _set_section_value(cls, content: str, section_name: str, key: str, value: str) -> str: + bounds = cls._section_bounds(content, section_name) + if bounds is None: + if section_name != "UserConfig": + raise ValueError(f"Steam manifest is missing the {section_name} section") + app_state = cls._section_bounds(content, "AppState") + if app_state is None: + raise ValueError("Steam manifest is missing the AppState section") + _, app_state_end, app_state_indent = app_state + prefix = content[:app_state_end] + if not prefix.endswith(("\n", "\r")): + prefix += "\n" + entry_indent = app_state_indent + "\t" + section = ( + f'{entry_indent}"UserConfig"\n' + f'{entry_indent}{{\n' + f'{entry_indent}\t"{key}"\t"{value}"\n' + f'{entry_indent}}}\n' + ) + return prefix + section + content[app_state_end:] + + body_start, body_end, section_indent = bounds + pattern = re.compile( + rf'(?m)^[ \t]*"{re.escape(key)}"[ \t]+"(?P(?:\\.|[^"\\])*)"' + ) + match = pattern.search(content, body_start, body_end) + if match is not None: + return content[: match.start("value")] + value + content[match.end("value") :] + + prefix = content[:body_end] + if not prefix.endswith(("\n", "\r")): + prefix += "\n" + entry_indent = section_indent + "\t" + return prefix + f'{entry_indent}"{key}"\t"{value}"\n' + content[body_end:] + + @classmethod + def _branch_or_default(cls, branch: Optional[str]) -> str: + return branch or cls.DEFAULT_BRANCH + + def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]: + selected_branch = self._branch_or_default( + self._section_value(content, "UserConfig", "BetaKey") + ) + current_branch = self._branch_or_default( + self._section_value(content, "MountedConfig", "BetaKey") + or self._section_value(content, "UserConfig", "BetaKey") + ) + needs_switch = ( + selected_branch != STEAM_LOSSLESS_SCALING_BRANCH + or current_branch != STEAM_LOSSLESS_SCALING_BRANCH + ) + return { + "installed": True, + "manifest_path": str(manifest_path), + "selected_branch": selected_branch, + "current_branch": current_branch, + "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, + "needs_switch": needs_switch, + "restart_required": ( + selected_branch == STEAM_LOSSLESS_SCALING_BRANCH + and current_branch != STEAM_LOSSLESS_SCALING_BRANCH + ), + } + + def get_branch_status(self) -> Dict[str, object]: + try: + manifest_path = self._manifest_path() + if manifest_path is None: + return self._success_response( + dict, + "Lossless Scaling is not installed through Steam", + installed=False, + manifest_path=None, + selected_branch=None, + current_branch=None, + target_branch=STEAM_LOSSLESS_SCALING_BRANCH, + needs_switch=False, + restart_required=False, + ) + + content = manifest_path.read_text(encoding="utf-8") + fields = self._status_fields(manifest_path, content) + if not fields["needs_switch"]: + message = "Lossless Scaling is using the lsfg-vk Steam branch" + elif fields["restart_required"]: + message = "lsfg-vk is selected; restart Steam to finish the branch switch" + else: + message = "Lossless Scaling is not using the lsfg-vk Steam branch" + return self._success_response(dict, message, **fields) + except Exception as error: + return self._error_response( + dict, + str(error), + installed=False, + manifest_path=None, + selected_branch=None, + current_branch=None, + target_branch=STEAM_LOSSLESS_SCALING_BRANCH, + needs_switch=False, + restart_required=False, + ) + + def select_branch(self) -> Dict[str, object]: + try: + manifest_path = self._manifest_path() + if manifest_path is None: + raise FileNotFoundError("Lossless Scaling is not installed through Steam") + + content = manifest_path.read_text(encoding="utf-8") + fields = self._status_fields(manifest_path, content) + if not fields["needs_switch"]: + return self._success_response( + dict, + "Lossless Scaling is already using the lsfg-vk Steam branch", + changed=False, + **fields, + ) + + updated = self._set_section_value( + content, + "UserConfig", + "BetaKey", + STEAM_LOSSLESS_SCALING_BRANCH, + ) + if updated != content: + file_mode = manifest_path.stat().st_mode & 0o777 + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=manifest_path.parent, + prefix=f".{manifest_path.name}.", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(updated) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(file_mode) + os.replace(temporary_path, manifest_path) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise + + new_fields = dict(fields) + new_fields["selected_branch"] = STEAM_LOSSLESS_SCALING_BRANCH + new_fields["needs_switch"] = new_fields["current_branch"] != STEAM_LOSSLESS_SCALING_BRANCH + new_fields["restart_required"] = new_fields["needs_switch"] + return self._success_response( + dict, + "lsfg-vk selected for Lossless Scaling; restart Steam to download it", + changed=updated != content, + **new_fields, + ) + except Exception as error: + return self._error_response( + dict, + str(error), + changed=False, + installed=False, + manifest_path=None, + selected_branch=None, + current_branch=None, + target_branch=STEAM_LOSSLESS_SCALING_BRANCH, + needs_switch=False, + restart_required=False, + ) -- cgit v1.2.3 From a9fd67d05d6819a839a60b581c1dc6eb2792a9be Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 00:16:19 -0400 Subject: refactor: make Steam branch integration read-only --- py_modules/lsfg_vk/steam_service.py | 106 ------------------------------------ 1 file changed, 106 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 3278f3c..867a135 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -1,6 +1,4 @@ -import os import re -import tempfile from pathlib import Path from typing import Dict, Optional, Tuple @@ -101,42 +99,6 @@ class SteamService(BaseService): return match.group("value") return None - @classmethod - def _set_section_value(cls, content: str, section_name: str, key: str, value: str) -> str: - bounds = cls._section_bounds(content, section_name) - if bounds is None: - if section_name != "UserConfig": - raise ValueError(f"Steam manifest is missing the {section_name} section") - app_state = cls._section_bounds(content, "AppState") - if app_state is None: - raise ValueError("Steam manifest is missing the AppState section") - _, app_state_end, app_state_indent = app_state - prefix = content[:app_state_end] - if not prefix.endswith(("\n", "\r")): - prefix += "\n" - entry_indent = app_state_indent + "\t" - section = ( - f'{entry_indent}"UserConfig"\n' - f'{entry_indent}{{\n' - f'{entry_indent}\t"{key}"\t"{value}"\n' - f'{entry_indent}}}\n' - ) - return prefix + section + content[app_state_end:] - - body_start, body_end, section_indent = bounds - pattern = re.compile( - rf'(?m)^[ \t]*"{re.escape(key)}"[ \t]+"(?P(?:\\.|[^"\\])*)"' - ) - match = pattern.search(content, body_start, body_end) - if match is not None: - return content[: match.start("value")] + value + content[match.end("value") :] - - prefix = content[:body_end] - if not prefix.endswith(("\n", "\r")): - prefix += "\n" - entry_indent = section_indent + "\t" - return prefix + f'{entry_indent}"{key}"\t"{value}"\n' + content[body_end:] - @classmethod def _branch_or_default(cls, branch: Optional[str]) -> str: return branch or cls.DEFAULT_BRANCH @@ -203,71 +165,3 @@ class SteamService(BaseService): needs_switch=False, restart_required=False, ) - - def select_branch(self) -> Dict[str, object]: - try: - manifest_path = self._manifest_path() - if manifest_path is None: - raise FileNotFoundError("Lossless Scaling is not installed through Steam") - - content = manifest_path.read_text(encoding="utf-8") - fields = self._status_fields(manifest_path, content) - if not fields["needs_switch"]: - return self._success_response( - dict, - "Lossless Scaling is already using the lsfg-vk Steam branch", - changed=False, - **fields, - ) - - updated = self._set_section_value( - content, - "UserConfig", - "BetaKey", - STEAM_LOSSLESS_SCALING_BRANCH, - ) - if updated != content: - file_mode = manifest_path.stat().st_mode & 0o777 - temporary_path = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=manifest_path.parent, - prefix=f".{manifest_path.name}.", - delete=False, - ) as temporary_file: - temporary_path = Path(temporary_file.name) - temporary_file.write(updated) - temporary_file.flush() - os.fsync(temporary_file.fileno()) - temporary_path.chmod(file_mode) - os.replace(temporary_path, manifest_path) - except Exception: - if temporary_path is not None: - temporary_path.unlink(missing_ok=True) - raise - - new_fields = dict(fields) - new_fields["selected_branch"] = STEAM_LOSSLESS_SCALING_BRANCH - new_fields["needs_switch"] = new_fields["current_branch"] != STEAM_LOSSLESS_SCALING_BRANCH - new_fields["restart_required"] = new_fields["needs_switch"] - return self._success_response( - dict, - "lsfg-vk selected for Lossless Scaling; restart Steam to download it", - changed=updated != content, - **new_fields, - ) - except Exception as error: - return self._error_response( - dict, - str(error), - changed=False, - installed=False, - manifest_path=None, - selected_branch=None, - current_branch=None, - target_branch=STEAM_LOSSLESS_SCALING_BRANCH, - needs_switch=False, - restart_required=False, - ) -- cgit v1.2.3 From ad2b182777bfd0a5ceef6e654df75ff13eb8b503 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 15:08:45 -0400 Subject: refactor: offload configuration to lsfg-vk --- py_modules/lsfg_vk/steam_service.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 867a135..58e0a20 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -128,6 +128,16 @@ class SteamService(BaseService): ), } + def find_lsfg_vk_dll(self) -> Optional[str]: + """Find the branch-specific upstream DLL in any Steam library.""" + if self.get_branch_status().get("needs_switch"): + return None + for library_root in self._steam_library_roots(): + dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll" + if dll_path.is_file(): + return str(dll_path) + return None + def get_branch_status(self) -> Dict[str, object]: try: manifest_path = self._manifest_path() @@ -165,3 +175,23 @@ class SteamService(BaseService): needs_switch=False, restart_required=False, ) + + def get_installed_games(self) -> Dict[str, object]: + """Return installed Steam app IDs and names for the Game Mode selector.""" + try: + games = {} + for library_root in self._steam_library_roots(): + for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"): + match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) + if not match: + continue + try: + content = manifest.read_text(encoding="utf-8") + except OSError: + continue + appid = match.group(1) + name = self._section_value(content, "AppState", "name") or f"App {appid}" + games[appid] = name + return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())]) + except Exception as error: + return self._error_response(dict, str(error), games=[]) -- cgit v1.2.3 From 8cf9d66b05bae5d58e72b0cb7d2b83ef13a86141 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 15:23:26 -0400 Subject: fix: filter Steam compatibility tools from game selector --- py_modules/lsfg_vk/steam_service.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 58e0a20..9b69806 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -9,6 +9,35 @@ from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRA 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", # 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_library_roots(self): candidates = ( @@ -190,6 +219,8 @@ class SteamService(BaseService): except OSError: continue appid = match.group(1) + if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS: + continue name = self._section_value(content, "AppState", "name") or f"App {appid}" games[appid] = name return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())]) -- cgit v1.2.3 From c9be32287ad5b72fcd86b10fa726d09dbd97d7fd Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 17:09:54 -0400 Subject: refactor: organize plugin into native tabs --- py_modules/lsfg_vk/steam_service.py | 77 +++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 9b69806..54ac359 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -39,7 +39,7 @@ class SteamService(BaseService): "1628350", # Steam Linux Runtime 3.0 } - def _steam_library_roots(self): + def _steam_roots(self): candidates = ( self.user_home / ".local/share/Steam", self.user_home / ".steam/steam", @@ -51,6 +51,11 @@ class SteamService(BaseService): for candidate in candidates: yield from self._unique_existing_root(candidate, seen) + def _steam_library_roots(self): + seen = set() + for candidate in self._steam_roots(): + yield from self._unique_existing_root(candidate, seen) + library_file = candidate / "steamapps/libraryfolders.vdf" try: content = library_file.read_text(encoding="utf-8") @@ -61,6 +66,65 @@ class SteamService(BaseService): path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') yield from self._unique_existing_root(Path(path), seen) + @staticmethod + def _read_shortcuts(data: bytes) -> Dict[str, object]: + def read_string(offset: int) -> Tuple[str, int]: + end = data.index(b"\0", offset) + return data[offset:end].decode("utf-8", errors="replace"), end + 1 + + def read_object(offset: int = 0) -> Tuple[Dict[str, object], int]: + values = {} + while offset < len(data): + value_type, offset = data[offset], offset + 1 + if value_type == 8: + return values, offset + key, offset = read_string(offset) + if value_type == 0: + value, offset = read_object(offset) + elif value_type == 1: + value, offset = read_string(offset) + elif value_type == 2: + if offset + 4 > len(data): + raise ValueError("truncated binary VDF integer") + value = int.from_bytes(data[offset:offset + 4], "little", signed=True) + offset += 4 + else: + raise ValueError(f"unsupported binary VDF type {value_type}") + values[key] = value + raise ValueError("unterminated binary VDF object") + + values, offset = read_object() + if offset != len(data): + raise ValueError("trailing binary VDF data") + return values + + @staticmethod + def _shortcut_game(shortcut: object) -> Optional[Dict[str, object]]: + if not isinstance(shortcut, dict): + return None + appid = shortcut.get("appid") + name = shortcut.get("AppName") + if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name: + return None + return {"appid": str(appid), "name": name, "nonSteam": True} + + def _shortcut_games(self): + games = {} + for steam_root in self._steam_roots(): + for shortcuts_file in sorted((steam_root / "userdata").glob("*/config/shortcuts.vdf")): + try: + root = self._read_shortcuts(shortcuts_file.read_bytes()) + except (OSError, ValueError): + continue + shortcuts = root.get("shortcuts", {}) + if not isinstance(shortcuts, dict): + continue + for shortcut in shortcuts.values(): + game = self._shortcut_game(shortcut) + if game and game["appid"] not in self.GAME_SELECTOR_EXCLUDED_APPIDS: + games.setdefault(game["appid"], game) + return list(games.values()) + @staticmethod def _unique_existing_root(path: Path, seen: set[str]): if not path.exists(): @@ -208,7 +272,7 @@ class SteamService(BaseService): def get_installed_games(self) -> Dict[str, object]: """Return installed Steam app IDs and names for the Game Mode selector.""" try: - games = {} + games: Dict[str, Dict[str, object]] = {} for library_root in self._steam_library_roots(): for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"): match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) @@ -222,7 +286,12 @@ class SteamService(BaseService): if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS: continue name = self._section_value(content, "AppState", "name") or f"App {appid}" - games[appid] = name - return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())]) + games[appid] = {"appid": appid, "name": name, "nonSteam": False} + for game in self._shortcut_games(): + games.setdefault(str(game["appid"]), game) + return self._success_response( + dict, + games=sorted(games.values(), key=lambda game: str(game["name"]).lower()), + ) except Exception as error: return self._error_response(dict, str(error), games=[]) -- cgit v1.2.3 From 991edaeb6b39ee9a7628f036ac2f1c57cf1caea1 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 6 Sep 2026 23:40:23 -0400 Subject: fix: use unsigned non-steam app IDs --- py_modules/lsfg_vk/steam_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 54ac359..8c50cfd 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -106,7 +106,7 @@ class SteamService(BaseService): name = shortcut.get("AppName") if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name: return None - return {"appid": str(appid), "name": name, "nonSteam": True} + return {"appid": str(appid & 0xffffffff), "name": name, "nonSteam": True} def _shortcut_games(self): games = {} -- cgit v1.2.3 From 22db1125238e54b29538102727c36284e122e703 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 16:02:26 -0400 Subject: feat: refine game discovery and profile UX --- py_modules/lsfg_vk/steam_service.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 8c50cfd..f46a091 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -56,15 +56,18 @@ class SteamService(BaseService): for candidate in self._steam_roots(): yield from self._unique_existing_root(candidate, seen) - library_file = candidate / "steamapps/libraryfolders.vdf" - try: - content = library_file.read_text(encoding="utf-8") - except OSError: - continue + for library_file in ( + candidate / "steamapps/libraryfolders.vdf", + candidate / "config/libraryfolders.vdf", + ): + try: + content = library_file.read_text(encoding="utf-8") + except OSError: + continue - for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): - path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') - yield from self._unique_existing_root(Path(path), seen) + for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): + path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') + yield from self._unique_existing_root(Path(path), seen) @staticmethod def _read_shortcuts(data: bytes) -> Dict[str, object]: @@ -88,6 +91,11 @@ class SteamService(BaseService): raise ValueError("truncated binary VDF integer") value = int.from_bytes(data[offset:offset + 4], "little", signed=True) offset += 4 + elif value_type == 7: + if offset + 8 > len(data): + raise ValueError("truncated binary VDF 64-bit integer") + value = int.from_bytes(data[offset:offset + 8], "little", signed=True) + offset += 8 else: raise ValueError(f"unsupported binary VDF type {value_type}") values[key] = value @@ -254,7 +262,7 @@ class SteamService(BaseService): elif fields["restart_required"]: message = "lsfg-vk is selected; restart Steam to finish the branch switch" else: - message = "Lossless Scaling is not using the lsfg-vk Steam branch" + message = "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" return self._success_response(dict, message, **fields) except Exception as error: return self._error_response( -- cgit v1.2.3 From e21eee83fc58fee3481aa0e17feb7cd9a8d8750e Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Mon, 7 Sep 2026 20:16:09 -0400 Subject: Support lowercase Steam shortcut names and collapsible profile details --- py_modules/lsfg_vk/steam_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index f46a091..9a6570f 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -111,7 +111,7 @@ class SteamService(BaseService): if not isinstance(shortcut, dict): return None appid = shortcut.get("appid") - name = shortcut.get("AppName") + 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 return {"appid": str(appid & 0xffffffff), "name": name, "nonSteam": True} -- cgit v1.2.3 From cc1e6f47dd9838b066822162a607d2859c043aff Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Wed, 9 Sep 2026 19:16:30 -0400 Subject: refactor: unify flatpak targets with steam profiles --- py_modules/lsfg_vk/steam_service.py | 86 ++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 9a6570f..b3bdb69 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -1,4 +1,5 @@ import re +import shlex from pathlib import Path from typing import Dict, Optional, Tuple @@ -6,6 +7,46 @@ from .base_service import BaseService from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH +_FLATPAK_APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$") + + +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 classify_shortcut_transport( + executable: Optional[str], + launch_options: Optional[str] = None, +) -> Dict[str, object]: + """Classify only direct Flatpak invocations; leave shell launchers on host.""" + 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"} + + if executable_tokens[0] != "/usr/bin/flatpak": + 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 == "--": + continue + if argument.startswith("-"): + continue + if _FLATPAK_APP_ID.fullmatch(argument): + return {"kind": "flatpak", "flatpakAppId": argument} + return {"kind": "host"} + return {"kind": "host"} + + class SteamService(BaseService): DEFAULT_BRANCH = "public" MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" @@ -114,7 +155,43 @@ 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 - return {"appid": str(appid & 0xffffffff), "name": name, "nonSteam": True} + executable = next( + ( + shortcut.get(key) + for key in ("Exe", "exe", "executable") + if isinstance(shortcut.get(key), str) + ), + None, + ) + launch_options = next( + ( + shortcut.get(key) + for key in ("LaunchOptions", "launchoptions", "launch_options", "arguments") + if isinstance(shortcut.get(key), str) + ), + None, + ) + start_dir = next( + ( + shortcut.get(key) + for key in ("StartDir", "startdir", "start_dir") + if isinstance(shortcut.get(key), str) + ), + None, + ) + game: Dict[str, object] = { + "appid": str(appid & 0xffffffff), + "name": name, + "nonSteam": True, + "transport": classify_shortcut_transport(executable, launch_options), + } + if executable is not None: + game["executable"] = executable + if launch_options is not None: + game["arguments"] = launch_options + if start_dir is not None: + game["startDir"] = start_dir + return game def _shortcut_games(self): games = {} @@ -294,7 +371,12 @@ class SteamService(BaseService): if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS: continue name = self._section_value(content, "AppState", "name") or f"App {appid}" - games[appid] = {"appid": appid, "name": name, "nonSteam": False} + games[appid] = { + "appid": appid, + "name": name, + "nonSteam": False, + "transport": {"kind": "host"}, + } for game in self._shortcut_games(): games.setdefault(str(game["appid"]), game) return self._success_response( -- cgit v1.2.3 From 28ebc17785a8cca52f289b74261d1f310fd35904 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 07:08:19 -0400 Subject: fix: detect wrapped Flatpak shortcuts --- py_modules/lsfg_vk/steam_service.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index b3bdb69..50d722b 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -4,10 +4,15 @@ from pathlib import Path from typing import Dict, Optional, Tuple from .base_service import BaseService -from .constants import STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH +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]]: @@ -19,17 +24,27 @@ def _split_command(value: Optional[str]) -> Optional[list[str]]: return None +def _is_managed_wrapper(value: str) -> bool: + """Recognize the wrapper Target while keeping arbitrary launchers as host games.""" + 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]: - """Classify only direct Flatpak invocations; leave shell launchers on host.""" + """Classify direct Flatpak invocations, including the managed wrapper Target.""" 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"} - if executable_tokens[0] != "/usr/bin/flatpak": + 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] -- cgit v1.2.3 From 450d3e5e6612d467a00bb937538fded640c66ecb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:24:05 -0400 Subject: refactor: simplify migration implementation --- py_modules/lsfg_vk/steam_service.py | 285 +++++++++++++----------------------- 1 file changed, 102 insertions(+), 183 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 50d722b..201441e 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -10,7 +10,6 @@ from .constants import ( 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}" @@ -25,106 +24,88 @@ def _split_command(value: Optional[str]) -> Optional[list[str]]: def _is_managed_wrapper(value: str) -> bool: - """Recognize the wrapper Target while keeping arbitrary launchers as host games.""" 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]: - """Classify direct Flatpak invocations, including the managed wrapper Target.""" +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 == "--": + if argument == "--" or argument.startswith("-"): continue - if argument.startswith("-"): - continue - if _FLATPAK_APP_ID.fullmatch(argument): - return {"kind": "flatpak", "flatpakAppId": argument} - return {"kind": "host"} + 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", # 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 + "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", } def _steam_roots(self): - candidates = ( + seen = set() + for candidate in ( self.user_home / ".local/share/Steam", self.user_home / ".steam/steam", self.user_home / ".steam/root", self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", - ) - seen = set() - - for candidate in candidates: + ): yield from self._unique_existing_root(candidate, seen) def _steam_library_roots(self): seen = set() - for candidate in self._steam_roots(): - yield from self._unique_existing_root(candidate, seen) - + for root in self._steam_roots(): + yield from self._unique_existing_root(root, seen) for library_file in ( - candidate / "steamapps/libraryfolders.vdf", - candidate / "config/libraryfolders.vdf", + root / "steamapps/libraryfolders.vdf", + root / "config/libraryfolders.vdf", ): try: content = library_file.read_text(encoding="utf-8") except OSError: continue - for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') yield from self._unique_existing_root(Path(path), seen) + @staticmethod + def _unique_existing_root(path: Path, seen: set[str]): + if not path.exists(): + return + try: + resolved = str(path.resolve()) + except OSError: + resolved = str(path) + if resolved not in seen: + seen.add(resolved) + yield path + @staticmethod def _read_shortcuts(data: bytes) -> Dict[str, object]: def read_string(offset: int) -> Tuple[str, int]: @@ -142,16 +123,12 @@ class SteamService(BaseService): value, offset = read_object(offset) elif value_type == 1: value, offset = read_string(offset) - elif value_type == 2: - if offset + 4 > len(data): + elif value_type in (2, 7): + width = 4 if value_type == 2 else 8 + if offset + width > len(data): raise ValueError("truncated binary VDF integer") - value = int.from_bytes(data[offset:offset + 4], "little", signed=True) - offset += 4 - elif value_type == 7: - if offset + 8 > len(data): - raise ValueError("truncated binary VDF 64-bit integer") - value = int.from_bytes(data[offset:offset + 8], "little", signed=True) - offset += 8 + value = int.from_bytes(data[offset:offset + width], "little", signed=True) + offset += width else: raise ValueError(f"unsupported binary VDF type {value_type}") values[key] = value @@ -170,53 +147,28 @@ 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 = next( - ( - shortcut.get(key) - for key in ("Exe", "exe", "executable") - if isinstance(shortcut.get(key), str) - ), - None, - ) - launch_options = next( - ( - shortcut.get(key) - for key in ("LaunchOptions", "launchoptions", "launch_options", "arguments") - if isinstance(shortcut.get(key), str) - ), - None, - ) - start_dir = next( - ( - shortcut.get(key) - for key in ("StartDir", "startdir", "start_dir") - if isinstance(shortcut.get(key), str) - ), - 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] = { - "appid": str(appid & 0xffffffff), + "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "transport": classify_shortcut_transport(executable, launch_options), + "transport": classify_shortcut_transport(executable, arguments), } - if executable is not None: - game["executable"] = executable - if launch_options is not None: - game["arguments"] = launch_options - if start_dir is not None: - game["startDir"] = start_dir + for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): + if value is not None: + game[key] = value return game def _shortcut_games(self): games = {} - for steam_root in self._steam_roots(): - for shortcuts_file in sorted((steam_root / "userdata").glob("*/config/shortcuts.vdf")): + for root in self._steam_roots(): + for path in sorted((root / "userdata").glob("*/config/shortcuts.vdf")): try: - root = self._read_shortcuts(shortcuts_file.read_bytes()) + shortcuts = self._read_shortcuts(path.read_bytes()).get("shortcuts", {}) except (OSError, ValueError): continue - shortcuts = root.get("shortcuts", {}) if not isinstance(shortcuts, dict): continue for shortcut in shortcuts.values(): @@ -225,25 +177,12 @@ class SteamService(BaseService): games.setdefault(game["appid"], game) return list(games.values()) - @staticmethod - def _unique_existing_root(path: Path, seen: set[str]): - if not path.exists(): - return - try: - resolved = str(path.resolve()) - except OSError: - resolved = str(path) - if resolved in seen: - return - seen.add(resolved) - yield path - def _manifest_path(self) -> Optional[Path]: - for library_root in self._steam_library_roots(): - manifest = library_root / "steamapps" / self.MANIFEST_FILENAME - if manifest.is_file(): - return manifest - return None + return next(( + path + for root in self._steam_library_roots() + if (path := root / "steamapps" / self.MANIFEST_FILENAME).is_file() + ), None) @staticmethod def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]: @@ -253,7 +192,6 @@ class SteamService(BaseService): ) if section is None: return None - depth = 1 in_string = False escaped = False @@ -266,9 +204,7 @@ class SteamService(BaseService): escaped = True elif character == '"': in_string = False - continue - - if character == '"': + elif character == '"': in_string = True elif character == "{": depth += 1 @@ -283,98 +219,82 @@ class SteamService(BaseService): bounds = cls._section_bounds(content, section_name) if bounds is None: return None - body_start, body_end, _ = bounds - pattern = re.compile( - r'(?m)^[ \t]*"(?P[^"]+)"[ \t]+"(?P(?:\\.|[^"\\])*)"' - ) - for match in pattern.finditer(content, body_start, body_end): - if match.group("key") == key: - return match.group("value") - return None + start, end, _ = bounds + pattern = re.compile(r'(?m)^[ \t]*"(?P[^"]+)"[ \t]+"(?P(?:\\.|[^"\\])*)"') + return next(( + match.group("value") + for match in pattern.finditer(content, start, end) + if match.group("key") == key + ), None) @classmethod def _branch_or_default(cls, branch: Optional[str]) -> str: return branch or cls.DEFAULT_BRANCH def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]: - selected_branch = self._branch_or_default( - self._section_value(content, "UserConfig", "BetaKey") - ) - current_branch = self._branch_or_default( + selected = self._branch_or_default(self._section_value(content, "UserConfig", "BetaKey")) + current = self._branch_or_default( self._section_value(content, "MountedConfig", "BetaKey") or self._section_value(content, "UserConfig", "BetaKey") ) - needs_switch = ( - selected_branch != STEAM_LOSSLESS_SCALING_BRANCH - or current_branch != STEAM_LOSSLESS_SCALING_BRANCH - ) + needs_switch = selected != STEAM_LOSSLESS_SCALING_BRANCH or current != STEAM_LOSSLESS_SCALING_BRANCH return { "installed": True, "manifest_path": str(manifest_path), - "selected_branch": selected_branch, - "current_branch": current_branch, + "selected_branch": selected, + "current_branch": current, "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, "needs_switch": needs_switch, - "restart_required": ( - selected_branch == STEAM_LOSSLESS_SCALING_BRANCH - and current_branch != STEAM_LOSSLESS_SCALING_BRANCH - ), + "restart_required": selected == STEAM_LOSSLESS_SCALING_BRANCH and current != STEAM_LOSSLESS_SCALING_BRANCH, + } + + @staticmethod + def _missing_branch_fields() -> Dict[str, object]: + return { + "installed": False, + "manifest_path": None, + "selected_branch": None, + "current_branch": None, + "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, + "needs_switch": False, + "restart_required": False, } def find_lsfg_vk_dll(self) -> Optional[str]: - """Find the branch-specific upstream DLL in any Steam library.""" if self.get_branch_status().get("needs_switch"): return None - for library_root in self._steam_library_roots(): - dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll" - if dll_path.is_file(): - return str(dll_path) - return None + return next(( + str(path) + for root in self._steam_library_roots() + if (path := root / "steamapps/common/Lossless Scaling/lsfg-vk.dll").is_file() + ), None) def get_branch_status(self) -> Dict[str, object]: try: - manifest_path = self._manifest_path() - if manifest_path is None: + manifest = self._manifest_path() + if manifest is None: return self._success_response( dict, "Lossless Scaling is not installed through Steam", - installed=False, - manifest_path=None, - selected_branch=None, - current_branch=None, - target_branch=STEAM_LOSSLESS_SCALING_BRANCH, - needs_switch=False, - restart_required=False, + **self._missing_branch_fields(), ) - - content = manifest_path.read_text(encoding="utf-8") - fields = self._status_fields(manifest_path, content) - if not fields["needs_switch"]: - message = "Lossless Scaling is using the lsfg-vk Steam branch" - elif fields["restart_required"]: - message = "lsfg-vk is selected; restart Steam to finish the branch switch" - else: - message = "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" + fields = self._status_fields(manifest, manifest.read_text(encoding="utf-8")) + message = ( + "Lossless Scaling is using the lsfg-vk Steam branch" + if not fields["needs_switch"] + else "lsfg-vk is selected; restart Steam to finish the branch switch" + if fields["restart_required"] + else "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" + ) return self._success_response(dict, message, **fields) except Exception as error: - return self._error_response( - dict, - str(error), - installed=False, - manifest_path=None, - selected_branch=None, - current_branch=None, - target_branch=STEAM_LOSSLESS_SCALING_BRANCH, - needs_switch=False, - restart_required=False, - ) + return self._error_response(dict, str(error), **self._missing_branch_fields()) def get_installed_games(self) -> Dict[str, object]: - """Return installed Steam app IDs and names for the Game Mode selector.""" try: games: Dict[str, Dict[str, object]] = {} - for library_root in self._steam_library_roots(): - for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"): + for root in self._steam_library_roots(): + for manifest in (root / "steamapps").glob("appmanifest_*.acf"): match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) if not match: continue @@ -385,10 +305,9 @@ class SteamService(BaseService): appid = match.group(1) if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS: continue - name = self._section_value(content, "AppState", "name") or f"App {appid}" games[appid] = { "appid": appid, - "name": name, + "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, "transport": {"kind": "host"}, } -- cgit v1.2.3 From 9902135e53be129bd6096d51d5e510ab298c1ae2 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 10 Sep 2026 12:19:40 -0400 Subject: fix: handle bare Flatpak shortcut targets --- py_modules/lsfg_vk/steam_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 201441e..a952fcb 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -35,7 +35,7 @@ def classify_shortcut_transport(executable: Optional[str], launch_options: Optio 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" + direct_flatpak = executable_tokens[0] in {"flatpak", "/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"} -- cgit v1.2.3 From c9d1c1980b415c3710f3e049cfcbd6a0cd90977a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:37:05 -0400 Subject: refactor: reduce flatpak shortcut detection --- py_modules/lsfg_vk/steam_service.py | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index a952fcb..df6e3a2 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -10,7 +10,6 @@ from .constants import ( 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}" @@ -30,27 +29,17 @@ def _is_managed_wrapper(value: str) -> bool: 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] in {"flatpak", "/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 is_direct_flatpak_shortcut(executable: Optional[str]) -> bool: + tokens = _split_command(executable) + if not tokens: + return False + if tokens[0] in {"flatpak", "/usr/bin/flatpak"}: + return True + return ( + len(tokens) == 2 + and _is_managed_wrapper(tokens[0]) + and tokens[1] == "/usr/bin/flatpak" + ) def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: @@ -154,7 +143,7 @@ class SteamService(BaseService): "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "transport": classify_shortcut_transport(executable, arguments), + "directFlatpak": is_direct_flatpak_shortcut(executable), } for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): if value is not None: @@ -309,7 +298,7 @@ class SteamService(BaseService): "appid": appid, "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, - "transport": {"kind": "host"}, + "directFlatpak": False, } for game in self._shortcut_games(): games.setdefault(str(game["appid"]), game) -- cgit v1.2.3 From 01e4a99ef2409666883ede7886169c96dc6b46fb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:52:15 -0400 Subject: fix: recognize released direct flatpak wrapper targets --- py_modules/lsfg_vk/steam_service.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index df6e3a2..8018f9b 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -11,6 +11,8 @@ from .constants import ( ) _WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" +_LEGACY_WRAPPER_NAMES = {"lsfg", "lsfg-vk-experimental", "mako-run", "mako-launch"} +_FLATPAK_TOKENS = {"flatpak", "/usr/bin/flatpak", "usr/bin/flatpak"} def _split_command(value: Optional[str]) -> Optional[list[str]]: @@ -22,24 +24,19 @@ def _split_command(value: Optional[str]) -> Optional[list[str]]: return None -def _is_managed_wrapper(value: str) -> bool: +def _is_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 + return Path(value).name in _LEGACY_WRAPPER_NAMES or Path(value).name == WRAPPER_FILENAME def is_direct_flatpak_shortcut(executable: Optional[str]) -> bool: tokens = _split_command(executable) if not tokens: return False - if tokens[0] in {"flatpak", "/usr/bin/flatpak"}: + if tokens[0] in _FLATPAK_TOKENS: return True - return ( - len(tokens) == 2 - and _is_managed_wrapper(tokens[0]) - and tokens[1] == "/usr/bin/flatpak" - ) + return len(tokens) == 2 and _is_wrapper(tokens[0]) and tokens[1] in _FLATPAK_TOKENS def _first_string(values: Dict[str, object], *keys: str) -> Optional[str]: -- cgit v1.2.3 From 1fcd7f031c3f6dc38c19bbafaf2c4174f11fa1cb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:33:59 -0400 Subject: refactor: decouple steam discovery from flatpak --- py_modules/lsfg_vk/steam_service.py | 32 -------------------------------- 1 file changed, 32 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 8018f9b..e493ba1 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,37 +6,8 @@ from .base_service import BaseService from .constants import ( STEAM_LOSSLESS_SCALING_APP_ID, STEAM_LOSSLESS_SCALING_BRANCH, - WRAPPER_FILENAME, ) -_WRAPPER_TOKEN = f"~/{WRAPPER_FILENAME}" -_LEGACY_WRAPPER_NAMES = {"lsfg", "lsfg-vk-experimental", "mako-run", "mako-launch"} -_FLATPAK_TOKENS = {"flatpak", "/usr/bin/flatpak", "usr/bin/flatpak"} - - -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_wrapper(value: str) -> bool: - if value in {_WRAPPER_TOKEN, f"$HOME/{WRAPPER_FILENAME}"}: - return True - return Path(value).name in _LEGACY_WRAPPER_NAMES or Path(value).name == WRAPPER_FILENAME - - -def is_direct_flatpak_shortcut(executable: Optional[str]) -> bool: - tokens = _split_command(executable) - if not tokens: - return False - if tokens[0] in _FLATPAK_TOKENS: - return True - return len(tokens) == 2 and _is_wrapper(tokens[0]) and tokens[1] in _FLATPAK_TOKENS - 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) @@ -140,7 +110,6 @@ class SteamService(BaseService): "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, - "directFlatpak": is_direct_flatpak_shortcut(executable), } for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): if value is not None: @@ -295,7 +264,6 @@ class SteamService(BaseService): "appid": appid, "name": self._section_value(content, "AppState", "name") or f"App {appid}", "nonSteam": False, - "directFlatpak": False, } for game in self._shortcut_games(): games.setdefault(str(game["appid"]), game) -- cgit v1.2.3 From 5b5f9df2b13f7502e897de56af6b7cc84eca2176 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:44:34 -0400 Subject: refactor: remove unused shortcut launch metadata --- py_modules/lsfg_vk/steam_service.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index e493ba1..2108071 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -9,10 +9,6 @@ from .constants import ( ) -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" @@ -103,18 +99,11 @@ 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] = { + return { "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, } - for key, value in (("executable", executable), ("arguments", arguments), ("startDir", start_dir)): - if value is not None: - game[key] = value - return game def _shortcut_games(self): games = {} -- cgit v1.2.3 From 81feb03288166755545401b9df2ff2c9bc3ae7d7 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 11 Sep 2026 16:48:35 -0400 Subject: handle flatpak grey, id proton and exclusions --- py_modules/lsfg_vk/steam_service.py | 38 +++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) (limited to 'py_modules/lsfg_vk/steam_service.py') diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py index 2108071..93f2593 100644 --- a/py_modules/lsfg_vk/steam_service.py +++ b/py_modules/lsfg_vk/steam_service.py @@ -12,12 +12,34 @@ from .constants import ( 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): @@ -99,11 +121,15 @@ 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 - return { + game = { "appid": str(appid & 0xFFFFFFFF), "name": name, "nonSteam": True, } + 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): games = {} -- cgit v1.2.3