From 02888bb7d2fe907003a63f4425d2cd6c6baaf7ce Mon Sep 17 00:00:00 2001 From: marshmallow-mia Date: Thu, 8 Oct 2026 09:12:33 +0200 Subject: [PATCH 1/4] Show owned games before their icons, keep slug-less ones, search by words The "Not installed" list stayed empty until every owned game's catalog page and icon had been fetched one after another, so a first load or a library with icon-less (delisted) games showed nothing for a long time. The list now appears as soon as Meta answers for the signed-in accounts and the icons replace the placeholders one by one; a failed icon no longer drops the list, and icons from an older scan or a signed-out account are discarded. Entitlements without a canonical name, as delisted games such as Echo VR can come back, were skipped. They are kept now and link to Meta's slug-less store URL. The entitlements query has no known paging variables, so when Meta reports more pages for any account the sidebar now says the list is partial and points to Add Game. Library search ignores case, accents, punctuation and word order: "echo lone" finds Lone Echo and "vader episode" finds Vader Immortal: Episode I. --- src/riftlift/auth.py | 10 +- src/riftlift/entitlements.py | 28 +++++- src/riftlift/i18n/en.py | 3 + src/riftlift/i18n/fr.py | 4 + src/riftlift/main_window.py | 65 ++++++++++--- src/riftlift/native_shell.py | 22 ++++- tests/test_entitlements.py | 88 ++++++++++++++++++ tests/test_owned_library.py | 176 +++++++++++++++++++++++++++++++++++ 8 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 tests/test_entitlements.py create mode 100644 tests/test_owned_library.py diff --git a/src/riftlift/auth.py b/src/riftlift/auth.py index ccd29c9..e4f822b 100644 --- a/src/riftlift/auth.py +++ b/src/riftlift/auth.py @@ -213,14 +213,16 @@ def runtime_access_token(paths: Paths) -> str: return account_tokens(paths)[0] -def owned_apps(paths: Paths) -> tuple[list[entitlements.OwnedApp], list[str]]: +def owned_apps(paths: Paths) -> tuple[entitlements.OwnedLibrary, list[str]]: """List Rift/PC VR apps owned by any signed-in account. Returns the merged apps and one message per account that could not be - read; raises only when no account could be read at all. + read; raises only when no account could be read at all. The list is + marked partial when Meta cut any account's list short. """ merged: dict[str, entitlements.OwnedApp] = {} failures = [] + partial = False signed_in = accounts(paths) if not signed_in: account_tokens(paths) # raises the signed-out error @@ -231,8 +233,10 @@ def owned_apps(paths: Paths) -> tuple[list[entitlements.OwnedApp], list[str]]: failures.append(f"{account.name or f'Meta account {number}'}: {error}") continue record_owned(paths, account.id, [app.app_id for app in owned]) + partial = partial or getattr(owned, "partial", False) for app in owned: merged.setdefault(app.app_id, app) if failures and len(failures) == len(signed_in): raise RiftLiftError(failures[0] if len(failures) == 1 else "; ".join(failures)) - return sorted(merged.values(), key=lambda app: app.name.lower()), failures + apps = sorted(merged.values(), key=lambda app: app.name.lower()) + return entitlements.OwnedLibrary(apps, partial=partial), failures diff --git a/src/riftlift/entitlements.py b/src/riftlift/entitlements.py index 1695459..9375ad1 100644 --- a/src/riftlift/entitlements.py +++ b/src/riftlift/entitlements.py @@ -22,10 +22,26 @@ class OwnedApp: @property def store_url(self) -> str: + # Delisted apps can come back without a canonical name; Meta's store + # also resolves the link without one. + if not self.slug: + return f"https://www.meta.com/experiences/pcvr/{self.app_id}/" return f"https://www.meta.com/experiences/pcvr/{self.slug}/{self.app_id}/" -def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: +class OwnedLibrary(list): + """Owned apps, plus whether Meta said more results exist than it returned. + + The entitlements query is a persisted one without known paging variables, + so a list Meta cut short can only be reported, not completed. + """ + + def __init__(self, apps=(), *, partial: bool = False): + super().__init__(apps) + self.partial = partial + + +def list_owned_pcvr_apps(token: str) -> OwnedLibrary: body = urllib.parse.urlencode( { "access_token": token, @@ -52,9 +68,11 @@ def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: nodes = entitlements["nodes"] except (KeyError, TypeError) as error: raise RiftLiftError("Meta returned no entitlement data") from error + page_info = entitlements.get("page_info") or {} + partial = bool(isinstance(page_info, dict) and page_info.get("has_next_page")) print( f"Meta entitlements: {len(nodes)} node(s) returned" - f"{'; a page_info field is present (results may be paginated and truncated)' if 'page_info' in entitlements else ''}." + f"{'; Meta reports more pages, so this list is incomplete' if partial else ''}." ) apps = [] skipped_platforms: dict[str, int] = {} @@ -68,8 +86,8 @@ def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: ) continue app_id, name = item.get("id"), item.get("display_name") - slug = item.get("canonical_name") - if app_id and name and slug: + slug = item.get("canonical_name") or "" + if app_id and name: apps.append(OwnedApp(app_id=str(app_id), name=str(name), slug=str(slug))) else: skipped_incomplete += 1 @@ -78,7 +96,7 @@ def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: f"Meta entitlements: skipped {skipped_incomplete} incomplete node(s); " f"skipped by platform: {skipped_platforms}" ) - return sorted(apps, key=lambda app: app.name.lower()) + return OwnedLibrary(sorted(apps, key=lambda app: app.name.lower()), partial=partial) def fetch_account_identity(token: str) -> tuple[str, str] | None: diff --git a/src/riftlift/i18n/en.py b/src/riftlift/i18n/en.py index 6f4f284..81c0d24 100644 --- a/src/riftlift/i18n/en.py +++ b/src/riftlift/i18n/en.py @@ -57,6 +57,9 @@ "not_installed": "Not installed", "version": "Version {version}", "refresh_tooltip": "Refresh installed games and your Meta library", + "partial_list": ( + "Meta returned only part of your library. Add a missing game with Add Game." + ), }, "empty": { "title": "No Rift games yet", diff --git a/src/riftlift/i18n/fr.py b/src/riftlift/i18n/fr.py index 9441ff2..c585f35 100644 --- a/src/riftlift/i18n/fr.py +++ b/src/riftlift/i18n/fr.py @@ -58,6 +58,10 @@ "not_installed": "Non installés", "version": "Version {version}", "refresh_tooltip": "Rafraîchir les jeux installés et ta bibliothèque Meta", + "partial_list": ( + "Meta n'a renvoyé qu'une partie de ta bibliothèque. Ajoute un jeu " + "manquant avec Ajouter un jeu." + ), }, "empty": { "title": "Aucun jeu Rift pour l'instant", diff --git a/src/riftlift/main_window.py b/src/riftlift/main_window.py index b962ef0..149ad80 100644 --- a/src/riftlift/main_window.py +++ b/src/riftlift/main_window.py @@ -121,6 +121,7 @@ class Events(QtCore.QObject): class OwnedEvents(QtCore.QObject): complete = QtCore.Signal(object, object) + icon = QtCore.Signal(int, str, str) class OwnedDetailEvents(QtCore.QObject): @@ -167,6 +168,7 @@ def __init__( set_language(language_preference(self.paths)) self.installed: list[Game] = [] self.owned: list[OwnedApp] = [] + self._owned_icons: dict[str, str] = {} self.selected_owned: OwnedApp | None = None self.slug: str | None = initial_slug self._pending_owned_app_id = initial_owned_app_id @@ -182,6 +184,7 @@ def __init__( self.events.complete.connect(self._finish) self.owned_events = OwnedEvents() self.owned_events.complete.connect(self._finish_owned_scan) + self.owned_events.icon.connect(self._finish_owned_icon) self.owned_detail_events = OwnedDetailEvents() self.owned_detail_events.complete.connect(self._finish_owned_detail) self.game_metadata_events = GameMetadataEvents() @@ -392,8 +395,11 @@ def show_auth(self): if dialog.changed and signed_in: self.refresh_owned() elif dialog.changed: - # Nobody is signed in any more, so nothing is owned. + # Nobody is signed in any more, so nothing is owned; icons a scan + # is still fetching belong to the old list. + self._owned_generation = getattr(self, "_owned_generation", 0) + 1 self.owned = [] + self.owned_hint.hide() self._render_tree() self._update_signin_label() @@ -904,19 +910,30 @@ def open_store(self): QtGui.QDesktopServices.openUrl(QtCore.QUrl(self.selected_owned.store_url)) def refresh_owned(self): + token = self._owned_generation = getattr(self, "_owned_generation", 0) + 1 + def worker(): - try: - with contextlib.redirect_stdout(Output(self.events.output.emit)): + with contextlib.redirect_stdout(Output(self.events.output.emit)): + try: owned, failures = owned_apps(self.paths) - for failure in failures: - print(f"Could not read a Meta account's games: {failure}") - icons = { - app.app_id: fetch_owned_icon(self.paths, app.app_id) - for app in owned - } - self.owned_events.complete.emit((owned, icons), None) - except Exception as error: - self.owned_events.complete.emit(([], {}), error) + except Exception as error: + self.owned_events.complete.emit(([], {}), error) + return + for failure in failures: + print(f"Could not read a Meta account's games: {failure}") + # Show the list now; each icon may need a catalog lookup and a + # download, so they follow one by one instead of holding it back. + self.owned_events.complete.emit((owned, {}), None) + for app in owned: + if token != self._owned_generation: + return + try: + icon = fetch_owned_icon(self.paths, app.app_id) + except Exception as error: + print(f"warning: no icon for {app.name}: {error}") + continue + if icon: + self.owned_events.icon.emit(token, app.app_id, icon) threading.Thread( target=worker, daemon=True, name="riftlift-owned-refresh" @@ -933,9 +950,31 @@ def _finish_owned_scan(self, result, error): return owned, icons = result self.owned = owned - self._owned_icons = icons + # Keep icons a previous scan found until this one replaces them. + app_ids = {app.app_id for app in owned} + self._owned_icons = { + **{ + app_id: path + for app_id, path in self._owned_icons.items() + if app_id in app_ids + }, + **icons, + } + self.owned_hint.setVisible(bool(getattr(owned, "partial", False))) self._render_tree() + def _finish_owned_icon(self, token, app_id, path): + if token != getattr(self, "_owned_generation", 0): + return + self._owned_icons[app_id] = path + item = self._find_item( + self._owned_category, + lambda app: isinstance(app, OwnedApp) and app.app_id == app_id, + ) + icon = rounded_icon(path, 34, 8) + if item is not None and not icon.isNull(): + item.setIcon(0, icon) + def install_owned(self): if self.selected_owned is not None: self.add_dialog( diff --git a/src/riftlift/native_shell.py b/src/riftlift/native_shell.py index 44b2289..816d446 100644 --- a/src/riftlift/native_shell.py +++ b/src/riftlift/native_shell.py @@ -6,6 +6,7 @@ from __future__ import annotations +import unicodedata from importlib.resources import files from PySide6 import QtCore, QtGui, QtWidgets @@ -19,6 +20,17 @@ SHELL = namespace("shell") +def search_words(text: str) -> list[str]: + """Split text into comparable words, ignoring case, accents and punctuation.""" + decomposed = unicodedata.normalize("NFKD", text.casefold()) + plain = "".join( + char if char.isalnum() else " " + for char in decomposed + if not unicodedata.combining(char) + ) + return plain.split() + + def brand_icon() -> QtGui.QIcon: return QtGui.QIcon(str(files("riftlift").joinpath("assets/mark.svg"))) @@ -292,6 +304,10 @@ def _build(self): self.search_empty.setWordWrap(True) self.search_empty.hide() left.addWidget(self.search_empty) + self.owned_hint = self.label(LIBRARY("partial_list"), "muted") + self.owned_hint.setWordWrap(True) + self.owned_hint.hide() + left.addWidget(self.owned_hint) self.steam_games = self.button(NAV("steam_games"), self.steam_dialog) self.addbtn = self.button(NAV("add_game"), lambda: self.add_dialog()) left.addWidget(self.steam_games) @@ -499,13 +515,15 @@ def action(text, callback): return page def _filter_library(self, text=""): - query = text.strip().casefold() + # Every typed word must start or occur in the title, in any order. + query = search_words(text) matches = 0 for category, _key in self._categories: visible = 0 for index in range(category.childCount()): item = category.child(index) - match = query in item.text(0).casefold() + title = " ".join(search_words(item.text(0))) + match = all(word in title for word in query) item.setHidden(not match) visible += int(match) category.setHidden(visible == 0) diff --git a/tests/test_entitlements.py b/tests/test_entitlements.py new file mode 100644 index 0000000..c8366e7 --- /dev/null +++ b/tests/test_entitlements.py @@ -0,0 +1,88 @@ +import io +import json + +from riftlift import entitlements +from riftlift.entitlements import OwnedApp, list_owned_pcvr_apps + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +def _serve(monkeypatch, nodes, page_info=None) -> None: + connection = {"nodes": nodes} + if page_info is not None: + connection["page_info"] = page_info + payload = {"data": {"viewer": {"user": {"active_entitlements": connection}}}} + monkeypatch.setattr( + entitlements.urllib.request, + "urlopen", + lambda *_args, **_kwargs: _Response(json.dumps(payload).encode()), + ) + + +def _item(app_id, name, slug, platform="PC"): + return { + "item": { + "id": app_id, + "display_name": name, + "canonical_name": slug, + "platform": platform, + } + } + + +def test_owned_apps_without_a_canonical_name_are_kept(monkeypatch) -> None: + _serve( + monkeypatch, + [_item("111", "Echo VR", None), _item("222", "Lone Echo", "lone-echo")], + ) + + owned = list_owned_pcvr_apps("token") + + assert [app.name for app in owned] == ["Echo VR", "Lone Echo"] + assert owned[0].store_url == "https://www.meta.com/experiences/pcvr/111/" + assert owned[1].store_url == ( + "https://www.meta.com/experiences/pcvr/lone-echo/222/" + ) + + +def test_owned_apps_skip_other_platforms_and_nameless_items(monkeypatch) -> None: + _serve( + monkeypatch, + [ + _item("1", "Quest Game", "quest", platform="ANDROID_6DOF"), + _item("2", None, "nameless"), + _item("3", "Stormland", "stormland"), + ], + ) + + assert list_owned_pcvr_apps("token") == [ + OwnedApp(app_id="3", name="Stormland", slug="stormland") + ] + + +def test_owned_apps_report_a_list_meta_cut_short(monkeypatch) -> None: + _serve( + monkeypatch, + [_item("1", "Stormland", "stormland")], + page_info={"has_next_page": True, "end_cursor": "abc"}, + ) + + assert list_owned_pcvr_apps("token").partial is True + + +def test_owned_apps_are_complete_without_a_next_page(monkeypatch) -> None: + _serve( + monkeypatch, + [_item("1", "Stormland", "stormland")], + page_info={"has_next_page": False}, + ) + assert list_owned_pcvr_apps("token").partial is False + + _serve(monkeypatch, [_item("1", "Stormland", "stormland")]) + assert list_owned_pcvr_apps("token").partial is False diff --git a/tests/test_owned_library.py b/tests/test_owned_library.py new file mode 100644 index 0000000..fb166f5 --- /dev/null +++ b/tests/test_owned_library.py @@ -0,0 +1,176 @@ +import os +import threading +import time +from pathlib import Path + +if os.name != "nt": + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6 import QtWidgets + +from riftlift import auth +from riftlift.auth import owned_apps, save_access_token +from riftlift.config import Paths +from riftlift.entitlements import OwnedApp, OwnedLibrary +from riftlift.main_window import Window +from riftlift.native_shell import search_words + +ICON = str(Path(__file__).parents[1] / "src/riftlift/assets/mark.svg") + + +def _wait_until(app, condition, timeout: float = 2) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if condition(): + return True + time.sleep(0.01) + return condition() + + +def _paths(tmp_path: Path) -> Paths: + paths = Paths( + *( + tmp_path / name + for name in ("data", "cache", "config", "games", "prefix", "tools") + ) + ) + paths.create() + return paths + + +def _owned_window(tmp_path, monkeypatch, owned, fetch_icon): + monkeypatch.setattr("riftlift.main_window.owned_apps", lambda _paths: (owned, [])) + monkeypatch.setattr("riftlift.main_window.fetch_owned_icon", fetch_icon) + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + window = Window(_paths(tmp_path), start_services=False) + window.refresh_owned() + return app, window + + +def _visible_titles(window) -> list[str]: + category = window._owned_category + return [ + category.child(index).text(0) + for index in range(category.childCount()) + if not category.child(index).isHidden() + ] + + +def test_owned_games_show_before_their_icons_arrive(tmp_path, monkeypatch) -> None: + release = threading.Event() + + def slow_icon(_paths, _app_id): + release.wait(5) + return ICON + + owned = [ + OwnedApp("1", "Lone Echo", "lone-echo"), + OwnedApp("2", "Stormland", "stormland"), + ] + app, window = _owned_window(tmp_path, monkeypatch, owned, slow_icon) + + assert _wait_until(app, lambda: window._owned_category.childCount() == 2) + placeholders = [ + window._owned_category.child(index).icon(0).cacheKey() for index in range(2) + ] + assert window._owned_icons == {} + + release.set() + assert _wait_until(app, lambda: set(window._owned_icons) == {"1", "2"}) + assert all( + window._owned_category.child(index).icon(0).cacheKey() != placeholders[index] + for index in range(2) + ) + window.close() + app.processEvents() + + +def test_a_failed_icon_keeps_the_owned_game_listed(tmp_path, monkeypatch) -> None: + def broken_icon(_paths, app_id): + if app_id == "1": + raise OSError("no catalog page") + return ICON + + owned = [OwnedApp("1", "Echo VR", ""), OwnedApp("2", "Stormland", "stormland")] + app, window = _owned_window(tmp_path, monkeypatch, owned, broken_icon) + + assert _wait_until(app, lambda: "2" in window._owned_icons) + assert _visible_titles(window) == ["Echo VR", "Stormland"] + assert "1" not in window._owned_icons + window.close() + app.processEvents() + + +def test_a_partial_owned_list_says_so(tmp_path, monkeypatch) -> None: + owned = OwnedLibrary([OwnedApp("1", "Stormland", "stormland")], partial=True) + app, window = _owned_window(tmp_path, monkeypatch, owned, lambda *_: None) + + assert _wait_until(app, lambda: window.owned) + assert not window.owned_hint.isHidden() + assert "Add Game" in window.owned_hint.text() + + window.owned = [] + monkeypatch.setattr( + "riftlift.main_window.owned_apps", + lambda _paths: (OwnedLibrary([OwnedApp("1", "Stormland", "stormland")]), []), + ) + window.refresh_owned() + assert _wait_until(app, lambda: window.owned_hint.isHidden()) + window.close() + app.processEvents() + + +def test_owned_apps_stays_partial_when_any_account_was_cut_short( + tmp_path, monkeypatch +) -> None: + paths = _paths(tmp_path) + save_access_token(paths, "FRL" + "a" * 176, ("meta-1", "Alpha")) + save_access_token(paths, "FRL" + "b" * 176, ("meta-2", "Beta")) + libraries = { + "a": OwnedLibrary([OwnedApp("1", "Lone Echo", "lone-echo")]), + "b": OwnedLibrary([OwnedApp("2", "Stormland", "stormland")], partial=True), + } + monkeypatch.setattr( + auth.entitlements, "list_owned_pcvr_apps", lambda token: libraries[token[3]] + ) + + owned, failures = owned_apps(paths) + assert [app.name for app in owned] == ["Lone Echo", "Stormland"] + assert owned.partial and failures == [] + + libraries["b"].partial = False + assert not owned_apps(paths)[0].partial + + +def test_library_search_ignores_word_order_accents_and_punctuation( + tmp_path, monkeypatch +) -> None: + owned = [ + OwnedApp("1", "Lone Echo", "lone-echo"), + OwnedApp("2", "Vader Immortal: Episode I", "vader-immortal-episode-i"), + OwnedApp("3", "Pokémon Café", "pokemon-cafe"), + OwnedApp("4", "Stormland", "stormland"), + ] + app, window = _owned_window(tmp_path, monkeypatch, owned, lambda *_: None) + assert _wait_until(app, lambda: window._owned_category.childCount() == 4) + + for query, expected in ( + ("echo lone", ["Lone Echo"]), + ("vader episode", ["Vader Immortal: Episode I"]), + ("immortal:", ["Vader Immortal: Episode I"]), + ("pokemon cafe", ["Pokémon Café"]), + ("storm", ["Stormland"]), + ("", ["Lone Echo", "Vader Immortal: Episode I", "Pokémon Café", "Stormland"]), + ): + window.search.setText(query) + assert _visible_titles(window) == expected, query + window.search.setText("nothing like this") + assert window.search_empty.isVisibleTo(window) + window.close() + app.processEvents() + + +def test_search_words_normalise_case_accents_and_punctuation() -> None: + assert search_words(" Pokémon: CAFÉ-Racer ") == ["pokemon", "cafe", "racer"] + assert search_words("") == [] From c87c2e28b38ea55e9b0f37b1e82193d12fb4b98a Mon Sep 17 00:00:00 2001 From: marshmallow-mia Date: Thu, 8 Oct 2026 17:46:38 +0200 Subject: [PATCH 2/4] Library search: keep apostrophes inside their word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search split titles at every punctuation mark, so "Lucky’s Tale" became "lucky s tale" and typing "luckys" found nothing. Apostrophes now stay part of their word and are dropped: "luckys", "lucky's" and "lucky" all find it. --- src/riftlift/native_shell.py | 4 +++- tests/test_owned_library.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/riftlift/native_shell.py b/src/riftlift/native_shell.py index 816d446..74e9637 100644 --- a/src/riftlift/native_shell.py +++ b/src/riftlift/native_shell.py @@ -18,6 +18,7 @@ GAME = namespace("game") LIBRARY = namespace("library") SHELL = namespace("shell") +APOSTROPHES = "'\u2019\u02bc" def search_words(text: str) -> list[str]: @@ -26,7 +27,8 @@ def search_words(text: str) -> list[str]: plain = "".join( char if char.isalnum() else " " for char in decomposed - if not unicodedata.combining(char) + # An apostrophe belongs to its word: "Lucky's" is found as "luckys" too. + if not unicodedata.combining(char) and char not in APOSTROPHES ) return plain.split() diff --git a/tests/test_owned_library.py b/tests/test_owned_library.py index fb166f5..98b1865 100644 --- a/tests/test_owned_library.py +++ b/tests/test_owned_library.py @@ -173,4 +173,6 @@ def test_library_search_ignores_word_order_accents_and_punctuation( def test_search_words_normalise_case_accents_and_punctuation() -> None: assert search_words(" Pokémon: CAFÉ-Racer ") == ["pokemon", "cafe", "racer"] + assert search_words("Lucky\u2019s Tale") == search_words("lucky's tale") + assert search_words("Lucky\u2019s Tale") == ["luckys", "tale"] assert search_words("") == [] From 17131671c44362971e2238f95825cf076dca2ed4 Mon Sep 17 00:00:00 2001 From: marshmallow-mia Date: Thu, 8 Oct 2026 19:23:23 +0200 Subject: [PATCH 3/4] Drop a worker's result when the window is already gone The library's background workers (owned games, icons, game details, store details, setup and system status) report through event objects of the main window. When those were deleted while a worker was still fetching, such as on quit, emit raised "Signal source has been deleted"; the except branch then emitted again, and the error escaped the thread. Results and printed lines that have nowhere to go are now dropped. --- src/riftlift/main_window.py | 64 +++++++++++++++++++++++++------------ tests/test_gui.py | 41 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/riftlift/main_window.py b/src/riftlift/main_window.py index 149ad80..575f9e5 100644 --- a/src/riftlift/main_window.py +++ b/src/riftlift/main_window.py @@ -141,13 +141,25 @@ class SystemStatusEvents(QtCore.QObject): complete = QtCore.Signal(bool, str) +def _deliver(signal, *args) -> None: + """Hand a worker thread's result to the window, unless it is gone. + + Closing the window or quitting deletes its event objects while a worker + may still be fetching; the result then has nowhere to go. + """ + with contextlib.suppress(RuntimeError): # "Signal source has been deleted" + signal.emit(*args) + + class Output(io.TextIOBase): def __init__(self, emit: Callable[[str], None]): self.emit = emit def write(self, value: str) -> int: if value: - self.emit(value) + # Workers print through this too; see _deliver. + with contextlib.suppress(RuntimeError): + self.emit(value) return len(value) def flush(self) -> None: @@ -241,9 +253,9 @@ def _load_owned_detail(self, app, *, refresh=False): def _check_setup_status(self) -> None: def worker(): try: - self.setup_status_events.complete.emit(needs_setup(self.paths)) + _deliver(self.setup_status_events.complete, needs_setup(self.paths)) except (OSError, RiftLiftError) as error: - self.setup_status_events.failed.emit(str(error)) + _deliver(self.setup_status_events.failed, str(error)) threading.Thread( target=worker, daemon=True, name="riftlift-setup-check" @@ -265,21 +277,25 @@ def worker(): try: missing = needs_setup(self.paths) except (OSError, RiftLiftError) as error: - self.system_status_events.complete.emit(False, str(error)) + _deliver(self.system_status_events.complete, False, str(error)) return if missing: - self.system_status_events.complete.emit( - False, SETUP("status_needs_setup") + _deliver( + self.system_status_events.complete, + False, + SETUP("status_needs_setup"), ) return try: active_runtime_json() except RiftLiftError: - self.system_status_events.complete.emit( - False, SETUP("status_no_openxr_runtime") + _deliver( + self.system_status_events.complete, + False, + SETUP("status_no_openxr_runtime"), ) return - self.system_status_events.complete.emit(True, SETUP("status_ok")) + _deliver(self.system_status_events.complete, True, SETUP("status_ok")) threading.Thread( target=worker, daemon=True, name="riftlift-status-check" @@ -610,11 +626,13 @@ def worker(): if is_steam else fetch_catalog_metadata(app_id) ) - self.game_metadata_events.complete.emit( - token, game.slug, metadata, None + _deliver( + self.game_metadata_events.complete, token, game.slug, metadata, None ) except Exception as error: - self.game_metadata_events.complete.emit(token, game.slug, None, error) + _deliver( + self.game_metadata_events.complete, token, game.slug, None, error + ) threading.Thread( target=worker, daemon=True, name="riftlift-description-refresh" @@ -671,11 +689,17 @@ def show_owned(self, app: OwnedApp, *, refresh: bool = False): def worker(): try: metadata, hero = self._load_owned_detail(app, refresh=refresh) - self.owned_detail_events.complete.emit( - token, app.app_id, (metadata, hero), None + _deliver( + self.owned_detail_events.complete, + token, + app.app_id, + (metadata, hero), + None, ) except Exception as error: - self.owned_detail_events.complete.emit(token, app.app_id, None, error) + _deliver( + self.owned_detail_events.complete, token, app.app_id, None, error + ) threading.Thread( target=worker, daemon=True, name="riftlift-owned-detail" @@ -917,13 +941,13 @@ def worker(): try: owned, failures = owned_apps(self.paths) except Exception as error: - self.owned_events.complete.emit(([], {}), error) + _deliver(self.owned_events.complete, ([], {}), error) return for failure in failures: print(f"Could not read a Meta account's games: {failure}") # Show the list now; each icon may need a catalog lookup and a # download, so they follow one by one instead of holding it back. - self.owned_events.complete.emit((owned, {}), None) + _deliver(self.owned_events.complete, (owned, {}), None) for app in owned: if token != self._owned_generation: return @@ -933,7 +957,7 @@ def worker(): print(f"warning: no icon for {app.name}: {error}") continue if icon: - self.owned_events.icon.emit(token, app.app_id, icon) + _deliver(self.owned_events.icon, token, app.app_id, icon) threading.Thread( target=worker, daemon=True, name="riftlift-owned-refresh" @@ -1036,9 +1060,9 @@ def worker(): contextlib.redirect_stderr(Output(self.events.output.emit)), ): result = operation() - self.events.complete.emit(success, result, refresh, None) + _deliver(self.events.complete, success, result, refresh, None) except Exception as error: - self.events.complete.emit("", None, False, error) + _deliver(self.events.complete, "", None, False, error) threading.Thread(target=worker, daemon=True, name="riftlift-operation").start() diff --git a/tests/test_gui.py b/tests/test_gui.py index f131fbf..c13d7f5 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1957,6 +1957,47 @@ def test_refresh_button_forces_owned_detail_refresh( app.processEvents() +def test_a_detail_fetch_finishing_after_close_is_dropped( + tmp_path: Path, monkeypatch +) -> None: + import shiboken6 + + paths = Paths( + tmp_path / "data", + tmp_path / "cache", + tmp_path / "config", + tmp_path / "games", + tmp_path / "prefix", + tmp_path / "tools", + ) + paths.create() + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + window = Window(paths) + started, release = threading.Event(), threading.Event() + + def slow_detail(_app, refresh=False): + started.set() + release.wait(5) + return None, None + + monkeypatch.setattr(window, "_load_owned_detail", slow_detail) + crashes = [] + monkeypatch.setattr(threading, "excepthook", crashes.append) + + window.show_owned(OwnedApp(app_id="222", name="Lone Echo", slug="lone-echo")) + assert started.wait(5) + worker = next(t for t in threading.enumerate() if t.name == "riftlift-owned-detail") + # Closing the window deletes its event objects while the worker still runs. + shiboken6.delete(window.owned_detail_events) + release.set() + worker.join(5) + + assert not worker.is_alive() + assert crashes == [] + window.close() + app.processEvents() + + def test_owned_game_meta_line_keeps_saying_not_installed( tmp_path: Path, monkeypatch ) -> None: From 5b66fd208fbd6472b29d69881f47c048c58a2237 Mon Sep 17 00:00:00 2001 From: marshmallow-mia Date: Thu, 8 Oct 2026 23:04:59 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Library=20search:=20fold=20=C3=B8,=20=C3=A6?= =?UTF-8?q?,=20=C5=93,=20=C5=82=20and=20=C4=91,=20and=20treat=20=E2=80=98?= =?UTF-8?q?=20as=20an=20apostrophe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search ignores accents by decomposing letters, but these letters have no decomposition, so "orsted" didn't find "Ørsted". They now fold to their plain spelling. The left single quotation mark, sometimes typed as an apostrophe, now stays part of its word like the other apostrophes. --- src/riftlift/native_shell.py | 9 +++++++-- tests/test_owned_library.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/riftlift/native_shell.py b/src/riftlift/native_shell.py index 74e9637..b47f253 100644 --- a/src/riftlift/native_shell.py +++ b/src/riftlift/native_shell.py @@ -18,12 +18,17 @@ GAME = namespace("game") LIBRARY = namespace("library") SHELL = namespace("shell") -APOSTROPHES = "'\u2019\u02bc" +APOSTROPHES = "'\u2018\u2019\u02bc" +# Letters that Unicode doesn't decompose into a base letter and an accent. +_FOLDED_LETTERS = str.maketrans( + {"\u00f8": "o", "\u00e6": "ae", "\u0153": "oe", "\u0142": "l", "\u0111": "d"} +) def search_words(text: str) -> list[str]: """Split text into comparable words, ignoring case, accents and punctuation.""" - decomposed = unicodedata.normalize("NFKD", text.casefold()) + folded = text.casefold().translate(_FOLDED_LETTERS) + decomposed = unicodedata.normalize("NFKD", folded) plain = "".join( char if char.isalnum() else " " for char in decomposed diff --git a/tests/test_owned_library.py b/tests/test_owned_library.py index 98b1865..c64e5bd 100644 --- a/tests/test_owned_library.py +++ b/tests/test_owned_library.py @@ -176,3 +176,15 @@ def test_search_words_normalise_case_accents_and_punctuation() -> None: assert search_words("Lucky\u2019s Tale") == search_words("lucky's tale") assert search_words("Lucky\u2019s Tale") == ["luckys", "tale"] assert search_words("") == [] + + +def test_search_words_fold_letters_without_an_accent_mark() -> None: + # These letters don't decompose, so stripping accents alone keeps them. + assert search_words("Ørsted Æble Łódź Œuvre Đakovo") == [ + "orsted", + "aeble", + "lodz", + "oeuvre", + "dakovo", + ] + assert search_words("Lucky\u2018s Tale") == ["luckys", "tale"]