Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/riftlift/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
28 changes: 23 additions & 5 deletions src/riftlift/entitlements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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] = {}
Expand All @@ -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
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/riftlift/i18n/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/riftlift/i18n/fr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 93 additions & 30 deletions src/riftlift/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -140,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:
Expand All @@ -167,6 +180,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
Expand All @@ -182,6 +196,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()
Expand Down Expand Up @@ -238,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"
Expand All @@ -262,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"
Expand Down Expand Up @@ -392,8 +411,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()

Expand Down Expand Up @@ -604,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"
Expand Down Expand Up @@ -665,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"
Expand Down Expand Up @@ -904,19 +934,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:
_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.
_deliver(self.owned_events.complete, (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:
_deliver(self.owned_events.icon, token, app.app_id, icon)

threading.Thread(
target=worker, daemon=True, name="riftlift-owned-refresh"
Expand All @@ -933,9 +974,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(
Expand Down Expand Up @@ -997,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()

Expand Down
Loading