diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a0a3f2b..3c2c6bc 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,2 @@ custom: - "https://revolut.me/paulcombal" - - "https://wise.com/pay/me/combaldieu" diff --git a/README.md b/README.md index 4ce6f3c..72170de 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ SamRewritten

SamRewritten screenshot - GTK version preview + Adwaita version preview

SamRewritten screenshot - Adwaita version preview + GTK version preview

A Steam Achievement Manager for Windows and Linux.

diff --git a/assets/org.samrewritten.SamRewritten.gschema.xml b/assets/org.samrewritten.SamRewritten.gschema.xml index 616623b..0213147 100644 --- a/assets/org.samrewritten.SamRewritten.gschema.xml +++ b/assets/org.samrewritten.SamRewritten.gschema.xml @@ -144,5 +144,17 @@ Keep A History Of Changes Whether every change SamRewritten makes is recorded to a local history file, so it can be reviewed and undone. Turning this off stops recording; anything already recorded is kept. + + + '' + Steam Collection To Show + Id of the Steam library collection the games list is limited to, as Steam stores it: 'favorite' for your favourites, 'from-tag-' followed by a tag name for one Steam made from a tag, or 'uc-' followed by random characters for one you created. An empty string shows every game. + + + + false + Hide Games Hidden In Steam + Whether games you placed in Steam's own Hidden collection are left out of the games list. + \ No newline at end of file diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index dbb5bc9..137df5a 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -2,7 +2,23 @@ ## Process model -![Architectural software schema](samdoc.drawio.png) +```mermaid +flowchart TB + UI["Front-end
GUI (GTK4) or CLI
the process the user launches"] + ORCH["Orchestrator
samrewritten --orchestrator
exactly one per front-end"] + A1["App server
--app=480"] + A2["App server
--app=220"] + A3["… up to 30 at once"] + STEAM[["Steam client
steamclient.so"]] + + UI <-->|"length-prefixed JSON
over a pair of unnamed pipes"| ORCH + ORCH <-->|"one pipe pair per child"| A1 + ORCH <--> A2 + ORCH -.-> A3 + ORCH <-->|"calls needing no app id:
owned apps, achievement counts,
collections, identity"| STEAM + A1 <-->|"SteamAPI_Init for 480 —
this is what holds the in-game presence"| STEAM + A2 <--> STEAM +``` Three kinds of process. They are all the same binary (`samrewritten`); the role is selected by command-line flags routed in `src/main.rs`. @@ -42,6 +58,164 @@ the "I'm running game X" presence holder. lock to serialize traffic on that pipe. (`gui_frontend::request` is a thin re-export kept for the GUI's existing imports.) +## Loading order in the GUI + +Nothing in the front-end blocks on Steam. Every front-end to orchestrator call +runs on a `spawn_blocking` worker with a `MainContext::spawn_local` +continuation, so the window stays interactive from the moment it appears; the +one deliberate exception is marked below. + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant G as GUI main thread + participant O as Orchestrator + participant S as Steam client + participant D as Steam files on disk + + U->>G: launch + G->>G: i18n, GSettings, build every widget + G->>D: enumerate Steam installs + opt several installs found + G-->>U: install chooser + end + G->>O: spawn --orchestrator + O->>O: join Flatpak Steam PID namespace, Linux only + G->>O: SetStealthMode — the one blocking call + G-->>U: window.present — the list area is a spinner,
the sidebar is not up yet + + par library + G->>O: GetSubscribedAppList + O->>S: connect — first Steam call of the session + O->>D: apps.xml, re-downloaded only if over 7 days old + O->>S: get_subscribed_apps + O->>D: localconfig.vdf — playtime and last played + O-->>G: the owned-app list + G->>G: fill the list store, swap the stack to the list page + G-->>U: spinner gone — grid and sidebar filters appear together + and identity + G->>O: GetCurrentUser + O-->>G: steam id + G->>O: GetUserPersonaName + O-->>G: name, into the sidebar + G->>O: GetUserAvatar + O-->>G: avatar, into the sidebar + end + + Note over G,U: Everything below lands in an already-visible window:
badges, the collection dropdown and idle state fill in
under the user, who can already scroll, search and filter. + + Note over G: The count prefill needs both the library and the
steam id, so whichever of the two lands second starts it. + G->>D: LocalIndex::read_all — achievement counts from Steam's own files + D-->>G: counts for most of the library at once + + G->>O: GetCollections, with the library it just loaded + O->>D: cloud-storage-namespace-1.json, about 0.2 ms + opt a dynamic collection needs app metadata + O->>D: appinfo.vdf and libraryfolders.vdf, about 25 ms + end + O-->>G: resolved collections + G-->>U: the sidebar collection dropdown fills in + + G->>O: GetRunningApps + O-->>G: which apps are already idling + + loop each card scrolled into view + G->>D: banner, from the local index then the disk cache + G->>O: GetAchievementCounts, 8 apps per chunk + Note over G,O: only for apps the local files could not settle + end + + U->>G: opens the profile page + G->>D: read_all_unlock_stamps — every stats file for the account + D-->>G: heatmap and completion curve + + U->>G: opens a game + G->>O: GetAchievementsAndStats, launch = true + O->>O: spawn samrewritten --app=440 + O->>S: SteamAPI_Init for 440 + O-->>G: achievements, stats and schema languages +``` + +### Before the window exists + +`create_main_ui` runs to completion first: translations, GSettings, and the +**whole** widget tree — app list, manage view, sidebar and profile page are all +built up front, then swapped by `GtkStack` rather than constructed on demand. +No Steam call has happened at this point and nothing has been read from disk +except the compiled schema and the install enumeration. If more than one Steam +install is present, the chooser dialog blocks here, before the main window. + +### At `window.present()` + +The orchestrator child already exists, has joined the Flatpak PID namespace if +needed, and has answered one **synchronous** `SetStealthMode` on the main +thread. The window then opens on a spinner and the library request is already +in flight. The sidebar is not visible yet: it lives *inside* the stack's list +page rather than beside the stack, so the filters arrive with the grid, not +before it. + +### Two independent branches + +The library (`GetSubscribedAppList`) and the identity (`GetCurrentUser`, then +name, then avatar) are unrelated requests racing each other. The identity chain +is sequential because each step needs the steam id from the first. + +`GetSubscribedAppList` is the first thing to actually touch Steam, so the +orchestrator's own connection is established there — not at spawn. It is also +the only startup step that makes an HTTP request of its own, and only when the +cached `apps.xml` is more than seven days old; banner downloads come later, one +card at a time. + +### When the spinner goes away + +The instant `GetSubscribedAppList` returns. The handler fills the list store and +switches `list_stack` to its list page in the same tick, which reveals the grid +and the sidebar together, and re-enables the search entry that was greyed for +the duration. + +That switch happens **before** `on_library_loaded()`, so the count prefill and +the collections fetch have not even started when the user first sees the list. +What is on screen at that moment is app names, local banners and playtime; +achievement badges fade in as counts settle, and the collection dropdown holds +nothing but "All games" until `GetCollections` answers. + +An empty library, and a library download that failed outright, both still reach +the list page — with an explanatory label where the grid would be, so the +sidebar and the search box stay usable. Any other error is the one case that +never gets there: the stack switches to a separate "disconnected" page instead. + +### The rendezvous + +`prefill_counts` needs the steam id *and* a non-empty list, so it is called from +both branches and does nothing until the second one arrives. It then reads +achievement counts straight out of Steam's own files for the whole library in +one worker pass — this is why most cards show their count without a single IPC +round trip. + +### After the library lands + +`on_library_loaded` fires the collection fetch and the idle-state sync. The +collections file is re-read on every refresh rather than cached: it is a few KB, +and Steam rewrites it within a second of any change. `appinfo.vdf` is only +touched when a dynamic collection actually needs it, and then only for the app +ids the caller owns. + +### Lazily, while you scroll + +Banners resolve local-first (an on-disk index, rebuilt on each refresh), then a +temp-dir cache, then the CDN. Achievement counts for whatever the local files +could not settle are fetched in chunks of 8, prioritised by what is on screen — +a card binding jumps its own app to the front of the queue. A filter or sort +that needs counts escalates this to a full sweep of the library. + +### On demand + +The profile page reads every unlock timestamp for the account when opened, and +throttles re-reads afterwards (1 s for the tiles, 5 s for the history). Opening +a game spawns a long-lived app server, which is what makes Steam show you as +in-game; idling does the same, and bulk operations spawn short-lived ones. + ## Bulk operations Multi-app operations (export, import, mass unlock, mass reset) are each a @@ -209,6 +383,12 @@ the compiled schema into `$SNAP/usr/share/glib-2.0/schemas/` via the * `stat_definitions.rs` — `AchievementInfo`, `StatInfo` (Int/Float), permission bit semantics. * `local_config.rs` — `localconfig.vdf` parser (playtime, last-played). + * `steam_collections.rs` — Steam library collections: parses the client's + on-disk mirror, reproduces Valve's own filter evaluation for dynamic ones, + and refuses (rather than guesses) any filter it cannot answer faithfully. + * `app_info.rs` — targeted `appinfo.vdf` reader, used only by the above: + skips app bodies by their length and decodes just the `common` fields the + collection filters need. * `local_stats.rs`, `key_value.rs` — on-disk fast path for achievement counts, over the Steam binary KeyValue parser. * `user_unlock_times/` — bulk parse of on-disk unlock timestamps, and the diff --git a/docs/img/screenshot1.png b/docs/img/screenshot1.png index 55f1974..492e035 100644 Binary files a/docs/img/screenshot1.png and b/docs/img/screenshot1.png differ diff --git a/docs/img/screenshot2.png b/docs/img/screenshot2.png index 268b392..c59d583 100644 Binary files a/docs/img/screenshot2.png and b/docs/img/screenshot2.png differ diff --git a/docs/samdoc.drawio b/docs/samdoc.drawio deleted file mode 100644 index 8bdb060..0000000 --- a/docs/samdoc.drawio +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/samdoc.drawio.png b/docs/samdoc.drawio.png deleted file mode 100644 index ef9646a..0000000 Binary files a/docs/samdoc.drawio.png and /dev/null differ diff --git a/po/de.po b/po/de.po index a88b282..cded503 100644 --- a/po/de.po +++ b/po/de.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-30 23:21+0200\n" +"POT-Creation-Date: 2026-09-02 21:40+0200\n" "PO-Revision-Date: 2026-08-26 19:00+0200\n" "Last-Translator: Christian Lauinger \n" "Language-Team: \n" @@ -18,7 +18,7 @@ msgstr "" "X-Generator: Poedit 3.9\n" #: src/gui_frontend/ui_components.rs:159 src/gui_frontend/app_view.rs:62 -#: src/gui_frontend/app_list_view/mod.rs:380 +#: src/gui_frontend/app_list_view/mod.rs:421 msgid "Loading..." msgstr "Wird geladen …" @@ -125,7 +125,7 @@ msgstr "OK" #: src/gui_frontend/dialogs.rs:123 src/gui_frontend/dialogs.rs:150 #: src/gui_frontend/profile_view/mod.rs:587 #: src/gui_frontend/app_list_view/progress_actions.rs:424 -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Cancel" msgstr "Abbrechen" @@ -150,9 +150,9 @@ msgstr "Steam ist bereits installiert?" #: src/gui_frontend/dialogs.rs:290 msgid "" "If you've installed Steam in a custom location, you can point SamRewritten " -"to it using environment variables. Please check the GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Falls Steam an einem benutzerdefinierten Ort installiert wurde, können Sie " "SamRewritten mithilfe von Umgebungsvariablen darauf verweisen. Eine " @@ -329,32 +329,32 @@ msgstr "Ziel bereits erreicht oder überschritten ({progress})" msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "{count} Errungenschaft(en) automatisch auffüllen ({progress})" -#: src/gui_frontend/achievement_manual_view/header.rs:49 +#: src/gui_frontend/achievement_manual_view/header.rs:50 msgid "Instant" msgstr "Sofort" -#: src/gui_frontend/achievement_manual_view/header.rs:52 +#: src/gui_frontend/achievement_manual_view/header.rs:53 msgid "Stage" msgstr "Vormerken" -#: src/gui_frontend/achievement_manual_view/header.rs:56 +#: src/gui_frontend/achievement_manual_view/header.rs:57 msgid "Copy user" msgstr "Benutzer kopieren" -#: src/gui_frontend/achievement_manual_view/header.rs:74 +#: src/gui_frontend/achievement_manual_view/header.rs:75 msgid "Auto-fill" msgstr "Automatisch auffüllen" -#: src/gui_frontend/achievement_manual_view/header.rs:77 +#: src/gui_frontend/achievement_manual_view/header.rs:78 msgid "Configuration" msgstr "Konfiguration" -#: src/gui_frontend/achievement_manual_view/header.rs:103 +#: src/gui_frontend/achievement_manual_view/header.rs:104 #: src/gui_frontend/achievement_manual_view/copy_controls.rs:48 msgid "Start" msgstr "Starten" -#: src/gui_frontend/achievement_manual_view/header.rs:108 +#: src/gui_frontend/achievement_manual_view/header.rs:109 msgid "Changes apply instantly" msgstr "Änderungen werden sofort angewendet" @@ -448,114 +448,153 @@ msgstr "Freunde suchen oder eine SteamID64 einfügen" msgid "Clear selected user" msgstr "Ausgewählten Benutzer entfernen" -#: src/gui_frontend/app_list_view/mod.rs:401 +#: src/gui_frontend/app_list_view/mod.rs:442 msgid "SamRewritten could not connect to Steam. Is it running?" msgstr "" "SamRewritten konnte keine Verbindung zu Steam herstellen. Wird Steam " "ausgeführt?" -#: src/gui_frontend/app_list_view/mod.rs:406 +#: src/gui_frontend/app_list_view/mod.rs:447 msgid "Try again" msgstr "Erneut versuchen" -#: src/gui_frontend/app_list_view/mod.rs:423 -#: src/gui_frontend/app_list_view/mod.rs:1605 +#: src/gui_frontend/app_list_view/mod.rs:464 +#: src/gui_frontend/app_list_view/mod.rs:1752 msgid "Name or AppId (Ctrl+K)" msgstr "Name oder App-ID (Strg+K)" -#: src/gui_frontend/app_list_view/mod.rs:439 +#: src/gui_frontend/app_list_view/mod.rs:480 msgid "Show or hide the sidebar" msgstr "Seitenleiste ein- oder ausblenden" -#: src/gui_frontend/app_list_view/mod.rs:1600 +#: src/gui_frontend/app_list_view/mod.rs:1747 msgid "Achievement or stat..." msgstr "Errungenschaft oder Statistik …" -#: src/gui_frontend/app_list_view/mod.rs:1719 -#: src/gui_frontend/app_list_view/settings_bindings.rs:265 +#: src/gui_frontend/app_list_view/mod.rs:1866 +#: src/gui_frontend/app_list_view/settings_bindings.rs:333 msgid "Could not change the in-game setting" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1721 -#: src/gui_frontend/app_list_view/settings_bindings.rs:266 +#: src/gui_frontend/app_list_view/mod.rs:1868 +#: src/gui_frontend/app_list_view/settings_bindings.rs:334 #, fuzzy msgid "The change could not be applied. Restart SamRewritten and try again." msgstr "Die neue Sprache wird beim nächsten Start von SamRewritten angewendet." -#: src/gui_frontend/app_list_view/sidebar.rs:47 +#: src/gui_frontend/app_list_view/sidebar.rs:58 msgid "Hide with no achievements" msgstr "Ohne Errungenschaften ausblenden" -#: src/gui_frontend/app_list_view/sidebar.rs:52 +#: src/gui_frontend/app_list_view/sidebar.rs:64 msgid "Hide at 100%" msgstr "Bei 100 % ausblenden" -#: src/gui_frontend/app_list_view/sidebar.rs:57 +#: src/gui_frontend/app_list_view/sidebar.rs:70 msgid "Hide at 0%" msgstr "Bei 0 % ausblenden" -#: src/gui_frontend/app_list_view/sidebar.rs:62 +#: src/gui_frontend/app_list_view/sidebar.rs:76 msgid "Hide never launched" msgstr "Nie gestartete ausblenden" -#: src/gui_frontend/app_list_view/sidebar.rs:67 +#: src/gui_frontend/app_list_view/sidebar.rs:82 msgid "Only currently idling" msgstr "Nur aktuell im Leerlauf" -#: src/gui_frontend/app_list_view/sidebar.rs:72 +#: src/gui_frontend/app_list_view/sidebar.rs:88 +msgid "Hide hidden in Steam" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:94 msgid "Show junk" msgstr "Unwichtige Apps anzeigen" -#: src/gui_frontend/app_list_view/sidebar.rs:86 +#: src/gui_frontend/app_list_view/sidebar.rs:108 msgid "App ID" msgstr "App-ID" -#: src/gui_frontend/app_list_view/sidebar.rs:91 +#: src/gui_frontend/app_list_view/sidebar.rs:113 msgid "Name" msgstr "Name" -#: src/gui_frontend/app_list_view/sidebar.rs:96 +#: src/gui_frontend/app_list_view/sidebar.rs:118 msgid "Last played" msgstr "Zuletzt gespielt" -#: src/gui_frontend/app_list_view/sidebar.rs:101 +#: src/gui_frontend/app_list_view/sidebar.rs:123 msgid "Playtime" msgstr "Spielzeit" -#: src/gui_frontend/app_list_view/sidebar.rs:106 +#: src/gui_frontend/app_list_view/sidebar.rs:128 msgid "Completion" msgstr "Fortschritt" -#: src/gui_frontend/app_list_view/sidebar.rs:111 +#: src/gui_frontend/app_list_view/sidebar.rs:133 msgid "Achievements left" msgstr "Verbleibende Errungenschaften" -#: src/gui_frontend/app_list_view/sidebar.rs:153 +#: src/gui_frontend/app_list_view/sidebar.rs:183 +msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:186 +msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:189 +msgid "" +"Needs information from Steam that could not be read. Refresh to try again." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:192 +msgid "" +"Needs your friends' games, which Steam can only tell us when it is online." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:201 +msgid "Favorites" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:228 msgid "View profile" msgstr "Profil anzeigen" -#: src/gui_frontend/app_list_view/sidebar.rs:208 +#: src/gui_frontend/app_list_view/sidebar.rs:283 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "Steam-Benutzer" -#: src/gui_frontend/app_list_view/sidebar.rs:229 +#: src/gui_frontend/app_list_view/sidebar.rs:305 +msgid "Steam is offline. What needs its servers is turned off." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:316 msgid "Fetching completion…" msgstr "Fortschritt wird abgerufen …" -#: src/gui_frontend/app_list_view/sidebar.rs:235 +#: src/gui_frontend/app_list_view/sidebar.rs:322 msgid "Click to cancel" msgstr "Zum Abbrechen klicken" -#: src/gui_frontend/app_list_view/sidebar.rs:266 +#: src/gui_frontend/app_list_view/sidebar.rs:353 msgid "Filters" msgstr "Filter" -#: src/gui_frontend/app_list_view/sidebar.rs:274 +#: src/gui_frontend/app_list_view/sidebar.rs:364 +msgid "Steam collection" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:365 +#: src/gui_frontend/app_list_view/sidebar.rs:588 +msgid "All games" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:448 msgid "Sort by" msgstr "Sortieren nach" -#: src/gui_frontend/app_list_view/sidebar.rs:313 +#: src/gui_frontend/app_list_view/sidebar.rs:513 msgid "Reset filters" msgstr "Filter zurücksetzen" @@ -1004,11 +1043,11 @@ msgstr "Beim Rückgängigmachen ist ein unerwarteter Fehler aufgetreten:" msgid "Undo incomplete" msgstr "Rückgängigmachen unvollständig" -#: src/gui_frontend/app_list_view/settings_bindings.rs:138 +#: src/gui_frontend/app_list_view/settings_bindings.rs:159 msgid "The new language will be applied the next time you start SamRewritten." msgstr "Die neue Sprache wird beim nächsten Start von SamRewritten angewendet." -#: src/gui_frontend/app_list_view/settings_bindings.rs:148 +#: src/gui_frontend/app_list_view/settings_bindings.rs:169 msgid "Language changed" msgstr "Sprache geändert" @@ -1207,18 +1246,18 @@ msgstr "" msgid "Import complete" msgstr "Import abgeschlossen" -#: src/gui_frontend/app_list_view/refresh_actions.rs:118 +#: src/gui_frontend/app_list_view/refresh_actions.rs:122 msgid "No apps found on your account. Search for App Id to get started." msgstr "" "In Ihrem Konto wurden keine Apps gefunden. Suchen Sie nach einer App-ID, um " "zu beginnen." -#: src/gui_frontend/app_list_view/refresh_actions.rs:135 +#: src/gui_frontend/app_list_view/refresh_actions.rs:139 msgid "No results. Check for spelling mistakes or try typing an App Id." msgstr "" "Keine Ergebnisse. Prüfen Sie die Schreibweise oder geben Sie eine App-ID ein." -#: src/gui_frontend/app_list_view/refresh_actions.rs:145 +#: src/gui_frontend/app_list_view/refresh_actions.rs:149 msgid "" "Failed to load library. Check your internet connection. Search for App Id to " "get started." @@ -1226,17 +1265,17 @@ msgstr "" "Die Bibliothek konnte nicht geladen werden. Prüfen Sie Ihre " "Internetverbindung. Suchen Sie nach einer App-ID, um zu beginnen." -#: src/gui_frontend/app_list_view/refresh_actions.rs:431 +#: src/gui_frontend/app_list_view/refresh_actions.rs:435 msgid "Reset Everything" msgstr "Alles zurücksetzen" -#: src/gui_frontend/app_list_view/refresh_actions.rs:432 +#: src/gui_frontend/app_list_view/refresh_actions.rs:436 msgid "This will reset all achievements and stats for this app. Are you sure?" msgstr "" "Dadurch werden alle Errungenschaften und Statistiken dieser App " "zurückgesetzt. Sind Sie sicher?" -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Sure, reset" msgstr "Ja, zurücksetzen" diff --git a/po/es.po b/po/es.po index 4f64762..732065c 100644 --- a/po/es.po +++ b/po/es.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-30 23:21+0200\n" +"POT-Creation-Date: 2026-09-02 21:40+0200\n" "PO-Revision-Date: 2026-07-05 00:00+0000\n" "Last-Translator: SamRewritten contributors\n" "Language-Team: Spanish\n" @@ -17,7 +17,7 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: src/gui_frontend/ui_components.rs:159 src/gui_frontend/app_view.rs:62 -#: src/gui_frontend/app_list_view/mod.rs:380 +#: src/gui_frontend/app_list_view/mod.rs:421 msgid "Loading..." msgstr "Cargando..." @@ -124,7 +124,7 @@ msgstr "Aceptar" #: src/gui_frontend/dialogs.rs:123 src/gui_frontend/dialogs.rs:150 #: src/gui_frontend/profile_view/mod.rs:587 #: src/gui_frontend/app_list_view/progress_actions.rs:424 -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Cancel" msgstr "Cancelar" @@ -149,9 +149,9 @@ msgstr "¿Ya tienes Steam instalado?" #: src/gui_frontend/dialogs.rs:290 msgid "" "If you've installed Steam in a custom location, you can point SamRewritten " -"to it using environment variables. Please check the GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Si has instalado Steam en una ubicación personalizada, puedes indicársela a " "SamRewritten mediante variables de entorno. Consulta la GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Si vous avez installé Steam dans un emplacement personnalisé, vous pouvez " "l'indiquer à SamRewritten à l'aide de variables d'environnement. Consultez " @@ -321,32 +321,32 @@ msgstr "Déjà à la cible ou au-dessus ({progress})" msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "Remplir automatiquement {count} succès ({progress})" -#: src/gui_frontend/achievement_manual_view/header.rs:49 +#: src/gui_frontend/achievement_manual_view/header.rs:50 msgid "Instant" msgstr "Instantané" -#: src/gui_frontend/achievement_manual_view/header.rs:52 +#: src/gui_frontend/achievement_manual_view/header.rs:53 msgid "Stage" msgstr "Préparer" -#: src/gui_frontend/achievement_manual_view/header.rs:56 +#: src/gui_frontend/achievement_manual_view/header.rs:57 msgid "Copy user" msgstr "Copier profil" -#: src/gui_frontend/achievement_manual_view/header.rs:74 +#: src/gui_frontend/achievement_manual_view/header.rs:75 msgid "Auto-fill" msgstr "Remplir auto" -#: src/gui_frontend/achievement_manual_view/header.rs:77 +#: src/gui_frontend/achievement_manual_view/header.rs:78 msgid "Configuration" msgstr "Configuration" -#: src/gui_frontend/achievement_manual_view/header.rs:103 +#: src/gui_frontend/achievement_manual_view/header.rs:104 #: src/gui_frontend/achievement_manual_view/copy_controls.rs:48 msgid "Start" msgstr "Démarrer" -#: src/gui_frontend/achievement_manual_view/header.rs:108 +#: src/gui_frontend/achievement_manual_view/header.rs:109 msgid "Changes apply instantly" msgstr "Les changements s'appliquent immédiatement" @@ -440,111 +440,157 @@ msgstr "Rechercher des amis ou coller un SteamID64" msgid "Clear selected user" msgstr "Effacer l'utilisateur sélectionné" -#: src/gui_frontend/app_list_view/mod.rs:401 +#: src/gui_frontend/app_list_view/mod.rs:442 msgid "SamRewritten could not connect to Steam. Is it running?" msgstr "SamRewritten n'a pas pu se connecter à Steam. Est-il bien lancé ?" -#: src/gui_frontend/app_list_view/mod.rs:406 +#: src/gui_frontend/app_list_view/mod.rs:447 msgid "Try again" msgstr "Réessayer" -#: src/gui_frontend/app_list_view/mod.rs:423 -#: src/gui_frontend/app_list_view/mod.rs:1605 +#: src/gui_frontend/app_list_view/mod.rs:464 +#: src/gui_frontend/app_list_view/mod.rs:1752 msgid "Name or AppId (Ctrl+K)" msgstr "Nom ou AppId (Ctrl+K)" -#: src/gui_frontend/app_list_view/mod.rs:439 +#: src/gui_frontend/app_list_view/mod.rs:480 msgid "Show or hide the sidebar" msgstr "Afficher ou masquer la barre latérale" -#: src/gui_frontend/app_list_view/mod.rs:1600 +#: src/gui_frontend/app_list_view/mod.rs:1747 msgid "Achievement or stat..." msgstr "Succès ou statistique..." -#: src/gui_frontend/app_list_view/mod.rs:1719 -#: src/gui_frontend/app_list_view/settings_bindings.rs:265 +#: src/gui_frontend/app_list_view/mod.rs:1866 +#: src/gui_frontend/app_list_view/settings_bindings.rs:333 msgid "Could not change the in-game setting" msgstr "Impossible de changer l'option in-game" -#: src/gui_frontend/app_list_view/mod.rs:1721 -#: src/gui_frontend/app_list_view/settings_bindings.rs:266 +#: src/gui_frontend/app_list_view/mod.rs:1868 +#: src/gui_frontend/app_list_view/settings_bindings.rs:334 msgid "The change could not be applied. Restart SamRewritten and try again." -msgstr "Le changement n'a pas pu être appliqué. Redémarrez SamRewritten et réessayez." +msgstr "" +"Le changement n'a pas pu être appliqué. Redémarrez SamRewritten et réessayez." -#: src/gui_frontend/app_list_view/sidebar.rs:47 +#: src/gui_frontend/app_list_view/sidebar.rs:58 msgid "Hide with no achievements" msgstr "Masquer sans succès" -#: src/gui_frontend/app_list_view/sidebar.rs:52 +#: src/gui_frontend/app_list_view/sidebar.rs:64 msgid "Hide at 100%" msgstr "Masquer à 100 %" -#: src/gui_frontend/app_list_view/sidebar.rs:57 +#: src/gui_frontend/app_list_view/sidebar.rs:70 msgid "Hide at 0%" msgstr "Masquer à 0 %" -#: src/gui_frontend/app_list_view/sidebar.rs:62 +#: src/gui_frontend/app_list_view/sidebar.rs:76 msgid "Hide never launched" msgstr "Masquer jamais lancés" -#: src/gui_frontend/app_list_view/sidebar.rs:67 +#: src/gui_frontend/app_list_view/sidebar.rs:82 msgid "Only currently idling" msgstr "Uniquement in-game" -#: src/gui_frontend/app_list_view/sidebar.rs:72 +#: src/gui_frontend/app_list_view/sidebar.rs:88 +msgid "Hide hidden in Steam" +msgstr "Masquer les masqués Steam" + +#: src/gui_frontend/app_list_view/sidebar.rs:94 msgid "Show junk" msgstr "Afficher superflus" -#: src/gui_frontend/app_list_view/sidebar.rs:86 +#: src/gui_frontend/app_list_view/sidebar.rs:108 msgid "App ID" msgstr "App ID" -#: src/gui_frontend/app_list_view/sidebar.rs:91 +#: src/gui_frontend/app_list_view/sidebar.rs:113 msgid "Name" msgstr "Nom" -#: src/gui_frontend/app_list_view/sidebar.rs:96 +#: src/gui_frontend/app_list_view/sidebar.rs:118 msgid "Last played" msgstr "Lancé récemment" -#: src/gui_frontend/app_list_view/sidebar.rs:101 +#: src/gui_frontend/app_list_view/sidebar.rs:123 msgid "Playtime" msgstr "Temps de jeu" -#: src/gui_frontend/app_list_view/sidebar.rs:106 +#: src/gui_frontend/app_list_view/sidebar.rs:128 msgid "Completion" msgstr "Complétion" -#: src/gui_frontend/app_list_view/sidebar.rs:111 +#: src/gui_frontend/app_list_view/sidebar.rs:133 msgid "Achievements left" msgstr "Succès restants" -#: src/gui_frontend/app_list_view/sidebar.rs:153 +#: src/gui_frontend/app_list_view/sidebar.rs:183 +msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." +msgstr "" +"Filtre selon un terme de recherche, ce que SamRewritten ne peut pas " +"reproduire exactement." + +#: src/gui_frontend/app_list_view/sidebar.rs:186 +msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." +msgstr "" +"Utilise un filtre Steam que SamRewritten ne peut pas reproduire exactement." + +#: src/gui_frontend/app_list_view/sidebar.rs:189 +msgid "" +"Needs information from Steam that could not be read. Refresh to try again." +msgstr "" +"Nécessite des informations de Steam qui n'ont pas pu être lues. Actualisez " +"pour réessayer." + +#: src/gui_frontend/app_list_view/sidebar.rs:192 +msgid "" +"Needs your friends' games, which Steam can only tell us when it is online." +msgstr "" +"Nécessite les jeux de vos amis, que Steam ne peut indiquer qu'en ligne." + +#: src/gui_frontend/app_list_view/sidebar.rs:201 +msgid "Favorites" +msgstr "Favoris" + +#: src/gui_frontend/app_list_view/sidebar.rs:228 msgid "View profile" msgstr "Voir le profil" -#: src/gui_frontend/app_list_view/sidebar.rs:208 +#: src/gui_frontend/app_list_view/sidebar.rs:283 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "Utilisateur Steam" -#: src/gui_frontend/app_list_view/sidebar.rs:229 +#: src/gui_frontend/app_list_view/sidebar.rs:305 +msgid "Steam is offline. What needs its servers is turned off." +msgstr "Steam est hors ligne. Ce qui dépend de ses serveurs est désactivé." + +#: src/gui_frontend/app_list_view/sidebar.rs:316 msgid "Fetching completion…" msgstr "Chargement de la complétion…" -#: src/gui_frontend/app_list_view/sidebar.rs:235 +#: src/gui_frontend/app_list_view/sidebar.rs:322 msgid "Click to cancel" msgstr "Cliquez pour annuler" -#: src/gui_frontend/app_list_view/sidebar.rs:266 +#: src/gui_frontend/app_list_view/sidebar.rs:353 msgid "Filters" msgstr "Filtres" -#: src/gui_frontend/app_list_view/sidebar.rs:274 +#: src/gui_frontend/app_list_view/sidebar.rs:364 +msgid "Steam collection" +msgstr "Collection Steam" + +#: src/gui_frontend/app_list_view/sidebar.rs:365 +#: src/gui_frontend/app_list_view/sidebar.rs:588 +msgid "All games" +msgstr "Tous les jeux" + +#: src/gui_frontend/app_list_view/sidebar.rs:448 msgid "Sort by" msgstr "Trier par" -#: src/gui_frontend/app_list_view/sidebar.rs:313 +#: src/gui_frontend/app_list_view/sidebar.rs:513 msgid "Reset filters" msgstr "Réinitialiser les filtres" @@ -986,12 +1032,12 @@ msgstr "Une erreur inattendue s'est produite pendant le retour en arrière :" msgid "Undo incomplete" msgstr "Retour en arrière incomplet" -#: src/gui_frontend/app_list_view/settings_bindings.rs:138 +#: src/gui_frontend/app_list_view/settings_bindings.rs:159 msgid "The new language will be applied the next time you start SamRewritten." msgstr "" "La nouvelle langue sera appliquée au prochain démarrage de SamRewritten." -#: src/gui_frontend/app_list_view/settings_bindings.rs:148 +#: src/gui_frontend/app_list_view/settings_bindings.rs:169 msgid "Language changed" msgstr "Langue modifiée" @@ -1189,17 +1235,17 @@ msgstr "" msgid "Import complete" msgstr "Importation terminée" -#: src/gui_frontend/app_list_view/refresh_actions.rs:118 +#: src/gui_frontend/app_list_view/refresh_actions.rs:122 msgid "No apps found on your account. Search for App Id to get started." msgstr "" "Aucune application trouvée sur votre compte. Recherchez un App Id pour " "commencer." -#: src/gui_frontend/app_list_view/refresh_actions.rs:135 +#: src/gui_frontend/app_list_view/refresh_actions.rs:139 msgid "No results. Check for spelling mistakes or try typing an App Id." msgstr "Aucun résultat. Vérifiez l'orthographe ou essayez de saisir un App Id." -#: src/gui_frontend/app_list_view/refresh_actions.rs:145 +#: src/gui_frontend/app_list_view/refresh_actions.rs:149 msgid "" "Failed to load library. Check your internet connection. Search for App Id to " "get started." @@ -1207,17 +1253,17 @@ msgstr "" "Échec du chargement de la bibliothèque. Vérifiez votre connexion Internet. " "Recherchez un App Id pour commencer." -#: src/gui_frontend/app_list_view/refresh_actions.rs:431 +#: src/gui_frontend/app_list_view/refresh_actions.rs:435 msgid "Reset Everything" msgstr "Tout réinitialiser" -#: src/gui_frontend/app_list_view/refresh_actions.rs:432 +#: src/gui_frontend/app_list_view/refresh_actions.rs:436 msgid "This will reset all achievements and stats for this app. Are you sure?" msgstr "" "Cela réinitialisera tous les succès et statistiques de cette application. " "Êtes-vous sûr ?" -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Sure, reset" msgstr "Oui, réinitialiser" @@ -1246,6 +1292,15 @@ msgstr "Cliquez pour préparer ce succès ; cliquez à nouveau pour le retirer." msgid "Already unlocked." msgstr "Déjà déverrouillé." +#~ msgid "Steam is offline, so it cannot read a friend's timings." +#~ msgstr "Steam est hors ligne, impossible de lire les horaires d'un ami." + +#~ msgid "Steam is offline, so it cannot count achievements." +#~ msgstr "Steam est hors ligne, impossible de compter les succès." + +#~ msgid "Steam is offline" +#~ msgstr "Steam est hors ligne" + #~ msgid "{count} games got one in the same sitting" #~ msgstr "{count} jeux en ont reçu une dans la même séance" diff --git a/po/ja.po b/po/ja.po index 354d114..2d0a82e 100644 --- a/po/ja.po +++ b/po/ja.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-30 23:21+0200\n" +"POT-Creation-Date: 2026-09-02 21:40+0200\n" "PO-Revision-Date: 2026-08-23 00:35+0900\n" "Language: ja\n" "MIME-Version: 1.0\n" @@ -11,7 +11,7 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: src/gui_frontend/ui_components.rs:159 src/gui_frontend/app_view.rs:62 -#: src/gui_frontend/app_list_view/mod.rs:380 +#: src/gui_frontend/app_list_view/mod.rs:421 msgid "Loading..." msgstr "読み込み中..." @@ -118,7 +118,7 @@ msgstr "OK" #: src/gui_frontend/dialogs.rs:123 src/gui_frontend/dialogs.rs:150 #: src/gui_frontend/profile_view/mod.rs:587 #: src/gui_frontend/app_list_view/progress_actions.rs:424 -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Cancel" msgstr "キャンセル" @@ -143,9 +143,9 @@ msgstr "すでにSteamをインストールしていますか?" #: src/gui_frontend/dialogs.rs:290 msgid "" "If you've installed Steam in a custom location, you can point SamRewritten " -"to it using environment variables. Please check the GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Steamをカスタムの場所にインストールしている場合は、環境変数でSamRewrittenに場" "所を指定できます。手順や問題の報告についてはGitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Caso a Steam esteja instalada em um diretório customizado, você pode usar " "variáveis de ambiente para apontar esse caminho para o SamRewritten. Por " @@ -322,32 +322,32 @@ msgstr "Já acima ou no alvo ({progress})" msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "Auto-preencher {count} conquista(s) ({progress})" -#: src/gui_frontend/achievement_manual_view/header.rs:49 +#: src/gui_frontend/achievement_manual_view/header.rs:50 msgid "Instant" msgstr "Instantâneo" -#: src/gui_frontend/achievement_manual_view/header.rs:52 +#: src/gui_frontend/achievement_manual_view/header.rs:53 msgid "Stage" msgstr "Programar" -#: src/gui_frontend/achievement_manual_view/header.rs:56 +#: src/gui_frontend/achievement_manual_view/header.rs:57 msgid "Copy user" msgstr "Copiar de usuário" -#: src/gui_frontend/achievement_manual_view/header.rs:74 +#: src/gui_frontend/achievement_manual_view/header.rs:75 msgid "Auto-fill" msgstr "Auto-preencher" -#: src/gui_frontend/achievement_manual_view/header.rs:77 +#: src/gui_frontend/achievement_manual_view/header.rs:78 msgid "Configuration" msgstr "Configuração" -#: src/gui_frontend/achievement_manual_view/header.rs:103 +#: src/gui_frontend/achievement_manual_view/header.rs:104 #: src/gui_frontend/achievement_manual_view/copy_controls.rs:48 msgid "Start" msgstr "Iniciar" -#: src/gui_frontend/achievement_manual_view/header.rs:108 +#: src/gui_frontend/achievement_manual_view/header.rs:109 msgid "Changes apply instantly" msgstr "Mudanças aplicam instantaneamente" @@ -441,112 +441,151 @@ msgstr "Procurar amigos ou colar um SteamID64" msgid "Clear selected user" msgstr "Limpar seleção de usuário" -#: src/gui_frontend/app_list_view/mod.rs:401 +#: src/gui_frontend/app_list_view/mod.rs:442 msgid "SamRewritten could not connect to Steam. Is it running?" msgstr "SamRewritten não pode se conectar com Steam. A Steam está ligada?" -#: src/gui_frontend/app_list_view/mod.rs:406 +#: src/gui_frontend/app_list_view/mod.rs:447 msgid "Try again" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:423 -#: src/gui_frontend/app_list_view/mod.rs:1605 +#: src/gui_frontend/app_list_view/mod.rs:464 +#: src/gui_frontend/app_list_view/mod.rs:1752 msgid "Name or AppId (Ctrl+K)" msgstr "Nome ou AppId (Ctrl+K)" -#: src/gui_frontend/app_list_view/mod.rs:439 +#: src/gui_frontend/app_list_view/mod.rs:480 msgid "Show or hide the sidebar" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1600 +#: src/gui_frontend/app_list_view/mod.rs:1747 msgid "Achievement or stat..." msgstr "Conquista ou estatística..." -#: src/gui_frontend/app_list_view/mod.rs:1719 -#: src/gui_frontend/app_list_view/settings_bindings.rs:265 +#: src/gui_frontend/app_list_view/mod.rs:1866 +#: src/gui_frontend/app_list_view/settings_bindings.rs:333 msgid "Could not change the in-game setting" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1721 -#: src/gui_frontend/app_list_view/settings_bindings.rs:266 +#: src/gui_frontend/app_list_view/mod.rs:1868 +#: src/gui_frontend/app_list_view/settings_bindings.rs:334 #, fuzzy msgid "The change could not be applied. Restart SamRewritten and try again." msgstr "O idioma será aplicado na próxima inicialização." -#: src/gui_frontend/app_list_view/sidebar.rs:47 +#: src/gui_frontend/app_list_view/sidebar.rs:58 msgid "Hide with no achievements" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:52 +#: src/gui_frontend/app_list_view/sidebar.rs:64 msgid "Hide at 100%" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:57 +#: src/gui_frontend/app_list_view/sidebar.rs:70 msgid "Hide at 0%" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:62 +#: src/gui_frontend/app_list_view/sidebar.rs:76 msgid "Hide never launched" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:67 +#: src/gui_frontend/app_list_view/sidebar.rs:82 msgid "Only currently idling" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:72 +#: src/gui_frontend/app_list_view/sidebar.rs:88 +msgid "Hide hidden in Steam" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:94 msgid "Show junk" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:86 +#: src/gui_frontend/app_list_view/sidebar.rs:108 msgid "App ID" msgstr "ID do app" -#: src/gui_frontend/app_list_view/sidebar.rs:91 +#: src/gui_frontend/app_list_view/sidebar.rs:113 msgid "Name" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:96 +#: src/gui_frontend/app_list_view/sidebar.rs:118 msgid "Last played" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:101 +#: src/gui_frontend/app_list_view/sidebar.rs:123 msgid "Playtime" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:106 +#: src/gui_frontend/app_list_view/sidebar.rs:128 msgid "Completion" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:111 +#: src/gui_frontend/app_list_view/sidebar.rs:133 msgid "Achievements left" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:153 +#: src/gui_frontend/app_list_view/sidebar.rs:183 +msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:186 +msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:189 +msgid "" +"Needs information from Steam that could not be read. Refresh to try again." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:192 +msgid "" +"Needs your friends' games, which Steam can only tell us when it is online." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:201 +msgid "Favorites" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:228 msgid "View profile" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:208 +#: src/gui_frontend/app_list_view/sidebar.rs:283 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:229 +#: src/gui_frontend/app_list_view/sidebar.rs:305 +msgid "Steam is offline. What needs its servers is turned off." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:316 msgid "Fetching completion…" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:235 +#: src/gui_frontend/app_list_view/sidebar.rs:322 msgid "Click to cancel" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:266 +#: src/gui_frontend/app_list_view/sidebar.rs:353 msgid "Filters" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:274 +#: src/gui_frontend/app_list_view/sidebar.rs:364 +msgid "Steam collection" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:365 +#: src/gui_frontend/app_list_view/sidebar.rs:588 +msgid "All games" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:448 msgid "Sort by" msgstr "Organizar por" -#: src/gui_frontend/app_list_view/sidebar.rs:313 +#: src/gui_frontend/app_list_view/sidebar.rs:513 msgid "Reset filters" msgstr "" @@ -982,11 +1021,11 @@ msgstr "" msgid "Undo incomplete" msgstr "Desbloqueamento incompleto" -#: src/gui_frontend/app_list_view/settings_bindings.rs:138 +#: src/gui_frontend/app_list_view/settings_bindings.rs:159 msgid "The new language will be applied the next time you start SamRewritten." msgstr "O idioma será aplicado na próxima inicialização." -#: src/gui_frontend/app_list_view/settings_bindings.rs:148 +#: src/gui_frontend/app_list_view/settings_bindings.rs:169 msgid "Language changed" msgstr "Idioma mudado" @@ -1184,15 +1223,15 @@ msgstr "" msgid "Import complete" msgstr "Importação completa" -#: src/gui_frontend/app_list_view/refresh_actions.rs:118 +#: src/gui_frontend/app_list_view/refresh_actions.rs:122 msgid "No apps found on your account. Search for App Id to get started." msgstr "Nenhum app encontrado em sua conta. Procure por App Id para começar." -#: src/gui_frontend/app_list_view/refresh_actions.rs:135 +#: src/gui_frontend/app_list_view/refresh_actions.rs:139 msgid "No results. Check for spelling mistakes or try typing an App Id." msgstr "Sem resultados. Confira sua digitação ou tente usar um App Id." -#: src/gui_frontend/app_list_view/refresh_actions.rs:145 +#: src/gui_frontend/app_list_view/refresh_actions.rs:149 msgid "" "Failed to load library. Check your internet connection. Search for App Id to " "get started." @@ -1200,17 +1239,17 @@ msgstr "" "Falha ao carregar sua biblioteca. Confira sua conexão com a internet. " "Procure por App Id para começar." -#: src/gui_frontend/app_list_view/refresh_actions.rs:431 +#: src/gui_frontend/app_list_view/refresh_actions.rs:435 msgid "Reset Everything" msgstr "Resetar tudo" -#: src/gui_frontend/app_list_view/refresh_actions.rs:432 +#: src/gui_frontend/app_list_view/refresh_actions.rs:436 msgid "This will reset all achievements and stats for this app. Are you sure?" msgstr "" "Isso irá resetar todas as conquistas e estatísticas para esse app. Você tem " "certeza?" -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Sure, reset" msgstr "Tenho certeza, resetar" diff --git a/po/ru.po b/po/ru.po index c837926..50eccf7 100644 --- a/po/ru.po +++ b/po/ru.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-30 23:21+0200\n" +"POT-Creation-Date: 2026-09-02 21:40+0200\n" "PO-Revision-Date: 2026-08-23 05:40+0000\n" "Last-Translator: AxelPAL\n" "Language-Team: Russian\n" @@ -18,7 +18,7 @@ msgstr "" "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" #: src/gui_frontend/ui_components.rs:159 src/gui_frontend/app_view.rs:62 -#: src/gui_frontend/app_list_view/mod.rs:380 +#: src/gui_frontend/app_list_view/mod.rs:421 msgid "Loading..." msgstr "Загрузка..." @@ -125,7 +125,7 @@ msgstr "ОК" #: src/gui_frontend/dialogs.rs:123 src/gui_frontend/dialogs.rs:150 #: src/gui_frontend/profile_view/mod.rs:587 #: src/gui_frontend/app_list_view/progress_actions.rs:424 -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Cancel" msgstr "Отмена" @@ -150,9 +150,9 @@ msgstr "Steam уже установлен?" #: src/gui_frontend/dialogs.rs:290 msgid "" "If you've installed Steam in a custom location, you can point SamRewritten " -"to it using environment variables. Please check the GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Если Steam установлен в нестандартном месте, укажите путь через переменные " "окружения. Инструкции и форма для сообщения о проблеме — на GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Steam'i farklı bir konuma kurduysanız, ortam değişkenlerini kullanarak " "SamRewritten'a bu konumu gösterebilirsiniz. Talimatlar veya yaşadığınız " @@ -330,32 +330,32 @@ msgstr "Zaten hedefe ulaşıldı veya aşıldı ({progress})" msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "{count} başarımı otomatik tamamla ({progress})" -#: src/gui_frontend/achievement_manual_view/header.rs:49 +#: src/gui_frontend/achievement_manual_view/header.rs:50 msgid "Instant" msgstr "Anında" -#: src/gui_frontend/achievement_manual_view/header.rs:52 +#: src/gui_frontend/achievement_manual_view/header.rs:53 msgid "Stage" msgstr "Kuyruk" -#: src/gui_frontend/achievement_manual_view/header.rs:56 +#: src/gui_frontend/achievement_manual_view/header.rs:57 msgid "Copy user" msgstr "Kullanıcı Kopyala" -#: src/gui_frontend/achievement_manual_view/header.rs:74 +#: src/gui_frontend/achievement_manual_view/header.rs:75 msgid "Auto-fill" msgstr "Otomatik doldur" -#: src/gui_frontend/achievement_manual_view/header.rs:77 +#: src/gui_frontend/achievement_manual_view/header.rs:78 msgid "Configuration" msgstr "Ayarlar" -#: src/gui_frontend/achievement_manual_view/header.rs:103 +#: src/gui_frontend/achievement_manual_view/header.rs:104 #: src/gui_frontend/achievement_manual_view/copy_controls.rs:48 msgid "Start" msgstr "Başla" -#: src/gui_frontend/achievement_manual_view/header.rs:108 +#: src/gui_frontend/achievement_manual_view/header.rs:109 msgid "Changes apply instantly" msgstr "Değişiklikler anında uygulanır" @@ -449,111 +449,150 @@ msgstr "Arkadaşlarda ara veya SteamID64 yapıştır" msgid "Clear selected user" msgstr "Seçili kullanıcıyı kaldır" -#: src/gui_frontend/app_list_view/mod.rs:401 +#: src/gui_frontend/app_list_view/mod.rs:442 msgid "SamRewritten could not connect to Steam. Is it running?" msgstr "SamRewritten Steam'a bağlanamadı. Steam açık mı?" -#: src/gui_frontend/app_list_view/mod.rs:406 +#: src/gui_frontend/app_list_view/mod.rs:447 msgid "Try again" msgstr "Tekrar deneyin" -#: src/gui_frontend/app_list_view/mod.rs:423 -#: src/gui_frontend/app_list_view/mod.rs:1605 +#: src/gui_frontend/app_list_view/mod.rs:464 +#: src/gui_frontend/app_list_view/mod.rs:1752 msgid "Name or AppId (Ctrl+K)" msgstr "İsim veya AppId (Ctrl+K)" -#: src/gui_frontend/app_list_view/mod.rs:439 +#: src/gui_frontend/app_list_view/mod.rs:480 msgid "Show or hide the sidebar" msgstr "Yan paneli göster/gizle" -#: src/gui_frontend/app_list_view/mod.rs:1600 +#: src/gui_frontend/app_list_view/mod.rs:1747 msgid "Achievement or stat..." msgstr "Başarım veya istatistik..." -#: src/gui_frontend/app_list_view/mod.rs:1719 -#: src/gui_frontend/app_list_view/settings_bindings.rs:265 +#: src/gui_frontend/app_list_view/mod.rs:1866 +#: src/gui_frontend/app_list_view/settings_bindings.rs:333 msgid "Could not change the in-game setting" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1721 -#: src/gui_frontend/app_list_view/settings_bindings.rs:266 +#: src/gui_frontend/app_list_view/mod.rs:1868 +#: src/gui_frontend/app_list_view/settings_bindings.rs:334 msgid "The change could not be applied. Restart SamRewritten and try again." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:47 +#: src/gui_frontend/app_list_view/sidebar.rs:58 msgid "Hide with no achievements" msgstr "Başarımı bulunmayanları gizle" -#: src/gui_frontend/app_list_view/sidebar.rs:52 +#: src/gui_frontend/app_list_view/sidebar.rs:64 msgid "Hide at 100%" msgstr "%100 olanları gizle" -#: src/gui_frontend/app_list_view/sidebar.rs:57 +#: src/gui_frontend/app_list_view/sidebar.rs:70 msgid "Hide at 0%" msgstr "%0 olanları gizle" -#: src/gui_frontend/app_list_view/sidebar.rs:62 +#: src/gui_frontend/app_list_view/sidebar.rs:76 msgid "Hide never launched" msgstr "Hiç oynanmayanları gizle" -#: src/gui_frontend/app_list_view/sidebar.rs:67 +#: src/gui_frontend/app_list_view/sidebar.rs:82 msgid "Only currently idling" msgstr "Arka planda çalışanlar" -#: src/gui_frontend/app_list_view/sidebar.rs:72 +#: src/gui_frontend/app_list_view/sidebar.rs:88 +msgid "Hide hidden in Steam" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:94 msgid "Show junk" msgstr "Çöpleri göster" -#: src/gui_frontend/app_list_view/sidebar.rs:86 +#: src/gui_frontend/app_list_view/sidebar.rs:108 msgid "App ID" msgstr "App ID" -#: src/gui_frontend/app_list_view/sidebar.rs:91 +#: src/gui_frontend/app_list_view/sidebar.rs:113 msgid "Name" msgstr "İsim" -#: src/gui_frontend/app_list_view/sidebar.rs:96 +#: src/gui_frontend/app_list_view/sidebar.rs:118 msgid "Last played" msgstr "En son oynama" -#: src/gui_frontend/app_list_view/sidebar.rs:101 +#: src/gui_frontend/app_list_view/sidebar.rs:123 msgid "Playtime" msgstr "Oynama süresi" -#: src/gui_frontend/app_list_view/sidebar.rs:106 +#: src/gui_frontend/app_list_view/sidebar.rs:128 msgid "Completion" msgstr "Tamamlanma" -#: src/gui_frontend/app_list_view/sidebar.rs:111 +#: src/gui_frontend/app_list_view/sidebar.rs:133 msgid "Achievements left" msgstr "Kalan Başarımlar" -#: src/gui_frontend/app_list_view/sidebar.rs:153 +#: src/gui_frontend/app_list_view/sidebar.rs:183 +msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:186 +msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:189 +msgid "" +"Needs information from Steam that could not be read. Refresh to try again." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:192 +msgid "" +"Needs your friends' games, which Steam can only tell us when it is online." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:201 +msgid "Favorites" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:228 msgid "View profile" msgstr "Profili görüntüle" -#: src/gui_frontend/app_list_view/sidebar.rs:208 +#: src/gui_frontend/app_list_view/sidebar.rs:283 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "Steam kullanıcısı" -#: src/gui_frontend/app_list_view/sidebar.rs:229 +#: src/gui_frontend/app_list_view/sidebar.rs:305 +msgid "Steam is offline. What needs its servers is turned off." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:316 msgid "Fetching completion…" msgstr "Tamamlanma durumu alınıyor…" -#: src/gui_frontend/app_list_view/sidebar.rs:235 +#: src/gui_frontend/app_list_view/sidebar.rs:322 msgid "Click to cancel" msgstr "İptal etmek için tıklayın" -#: src/gui_frontend/app_list_view/sidebar.rs:266 +#: src/gui_frontend/app_list_view/sidebar.rs:353 msgid "Filters" msgstr "Filtreler" -#: src/gui_frontend/app_list_view/sidebar.rs:274 +#: src/gui_frontend/app_list_view/sidebar.rs:364 +msgid "Steam collection" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:365 +#: src/gui_frontend/app_list_view/sidebar.rs:588 +msgid "All games" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:448 msgid "Sort by" msgstr "Sıralama" -#: src/gui_frontend/app_list_view/sidebar.rs:313 +#: src/gui_frontend/app_list_view/sidebar.rs:513 msgid "Reset filters" msgstr "Filtreleri sıfırla" @@ -991,11 +1030,11 @@ msgstr "" msgid "Undo incomplete" msgstr "" -#: src/gui_frontend/app_list_view/settings_bindings.rs:138 +#: src/gui_frontend/app_list_view/settings_bindings.rs:159 msgid "The new language will be applied the next time you start SamRewritten." msgstr "" -#: src/gui_frontend/app_list_view/settings_bindings.rs:148 +#: src/gui_frontend/app_list_view/settings_bindings.rs:169 msgid "Language changed" msgstr "" @@ -1170,29 +1209,29 @@ msgstr "" msgid "Import complete" msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:118 +#: src/gui_frontend/app_list_view/refresh_actions.rs:122 msgid "No apps found on your account. Search for App Id to get started." msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:135 +#: src/gui_frontend/app_list_view/refresh_actions.rs:139 msgid "No results. Check for spelling mistakes or try typing an App Id." msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:145 +#: src/gui_frontend/app_list_view/refresh_actions.rs:149 msgid "" "Failed to load library. Check your internet connection. Search for App Id to " "get started." msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:431 +#: src/gui_frontend/app_list_view/refresh_actions.rs:435 msgid "Reset Everything" msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:432 +#: src/gui_frontend/app_list_view/refresh_actions.rs:436 msgid "This will reset all achievements and stats for this app. Are you sure?" msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Sure, reset" msgstr "" diff --git a/po/uk.po b/po/uk.po index 88b7b2e..bf187af 100644 --- a/po/uk.po +++ b/po/uk.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-30 23:21+0200\n" +"POT-Creation-Date: 2026-09-02 21:40+0200\n" "PO-Revision-Date: 2026-08-13 07:51+0000\n" "Last-Translator: Dan \n" "Language-Team: Ukrainian Уже встановили Steam?" #: src/gui_frontend/dialogs.rs:290 msgid "" "If you've installed Steam in a custom location, you can point SamRewritten " -"to it using environment variables. Please check the GitHub page for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" "Якщо ви встановили Steam у власну теку, ви можете вказати шлях до неї для " "SamRewritten за допомогою змінних середовища. Перегляньте інструкції на for instructions, or to report " -"your issue." +"to it using environment variables. Please check the GitHub page for instructions, or to " +"report your issue." msgstr "" #: src/gui_frontend/dialogs.rs:295 @@ -305,32 +305,32 @@ msgstr "" msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "" -#: src/gui_frontend/achievement_manual_view/header.rs:49 +#: src/gui_frontend/achievement_manual_view/header.rs:50 msgid "Instant" msgstr "即時" -#: src/gui_frontend/achievement_manual_view/header.rs:52 +#: src/gui_frontend/achievement_manual_view/header.rs:53 msgid "Stage" msgstr "" -#: src/gui_frontend/achievement_manual_view/header.rs:56 +#: src/gui_frontend/achievement_manual_view/header.rs:57 msgid "Copy user" msgstr "" -#: src/gui_frontend/achievement_manual_view/header.rs:74 +#: src/gui_frontend/achievement_manual_view/header.rs:75 msgid "Auto-fill" msgstr "" -#: src/gui_frontend/achievement_manual_view/header.rs:77 +#: src/gui_frontend/achievement_manual_view/header.rs:78 msgid "Configuration" msgstr "" -#: src/gui_frontend/achievement_manual_view/header.rs:103 +#: src/gui_frontend/achievement_manual_view/header.rs:104 #: src/gui_frontend/achievement_manual_view/copy_controls.rs:48 msgid "Start" msgstr "" -#: src/gui_frontend/achievement_manual_view/header.rs:108 +#: src/gui_frontend/achievement_manual_view/header.rs:109 msgid "Changes apply instantly" msgstr "" @@ -424,111 +424,150 @@ msgstr "" msgid "Clear selected user" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:401 +#: src/gui_frontend/app_list_view/mod.rs:442 msgid "SamRewritten could not connect to Steam. Is it running?" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:406 +#: src/gui_frontend/app_list_view/mod.rs:447 msgid "Try again" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:423 -#: src/gui_frontend/app_list_view/mod.rs:1605 +#: src/gui_frontend/app_list_view/mod.rs:464 +#: src/gui_frontend/app_list_view/mod.rs:1752 msgid "Name or AppId (Ctrl+K)" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:439 +#: src/gui_frontend/app_list_view/mod.rs:480 msgid "Show or hide the sidebar" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1600 +#: src/gui_frontend/app_list_view/mod.rs:1747 msgid "Achievement or stat..." msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1719 -#: src/gui_frontend/app_list_view/settings_bindings.rs:265 +#: src/gui_frontend/app_list_view/mod.rs:1866 +#: src/gui_frontend/app_list_view/settings_bindings.rs:333 msgid "Could not change the in-game setting" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1721 -#: src/gui_frontend/app_list_view/settings_bindings.rs:266 +#: src/gui_frontend/app_list_view/mod.rs:1868 +#: src/gui_frontend/app_list_view/settings_bindings.rs:334 msgid "The change could not be applied. Restart SamRewritten and try again." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:47 +#: src/gui_frontend/app_list_view/sidebar.rs:58 msgid "Hide with no achievements" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:52 +#: src/gui_frontend/app_list_view/sidebar.rs:64 msgid "Hide at 100%" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:57 +#: src/gui_frontend/app_list_view/sidebar.rs:70 msgid "Hide at 0%" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:62 +#: src/gui_frontend/app_list_view/sidebar.rs:76 msgid "Hide never launched" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:67 +#: src/gui_frontend/app_list_view/sidebar.rs:82 msgid "Only currently idling" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:72 +#: src/gui_frontend/app_list_view/sidebar.rs:88 +msgid "Hide hidden in Steam" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:94 msgid "Show junk" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:86 +#: src/gui_frontend/app_list_view/sidebar.rs:108 msgid "App ID" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:91 +#: src/gui_frontend/app_list_view/sidebar.rs:113 msgid "Name" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:96 +#: src/gui_frontend/app_list_view/sidebar.rs:118 msgid "Last played" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:101 +#: src/gui_frontend/app_list_view/sidebar.rs:123 msgid "Playtime" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:106 +#: src/gui_frontend/app_list_view/sidebar.rs:128 msgid "Completion" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:111 +#: src/gui_frontend/app_list_view/sidebar.rs:133 msgid "Achievements left" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:153 +#: src/gui_frontend/app_list_view/sidebar.rs:183 +msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:186 +msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:189 +msgid "" +"Needs information from Steam that could not be read. Refresh to try again." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:192 +msgid "" +"Needs your friends' games, which Steam can only tell us when it is online." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:201 +msgid "Favorites" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:228 msgid "View profile" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:208 +#: src/gui_frontend/app_list_view/sidebar.rs:283 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:229 +#: src/gui_frontend/app_list_view/sidebar.rs:305 +msgid "Steam is offline. What needs its servers is turned off." +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:316 msgid "Fetching completion…" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:235 +#: src/gui_frontend/app_list_view/sidebar.rs:322 msgid "Click to cancel" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:266 +#: src/gui_frontend/app_list_view/sidebar.rs:353 msgid "Filters" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:274 +#: src/gui_frontend/app_list_view/sidebar.rs:364 +msgid "Steam collection" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:365 +#: src/gui_frontend/app_list_view/sidebar.rs:588 +msgid "All games" +msgstr "" + +#: src/gui_frontend/app_list_view/sidebar.rs:448 msgid "Sort by" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:313 +#: src/gui_frontend/app_list_view/sidebar.rs:513 msgid "Reset filters" msgstr "" @@ -952,11 +991,11 @@ msgstr "" msgid "Undo incomplete" msgstr "" -#: src/gui_frontend/app_list_view/settings_bindings.rs:138 +#: src/gui_frontend/app_list_view/settings_bindings.rs:159 msgid "The new language will be applied the next time you start SamRewritten." msgstr "" -#: src/gui_frontend/app_list_view/settings_bindings.rs:148 +#: src/gui_frontend/app_list_view/settings_bindings.rs:169 msgid "Language changed" msgstr "" @@ -1131,29 +1170,29 @@ msgstr "" msgid "Import complete" msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:118 +#: src/gui_frontend/app_list_view/refresh_actions.rs:122 msgid "No apps found on your account. Search for App Id to get started." msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:135 +#: src/gui_frontend/app_list_view/refresh_actions.rs:139 msgid "No results. Check for spelling mistakes or try typing an App Id." msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:145 +#: src/gui_frontend/app_list_view/refresh_actions.rs:149 msgid "" "Failed to load library. Check your internet connection. Search for App Id to " "get started." msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:431 +#: src/gui_frontend/app_list_view/refresh_actions.rs:435 msgid "Reset Everything" msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:432 +#: src/gui_frontend/app_list_view/refresh_actions.rs:436 msgid "This will reset all achievements and stats for this app. Are you sure?" msgstr "" -#: src/gui_frontend/app_list_view/refresh_actions.rs:433 +#: src/gui_frontend/app_list_view/refresh_actions.rs:437 msgid "Sure, reset" msgstr "" diff --git a/src/backend/app_info.rs b/src/backend/app_info.rs new file mode 100644 index 0000000..fe9895c --- /dev/null +++ b/src/backend/app_info.rs @@ -0,0 +1,538 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Paul +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! `appinfo.vdf`: a header, a length-prefixed blob per app, then the string table. + +use crate::dev_println; +use crate::utils::ipc_types::SamError; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +const MAGIC_V29: u32 = 0x0756_4429; + +/// infoState, lastUpdated, token, text hash, changeNumber, binary hash. +const APP_HEADER_LEN: usize = 4 + 4 + 8 + 20 + 4 + 20; + +/// Valve's blobs nest about four deep; skipping recurses, so runaway nesting would +/// blow the stack. +const MAX_DEPTH: u8 = 32; + +#[derive(Debug, Default, Clone)] +pub struct AppInfo { + pub controller_support: String, + pub languages: HashSet, + pub store_tags: HashSet, + pub categories: HashSet, + pub deck_compat: i32, + pub steamos_compat: i32, + pub steam_machine_compat: i32, + pub mastersub_appid: u32, +} + +impl AppInfo { + pub fn has_category(&self, id: u32) -> bool { + self.categories.contains(&id) + } + + pub fn has_store_tag(&self, id: u32) -> bool { + self.store_tags.contains(&id) + } + + pub fn has_language(&self, index: u32) -> bool { + self.languages.contains(&index) + } +} + +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, + depth: u8, +} + +impl<'a> Cursor<'a> { + fn new(buf: &'a [u8], pos: usize) -> Self { + Self { buf, pos, depth: 0 } + } + + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let end = self.pos.checked_add(n)?; + let slice = self.buf.get(self.pos..end)?; + self.pos = end; + Some(slice) + } + + fn u8(&mut self) -> Option { + Some(self.take(1)?[0]) + } + + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + + fn i32(&mut self) -> Option { + Some(i32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + + fn i64(&mut self) -> Option { + Some(i64::from_le_bytes(self.take(8)?.try_into().ok()?)) + } + + fn cstr(&mut self) -> Option { + let rest = self.buf.get(self.pos..)?; + let len = rest.iter().position(|b| *b == 0)?; + let s = String::from_utf8_lossy(&rest[..len]).into_owned(); + self.pos += len + 1; + Some(s) + } + + fn skip_value(&mut self, kv_type: u8) -> Option<()> { + match kv_type { + 0 => self.skip_block(), + 1 | 5 => self.cstr().map(|_| ()), + 2 | 3 | 4 | 6 => self.take(4).map(|_| ()), + 7 => self.take(8).map(|_| ()), + _ => None, + } + } + + fn skip_block(&mut self) -> Option<()> { + self.depth += 1; + if self.depth > MAX_DEPTH { + return None; + } + loop { + let kv_type = self.u8()?; + if kv_type == 8 { + self.depth -= 1; + return Some(()); + } + self.u32()?; + self.skip_value(kv_type)?; + } + } + + fn key<'s>(&mut self, strings: &'s [String]) -> Option<&'s str> { + strings.get(self.u32()? as usize).map(String::as_str) + } +} + +fn read_string_table(buf: &[u8], offset: i64) -> Option> { + let mut cur = Cursor::new(buf, usize::try_from(offset).ok()?); + // From a file-supplied offset, so it never sizes an allocation: a corrupt one + // would abort the process uncatchably. + let count = cur.u32()? as usize; + if count > buf.len().saturating_sub(cur.pos) { + return None; + } + let mut out = Vec::with_capacity(count.min(16 * 1024)); + for _ in 0..count { + out.push(cur.cstr()?); + } + Some(out) +} + +fn read_int_values(cur: &mut Cursor, out: &mut HashSet) -> Option<()> { + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(()); + } + cur.u32()?; + match kv_type { + 2 => { + out.insert(cur.i32()? as u32); + } + 7 => { + let raw = cur.take(8)?; + out.insert(u64::from_le_bytes(raw.try_into().ok()?) as u32); + } + other => cur.skip_value(other)?, + } + } +} + +const LANGUAGE_ORDER: [&str; 32] = [ + "english", + "german", + "french", + "italian", + "koreana", + "spanish", + "schinese", + "tchinese", + "russian", + "thai", + "japanese", + "portuguese", + "polish", + "danish", + "dutch", + "finnish", + "norwegian", + "swedish", + "hungarian", + "czech", + "romanian", + "turkish", + "brazilian", + "bulgarian", + "greek", + "arabic", + "ukrainian", + "latam", + "vietnamese", + "sc_schinese", + "indonesian", + "malay", +]; + +fn language_index(name: &str) -> Option { + // Steam accepts either spelling for Korean. + let name = if name == "korean" { "koreana" } else { name }; + LANGUAGE_ORDER + .iter() + .position(|known| *known == name) + .map(|index| index as u32) +} + +fn read_supported_languages( + cur: &mut Cursor, + strings: &[String], + out: &mut HashSet, +) -> Option<()> { + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(()); + } + let key = cur.key(strings)?; + let index = language_index(key); + if kv_type != 0 { + cur.skip_value(kv_type)?; + continue; + } + if read_language_entry(cur, strings)? + && let Some(index) = index + { + out.insert(index); + } + } +} + +fn read_language_entry(cur: &mut Cursor, strings: &[String]) -> Option { + let mut supported = false; + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(supported); + } + let key = cur.key(strings)?; + match (kv_type, key) { + (1, "supported") => supported = cur.cstr()? == "true", + (2, "supported") => supported = cur.i32()? != 0, + (other, _) => cur.skip_value(other)?, + } + } +} + +fn read_category_keys(cur: &mut Cursor, strings: &[String], out: &mut HashSet) -> Option<()> { + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(()); + } + let key = cur.key(strings)?; + if let Some(id) = key.strip_prefix("category_").and_then(|n| n.parse().ok()) { + out.insert(id); + } + cur.skip_value(kv_type)?; + } +} + +fn read_deck_compat(cur: &mut Cursor, strings: &[String], out: &mut AppInfo) -> Option<()> { + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(()); + } + let key = cur.key(strings)?; + let slot = match key { + "category" => Some(&mut out.deck_compat), + "steamos_compatibility" => Some(&mut out.steamos_compat), + "steam_machine_compatibility" => Some(&mut out.steam_machine_compat), + _ => None, + }; + match (kv_type, slot) { + (2, Some(slot)) => *slot = cur.i32()?, + (other, _) => cur.skip_value(other)?, + } + } +} + +fn read_common(cur: &mut Cursor, strings: &[String], out: &mut AppInfo) -> Option<()> { + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(()); + } + let key = cur.key(strings)?; + match (kv_type, key) { + (1, "controller_support") => out.controller_support = cur.cstr()?, + (2, "mastersubs_granting_app") => out.mastersub_appid = cur.i32()? as u32, + (0, "store_tags") => read_int_values(cur, &mut out.store_tags)?, + (0, "category") => read_category_keys(cur, strings, &mut out.categories)?, + (0, "supported_languages") => { + read_supported_languages(cur, strings, &mut out.languages)? + } + (0, "steam_deck_compatibility") => read_deck_compat(cur, strings, out)?, + (other, _) => cur.skip_value(other)?, + } + } +} + +/// Steam nests `common` under an `appinfo` root on some apps, top level on others. +fn read_app(cur: &mut Cursor, strings: &[String], out: &mut AppInfo, depth: u8) -> Option<()> { + loop { + let kv_type = cur.u8()?; + if kv_type == 8 { + return Some(()); + } + let key = cur.key(strings)?; + if kv_type != 0 { + cur.skip_value(kv_type)?; + continue; + } + match key { + "common" => read_common(cur, strings, out)?, + "appinfo" if depth == 0 => read_app(cur, strings, out, depth + 1)?, + _ => cur.skip_block()?, + } + } +} + +pub fn read(path: &Path, wanted: &HashSet) -> Result, SamError> { + let buf = std::fs::read(path).map_err(|e| { + dev_println!("ORCH", "Failed to read {}: {e}", path.display()); + SamError::UnknownError + })?; + + let mut head = Cursor::new(&buf, 0); + let magic = head.u32().ok_or(SamError::UnknownError)?; + head.u32().ok_or(SamError::UnknownError)?; + if magic != MAGIC_V29 { + dev_println!("ORCH", "Unsupported appinfo.vdf magic {magic:#x}"); + return Err(SamError::UnknownError); + } + let table_offset = head.i64().ok_or(SamError::UnknownError)?; + let strings = read_string_table(&buf, table_offset).ok_or(SamError::UnknownError)?; + + let mut out = HashMap::with_capacity(wanted.len()); + let mut pos = head.pos; + loop { + let mut header = Cursor::new(&buf, pos); + let Some(app_id) = header.u32() else { break }; + if app_id == 0 { + break; + } + let Some(size) = header.u32() else { break }; + let Some(next) = pos + .checked_add(8) + .and_then(|p| p.checked_add(size as usize)) + else { + break; + }; + if next > buf.len() { + break; + } + if wanted.contains(&app_id) { + let mut info = AppInfo::default(); + let mut body = Cursor::new(&buf[..next], pos + 8 + APP_HEADER_LEN); + if read_app(&mut body, &strings, &mut info, 0).is_some() { + out.insert(app_id, info); + } else { + dev_println!("ORCH", "Malformed appinfo entry for app {app_id}"); + } + } + pos = next; + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Builder { + strings: Vec, + body: Vec, + } + + impl Builder { + fn new() -> Self { + Self { + strings: Vec::new(), + body: Vec::new(), + } + } + + fn key(&mut self, name: &str) -> u32 { + if let Some(i) = self.strings.iter().position(|s| s == name) { + return i as u32; + } + self.strings.push(name.to_string()); + self.strings.len() as u32 - 1 + } + + fn open(&mut self, name: &str) -> &mut Self { + let index = self.key(name); + self.body.push(0); + self.body.extend_from_slice(&index.to_le_bytes()); + self + } + + fn close(&mut self) -> &mut Self { + self.body.push(8); + self + } + + fn int(&mut self, name: &str, value: i32) -> &mut Self { + let index = self.key(name); + self.body.push(2); + self.body.extend_from_slice(&index.to_le_bytes()); + self.body.extend_from_slice(&value.to_le_bytes()); + self + } + + fn string(&mut self, name: &str, value: &str) -> &mut Self { + let index = self.key(name); + self.body.push(1); + self.body.extend_from_slice(&index.to_le_bytes()); + self.body.extend_from_slice(value.as_bytes()); + self.body.push(0); + self + } + + fn finish(&self, app_ids: &[u32]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&MAGIC_V29.to_le_bytes()); + out.extend_from_slice(&1u32.to_le_bytes()); + let offset_at = out.len(); + out.extend_from_slice(&0i64.to_le_bytes()); + for app_id in app_ids { + out.extend_from_slice(&app_id.to_le_bytes()); + let size = APP_HEADER_LEN + self.body.len() + 1; + out.extend_from_slice(&(size as u32).to_le_bytes()); + out.extend(std::iter::repeat_n(0u8, APP_HEADER_LEN)); + out.extend_from_slice(&self.body); + out.push(8); + } + out.extend_from_slice(&0u32.to_le_bytes()); + + let table = out.len() as i64; + out[offset_at..offset_at + 8].copy_from_slice(&table.to_le_bytes()); + out.extend_from_slice(&(self.strings.len() as u32).to_le_bytes()); + for s in &self.strings { + out.extend_from_slice(s.as_bytes()); + out.push(0); + } + out + } + } + + fn sample() -> Vec { + let mut b = Builder::new(); + b.open("appinfo"); + b.open("common"); + b.string("controller_support", "full"); + b.int("mastersubs_granting_app", 1_289_670); + b.open("store_tags").int("0", 19).int("1", 492).close(); + b.open("category") + .int("category_22", 1) + .int("category_29", 1) + .close(); + b.open("steam_deck_compatibility") + .int("category", 3) + .int("steamos_compatibility", 2) + .close(); + b.close(); + b.open("extended").string("developer", "irrelevant").close(); + b.close(); + b.finish(&[240, 730]) + } + + fn read_bytes(bytes: &[u8], wanted: &[u32]) -> Result, SamError> { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "sam_test_appinfo_{}_{}.vdf", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::write(&path, bytes).expect("fixture should write"); + let result = read(&path, &wanted.iter().copied().collect()); + std::fs::remove_file(&path).ok(); + result + } + + #[test] + fn common_fields_are_read_and_other_apps_skipped_whole() { + let apps = read_bytes(&sample(), &[730]).expect("fixture should parse"); + assert_eq!(apps.len(), 1, "only the wanted app is kept"); + let info = &apps[&730]; + assert_eq!(info.controller_support, "full"); + assert_eq!(info.mastersub_appid, 1_289_670); + assert!(info.has_store_tag(19) && info.has_store_tag(492)); + assert!(info.has_category(22) && info.has_category(29)); + assert!(!info.has_category(1)); + assert_eq!(info.deck_compat, 3); + assert_eq!(info.steamos_compat, 2); + assert_eq!(info.steam_machine_compat, 0); + } + + #[test] + fn a_truncated_file_fails_instead_of_returning_half_an_app() { + let full = sample(); + let cut = read_bytes(&full[..full.len() / 2], &[240]); + assert!(cut.is_err(), "a truncated string table has to be an error"); + } + + #[test] + fn a_wrong_magic_is_refused() { + let mut bytes = sample(); + bytes[0] ^= 0xff; + assert!(read_bytes(&bytes, &[240]).is_err()); + } + + #[test] + fn an_absurd_string_table_count_is_refused_rather_than_allocated() { + let mut bytes = sample(); + bytes[8..16].copy_from_slice(&0i64.to_le_bytes()); + assert!(read_bytes(&bytes, &[240]).is_err()); + } + + #[test] + fn runaway_nesting_is_refused_rather_than_followed() { + let mut b = Builder::new(); + let index = b.key("nested"); + b.open("appinfo"); + for _ in 0..(MAX_DEPTH as usize + 200) { + b.body.push(0); + b.body.extend_from_slice(&index.to_le_bytes()); + } + let bytes = b.finish(&[240]); + let apps = read_bytes(&bytes, &[240]).expect("the walk itself must not fail"); + assert!(apps.is_empty(), "the malformed app is dropped, not parsed"); + } +} diff --git a/src/backend/connected_steam.rs b/src/backend/connected_steam.rs index 257cc40..70aed98 100644 --- a/src/backend/connected_steam.rs +++ b/src/backend/connected_steam.rs @@ -14,6 +14,7 @@ // along with this program. If not, see . use crate::steam_client::client_engine_wrapper::ClientEngine; +use crate::steam_client::client_unified_messages_wrapper::ClientUnifiedMessages; use crate::steam_client::client_user_stats_map_wrapper::ClientUserStatsMap; use crate::steam_client::client_user_wrapper::ClientUser; use crate::steam_client::create_client::{create_client_engine, create_steam_client}; @@ -76,6 +77,11 @@ impl ConnectedSteam { Ok(self.engine.get_iclient_user_stats(user, pipe)?) } + pub fn unified_messages(&self) -> Result> { + let (pipe, user) = self.engine_handles; + Ok(self.engine.get_iclient_unified_messages(user, pipe)?) + } + pub fn client_user(&self) -> Result> { let (pipe, user) = self.engine_handles; Ok(self.engine.get_iclient_user(user, pipe)?) diff --git a/src/backend/friend_library.rs b/src/backend/friend_library.rs new file mode 100644 index 0000000..d0523f2 --- /dev/null +++ b/src/backend/friend_library.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Paul +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use crate::dev_println; +use crate::steam_client::client_unified_messages_wrapper::ClientUnifiedMessages; +use crate::steam_client::steamworks_types::{AppId_t, EResult}; +use crate::steam_client::wrapper_types::SteamClientError; +use crate::utils::app_paths::get_temp_cache_dir; +use crate::utils::ipc_types::SamError; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +const METHOD: &str = "Player.GetOwnedGames#1"; + +/// What Steam's own client keeps this answer for. +const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +fn cache_path(account_id: u32) -> PathBuf { + get_temp_cache_dir().join(format!("friend-games-{account_id}.json")) +} + +pub fn cached_owned_games(account_id: u32) -> Option> { + let path = cache_path(account_id); + if fs::metadata(&path).ok()?.modified().ok()?.elapsed().ok()? > CACHE_TTL { + return None; + } + let apps: Vec = serde_json::from_slice(&fs::read(&path).ok()?).ok()?; + Some(apps.into_iter().collect()) +} + +fn store(account_id: u32, owned: &HashSet) { + let apps: Vec = owned.iter().copied().collect(); + match serde_json::to_vec(&apps) { + Ok(bytes) => { + if let Err(e) = fs::write(cache_path(account_id), bytes) { + dev_println!("ORCH", "Could not cache friend {account_id}: {e}"); + } + } + Err(e) => dev_println!("ORCH", "Could not encode friend {account_id}: {e}"), + } +} + +const STEAM_ID64_BASE: u64 = 0x0110_0001_0000_0000; + +pub fn steam_id64(account_id: u32) -> u64 { + STEAM_ID64_BASE | u64::from(account_id) +} + +fn put_varint(out: &mut Vec, mut value: u64) { + while value >= 0x80 { + out.push((value as u8) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +fn take_varint(bytes: &[u8], pos: &mut usize) -> Option { + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let byte = *bytes.get(*pos)?; + *pos += 1; + if shift == 63 && byte & 0x7e != 0 { + return None; + } + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + } + None +} + +fn advance(bytes: &[u8], pos: &mut usize, n: usize) -> Option<()> { + let end = pos.checked_add(n)?; + (end <= bytes.len()).then(|| *pos = end) +} + +fn skip(bytes: &[u8], pos: &mut usize, wire: u64) -> Option<()> { + match wire { + 0 => take_varint(bytes, pos).map(|_| ()), + 1 => advance(bytes, pos, 8), + 2 => { + let len = take_varint(bytes, pos)? as usize; + advance(bytes, pos, len) + } + 5 => advance(bytes, pos, 4), + _ => None, + } +} + +fn encode_request(account_id: u32) -> Vec { + let mut out = Vec::with_capacity(16); + out.push(0x08); + put_varint(&mut out, steam_id64(account_id)); + out.extend_from_slice(&[0x18, 0x01]); + out.extend_from_slice(&[0x30, 0x00]); + out +} + +fn decode_app_ids(bytes: &[u8]) -> Option> { + let mut out = HashSet::new(); + let mut pos = 0usize; + while pos < bytes.len() { + let tag = take_varint(bytes, &mut pos)?; + let (field, wire) = (tag >> 3, tag & 7); + if field != 2 || wire != 2 { + skip(bytes, &mut pos, wire)?; + continue; + } + let len = take_varint(bytes, &mut pos)? as usize; + let end = pos.checked_add(len)?; + let game = bytes.get(pos..end)?; + pos = end; + + let mut inner = 0usize; + while inner < game.len() { + let tag = take_varint(game, &mut inner)?; + let (field, wire) = (tag >> 3, tag & 7); + if field == 1 && wire == 0 { + if let Ok(app_id) = AppId_t::try_from(take_varint(game, &mut inner)?) { + out.insert(app_id); + } + } else { + skip(game, &mut inner, wire)?; + } + } + } + Some(out) +} + +pub fn owned_games( + unified: &ClientUnifiedMessages, + account_id: u32, + deadline: Instant, +) -> Result, SamError> { + let response = match unified.call(METHOD, &encode_request(account_id), deadline) { + Ok(response) => response, + // A refusal is permanent; anything else Steam answers is transient. + Err(SteamClientError::MethodResultFailed(_, result)) + if result == EResult::k_EResultAccessDenied as i32 => + { + dev_println!("ORCH", "{METHOD} for {account_id} was refused"); + return Ok(HashSet::new()); + } + Err(e) => { + dev_println!("ORCH", "{METHOD} for {account_id} failed: {e}"); + return Err(SamError::UnknownError); + } + }; + let owned = decode_app_ids(&response).ok_or_else(|| { + dev_println!( + "ORCH", + "{METHOD} for {account_id} returned a malformed body" + ); + SamError::UnknownError + })?; + store(account_id, &owned); + Ok(owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn game(app_id: u32, playtime: u32) -> Vec { + let mut inner = vec![0x08]; + put_varint(&mut inner, u64::from(app_id)); + inner.push(0x20); + put_varint(&mut inner, u64::from(playtime)); + let mut out = vec![0x12]; + put_varint(&mut out, inner.len() as u64); + out.extend_from_slice(&inner); + out + } + + #[test] + fn the_request_matches_what_steams_own_library_sends() { + let bytes = encode_request(58903702); + let mut pos = 1usize; + assert_eq!(bytes[0], 0x08); + assert_eq!( + take_varint(&bytes, &mut pos), + Some(STEAM_ID64_BASE + 58903702) + ); + assert_eq!(&bytes[pos..], &[0x18, 0x01, 0x30, 0x00]); + } + + #[test] + fn an_account_id_widens_to_an_individual_public_steam_id() { + assert_eq!(steam_id64(1), 76561197960265729); + } + + #[test] + fn only_the_app_ids_are_taken_out_of_the_games_list() { + let mut body = vec![0x08, 0x02]; + body.extend(game(240, 12)); + body.extend(game(730, 0)); + assert_eq!(decode_app_ids(&body), Some(HashSet::from([240, 730]))); + } + + #[test] + fn an_empty_body_is_an_empty_library_rather_than_an_error() { + assert_eq!(decode_app_ids(&[]), Some(HashSet::new())); + } + + #[test] + fn a_truncated_body_is_refused() { + let body = game(240, 12); + for cut in 1..body.len() { + assert_eq!(decode_app_ids(&body[..cut]), None, "cut at {cut}"); + } + } + + #[test] + fn a_cached_library_round_trips_and_expires() { + // A real account id would collide with a live cache entry. + let account_id = u32::MAX - 7; + let owned = HashSet::from([240, 730, 440]); + store(account_id, &owned); + assert_eq!(cached_owned_games(account_id), Some(owned)); + + let path = cache_path(account_id); + let stale = std::time::SystemTime::now() - CACHE_TTL - Duration::from_secs(60); + fs::File::options() + .write(true) + .open(&path) + .expect("cache file should exist") + .set_modified(stale) + .expect("mtime should be settable"); + assert_eq!(cached_owned_games(account_id), None); + + let _ = fs::remove_file(&path); + assert_eq!(cached_owned_games(account_id), None); + } + + #[test] + fn unknown_fields_of_every_wire_type_are_skipped() { + let mut body = vec![0x0d, 1, 2, 3, 4]; + body.push(0x11); + body.extend_from_slice(&[0; 8]); + body.extend_from_slice(&[0x1a, 0x02, 0xff, 0xff]); + body.extend(game(440, 5)); + assert_eq!(decode_app_ids(&body), Some(HashSet::from([440]))); + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index caad808..fee1b31 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -14,9 +14,11 @@ // along with this program. If not, see . pub mod app; +pub mod app_info; pub mod app_lister; pub mod app_manager; pub mod connected_steam; +pub mod friend_library; pub mod key_value; pub mod local_config; pub mod local_stats; @@ -25,6 +27,7 @@ pub mod orchestrator_client; pub mod progress_io; pub mod stat_definitions; pub mod stats_access; +pub mod steam_collections; mod tests; pub mod types; pub mod user_unlock_times; diff --git a/src/backend/orchestrator.rs b/src/backend/orchestrator.rs index d1d047c..fcf86eb 100644 --- a/src/backend/orchestrator.rs +++ b/src/backend/orchestrator.rs @@ -13,9 +13,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use crate::backend::app_info; use crate::backend::app_lister::{AppLister, fetch_achievement_counts}; use crate::backend::connected_steam::ConnectedSteam; -use crate::backend::local_config::parse_localconfig; +use crate::backend::friend_library; +use crate::backend::local_config::{PlaytimeMap, parse_localconfig}; use crate::backend::local_stats::{LocalIndex, read_schema_languages}; use crate::backend::orchestrator_client::AppProgress; use crate::backend::progress_io::{MAX_CONCURRENT_APPS, run_command_on_apps_concurrent}; @@ -23,6 +25,7 @@ use crate::backend::stat_definitions::{AchievementInfo, StatInfo}; use crate::backend::stats_access::{ app_server_command, idle_app_server_command, set_stealth, stealth, }; +use crate::backend::steam_collections::{self, CollectionModel, LibraryFacts}; use crate::backend::user_unlock_times; use crate::dev_println; use crate::utils::bidir_child::BidirChild; @@ -38,6 +41,7 @@ use serde::de::DeserializeOwned; use std::collections::{HashMap, HashSet}; use std::io::Write; use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; /// Forward `command` to the app server and return the framed response bytes /// (length prefix + JSON) suitable for proxying straight back to the parent. @@ -118,6 +122,101 @@ fn ensure_connected(slot: &mut Option) -> Result<&mut ConnectedS Ok(slot.as_mut().unwrap()) } +/// Spent holding the orchestrator lock, so every other request waits on it. +const FRIEND_LIBRARY_BUDGET: Duration = Duration::from_secs(5); +const FRIEND_LIBRARY_FLOOR: Duration = Duration::from_millis(500); + +fn collections_for(connected_steam: &mut ConnectedSteam, library: &[u32]) -> Vec { + let Ok(steam_id) = connected_steam.user.get_steam_id() else { + return Vec::new(); + }; + let account_id = user_unlock_times::account_id(steam_id.m_steamid); + let Some(path) = SteamLocator::get_collections_path(account_id) else { + dev_println!("ORCH", "No collections file for account {account_id}"); + return Vec::new(); + }; + let Ok(collections) = steam_collections::parse(&path) else { + return Vec::new(); + }; + + let mut playtimes = PlaytimeMap::new(); + let mut installed = HashSet::new(); + let mut app_info = HashMap::new(); + let mut have_playtimes = true; + let mut have_installed = true; + let mut have_app_info = true; + if collections.iter().any(|c| c.needs_playtimes()) { + match SteamLocator::get_collections_local_config_path(account_id) + .and_then(|p| parse_localconfig(&p).ok()) + { + Some(loaded) => playtimes = loaded, + None => have_playtimes = false, + } + } + if collections.iter().any(|c| c.needs_installed()) { + match SteamLocator::get_library_folders_path(account_id) + .and_then(|p| steam_collections::installed_apps(&p)) + { + Some(loaded) => installed = loaded, + None => have_installed = false, + } + } + if collections.iter().any(|c| c.needs_app_info()) { + let wanted: HashSet = library.iter().copied().collect(); + match SteamLocator::get_app_info_path(account_id) + .and_then(|p| app_info::read(&p, &wanted).ok()) + { + Some(loaded) => app_info = loaded, + None => have_app_info = false, + } + } + + let mut friends_owned: HashMap> = HashMap::new(); + let mut wanted_friends: Vec = collections.iter().flat_map(|c| c.friend_ids()).collect(); + wanted_friends.sort_unstable(); + wanted_friends.dedup(); + let online = connected_steam.user.b_logged_on() != Ok(false); + let mut uncached: Vec = Vec::new(); + for friend in wanted_friends { + match friend_library::cached_owned_games(friend) { + Some(owned) => { + friends_owned.insert(friend, owned); + } + None => uncached.push(friend), + } + } + + if !uncached.is_empty() && online { + match connected_steam.unified_messages() { + Ok(unified) => { + let deadline = Instant::now() + FRIEND_LIBRARY_BUDGET; + for friend in uncached { + if deadline.saturating_duration_since(Instant::now()) < FRIEND_LIBRARY_FLOOR { + dev_println!("ORCH", "Out of time before reading friend {friend}"); + break; + } + if let Ok(owned) = friend_library::owned_games(&unified, friend, deadline) { + friends_owned.insert(friend, owned); + } + } + } + Err(e) => dev_println!("ORCH", "No unified messages interface: {e}"), + } + } + + let facts = LibraryFacts { + playtimes: &playtimes, + app_info: &app_info, + installed: &installed, + have_app_info, + have_playtimes, + have_installed, + friends_owned: &friends_owned, + online, + }; + steam_collections::resolve(collections, library, &facts) +} + /// Drop a stale connection if Steam was restarted, then `ensure_connected`. Used /// by the orchestrator's own (non-app-scoped) commands. fn orchestrator_connection(slot: &mut Option) -> Result<&mut ConnectedSteam, ()> { @@ -688,6 +787,15 @@ fn process_command( send(tx, &SteamResponse::from(friends)); } + SteamCommand::GetSteamOnline => { + // Offline is Steam running without a connection, not Steam absent. + let online = match orchestrator_connection(connected_steam) { + Ok(cs) => cs.user.b_logged_on() != Ok(false), + Err(()) => true, + }; + send(tx, &SteamResponse::from(Ok::(online))); + } + SteamCommand::GetCurrentUser => { let steam_id: Result = match orchestrator_connection(connected_steam) { Ok(cs) => cs.user.get_steam_id().map(|id| id.m_steamid).map_err(|e| { @@ -699,6 +807,14 @@ fn process_command( send(tx, &SteamResponse::from(steam_id)); } + SteamCommand::GetCollections(library) => { + let collections = match orchestrator_connection(connected_steam) { + Ok(cs) => collections_for(cs, &library), + Err(()) => Vec::new(), + }; + send(tx, &SteamResponse::Success(collections)); + } + SteamCommand::GetUserAvatar(steam_id64) => { let avatar = match orchestrator_connection(connected_steam) { Ok(cs) => user_unlock_times::fetch_user_avatar(&cs.friends, &cs.utils, steam_id64), diff --git a/src/backend/orchestrator_client.rs b/src/backend/orchestrator_client.rs index 4b41fb2..d26de4e 100644 --- a/src/backend/orchestrator_client.rs +++ b/src/backend/orchestrator_client.rs @@ -21,6 +21,7 @@ use crate::backend::app_lister::AppModel; use crate::backend::stat_definitions::{AchievementInfo, StatInfo}; +use crate::backend::steam_collections::CollectionModel; use crate::backend::user_unlock_times::{AchievementUnlock, AvatarImage, Friend}; use crate::dev_println; #[cfg(feature = "gui")] @@ -212,8 +213,13 @@ request!(GetFriendAchievementCount { app_id: u32, steam_id64: u64 } -> (u32, u32 request!(GetFriends -> Vec => SteamCommand::GetFriends); +request!(GetCollections { library: Vec } -> Vec + => SteamCommand::GetCollections(library)); + request!(GetCurrentUser -> u64 => SteamCommand::GetCurrentUser); +request!(GetSteamOnline -> bool => SteamCommand::GetSteamOnline); + request!(GetUserAvatar { steam_id64: u64 } -> Option => SteamCommand::GetUserAvatar(steam_id64)); diff --git a/src/backend/steam_collections.rs b/src/backend/steam_collections.rs new file mode 100644 index 0000000..89089e4 --- /dev/null +++ b/src/backend/steam_collections.rs @@ -0,0 +1,815 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Paul +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use crate::backend::app_info::AppInfo; +use crate::backend::local_config::PlaytimeMap; +use crate::dev_println; +use crate::steam_client::steamworks_types::AppId_t; +use crate::utils::ipc_types::SamError; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +const KEY_PREFIX: &str = "user-collections."; +#[cfg_attr(not(feature = "gui"), allow(dead_code))] +pub const FAVORITE_ID: &str = "favorite"; +#[cfg_attr(not(feature = "gui"), allow(dead_code))] +pub const HIDDEN_ID: &str = "hidden"; + +// Positions 0 and 3 exist in the format; Valve's matcher tests neither. +const GROUP_STATE: usize = 1; +const GROUP_FEATURES: usize = 2; +const GROUP_STORE_TAGS: usize = 4; +const GROUP_SUBSCRIPTION: usize = 5; +const GROUP_FRIENDS: usize = 6; +const GROUP_LANGUAGES: usize = 7; +const GROUP_CATEGORIES: usize = 8; +const METADATA_GROUPS: [usize; 5] = [ + GROUP_FEATURES, + GROUP_STORE_TAGS, + GROUP_SUBSCRIPTION, + GROUP_LANGUAGES, + GROUP_CATEGORIES, +]; +const LANGUAGE_COUNT: u32 = 32; +const KNOWN_GROUP_COUNT: usize = 9; + +const COMPAT_UNSUPPORTED: i32 = 1; +const COMPAT_PLAYABLE: i32 = 2; +const COMPAT_VERIFIED: i32 = 3; +const STEAMOS_UNSUPPORTED: i32 = 1; +const STEAMOS_COMPATIBLE: i32 = 2; + +const OPTION_DECK_UNSUPPORTED: u32 = 15; +const OPTION_INSTALLED: u32 = 1; +const OPTION_PLAYED: u32 = 3; +const OPTION_UNPLAYED: u32 = 4; +const OPTION_EA_SUBSCRIPTION: u32 = 4000; +const EA_PLAY_APP_ID: u32 = 1_289_670; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsupportedReason { + SearchText, + UnknownFilter, + Unavailable, + Offline, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CollectionModel { + pub id: String, + pub name: String, + pub app_ids: Vec, + pub unsupported: Option, +} + +#[derive(Deserialize)] +struct RawEntry { + #[serde(default)] + value: Option, +} + +#[derive(Deserialize, Default)] +struct FilterGroup { + #[serde(default, rename = "rgOptions")] + options: Vec, + #[serde(default, rename = "bAcceptUnion")] + accept_union: bool, +} + +const SPEC_FORMAT_VERSION: u32 = 2; + +#[derive(Deserialize, Default)] +struct FilterSpec { + /// An unknown version -- absent included -- builds no filter: the collection + /// is then static. + #[serde(default, rename = "nFormatVersion")] + format_version: Option, + #[serde(default, rename = "strSearchText")] + search_text: String, + #[serde(default, rename = "filterGroups")] + groups: Vec, +} + +#[derive(Deserialize)] +struct RawCollection { + id: String, + #[serde(default)] + name: String, + #[serde(default)] + added: Vec, + #[serde(default)] + removed: Vec, + #[serde(default, rename = "filterSpec")] + filter_spec: Option, +} + +pub struct Collection { + raw: RawCollection, + unsupported: Option, +} + +impl Collection { + fn usable_spec(&self) -> Option<&FilterSpec> { + if self.unsupported.is_some() { + return None; + } + self.raw.filter_spec.as_ref() + } + + pub fn needs_app_info(&self) -> bool { + self.usable_spec() + .is_some_and(|spec| METADATA_GROUPS.iter().any(|g| spec.group(*g).is_some())) + } + + fn state_options(&self, wanted: &[u32]) -> bool { + self.usable_spec() + .and_then(|spec| spec.group(GROUP_STATE)) + .is_some_and(|group| group.options.iter().any(|o| wanted.contains(o))) + } + + pub fn needs_playtimes(&self) -> bool { + self.state_options(&[OPTION_PLAYED, OPTION_UNPLAYED]) + } + + pub fn needs_installed(&self) -> bool { + self.state_options(&[OPTION_INSTALLED]) + } + + pub fn friend_ids(&self) -> Vec { + self.usable_spec() + .and_then(|spec| spec.group(GROUP_FRIENDS)) + .map(|group| group.options.clone()) + .unwrap_or_default() + } +} + +impl FilterSpec { + /// Steam drops this option as it loads a spec, so a stored one means nothing. + fn strip_ignored_options(&mut self) { + if let Some(group) = self.groups.get_mut(GROUP_FEATURES) { + group.options.retain(|o| *o != OPTION_DECK_UNSUPPORTED); + } + } + + /// Steam matches *no* game for a spec with nothing set, not every game. + fn is_empty(&self) -> bool { + self.search_text.is_empty() && self.groups.iter().all(|g| g.options.is_empty()) + } + + fn group(&self, index: usize) -> Option<&FilterGroup> { + self.groups.get(index).filter(|g| !g.options.is_empty()) + } +} + +pub struct LibraryFacts<'a> { + pub playtimes: &'a PlaytimeMap, + pub app_info: &'a HashMap, + pub installed: &'a HashSet, + /// Answering from facts that failed to load would empty a collection while + /// still presenting it as an answer. + pub have_app_info: bool, + pub have_playtimes: bool, + pub have_installed: bool, + pub friends_owned: &'a HashMap>, + pub online: bool, +} + +fn feature_matches(option: u32, info: &AppInfo) -> Option { + let full = info.controller_support == "full"; + let partial = info.controller_support == "partial"; + let any_category = |ids: &[u32]| ids.iter().any(|id| info.has_category(*id)); + + Some(match option { + 1 => full || info.has_category(28), + 2 => full || partial || any_category(&[28, 18]), + // VR tests live on the client's app overview, not in appinfo.vdf. + 3 | 26 => return None, + 4 => info.has_category(29), + 5 => info.has_category(30), + 6 => info.has_category(22), + 7 => info.has_category(2), + 8 => any_category(&[1, 36, 37, 27, 20, 24]), + 9 => any_category(&[9, 38, 39]), + 10 => info.has_category(23), + 11 => info.has_category(44), + 12 => info.deck_compat >= COMPAT_VERIFIED, + 13 => info.deck_compat >= COMPAT_PLAYABLE, + 14 => info.deck_compat != COMPAT_UNSUPPORTED, + 16 => any_category(&[55, 56]), + 17 => info.has_category(56), + 18 => any_category(&[57, 58]), + 19 => info.has_category(58), + 20 => info.has_category(59), + 21 => info.has_category(60), + 22 => info.has_category(61), + 23 => info.has_category(62), + 24 => info.steamos_compat >= STEAMOS_COMPATIBLE, + 25 => info.steamos_compat != STEAMOS_UNSUPPORTED, + 27 => any_category(&[39, 37, 24]), + // Valve's matcher has no AnyController branch, so it matches nothing there. + 28 => false, + 29 => info.steam_machine_compat >= COMPAT_VERIFIED, + 30 => info.steam_machine_compat >= COMPAT_PLAYABLE, + 31 => info.steam_machine_compat != COMPAT_UNSUPPORTED, + _ => return None, + }) +} + +fn state_matches(option: u32, played: bool, installed: bool) -> Option { + Some(match option { + OPTION_INSTALLED => installed, + OPTION_PLAYED => played, + OPTION_UNPLAYED => !played, + _ => return None, + }) +} + +fn group_matches(group: &FilterGroup, mut predicate: impl FnMut(u32) -> bool) -> bool { + if group.accept_union { + group.options.iter().any(|o| predicate(*o)) + } else { + group.options.iter().all(|o| predicate(*o)) + } +} + +fn unsupported_reason(spec: &FilterSpec) -> Option { + if !spec.search_text.is_empty() { + return Some(UnsupportedReason::SearchText); + } + if (KNOWN_GROUP_COUNT..spec.groups.len()).any(|i| spec.group(i).is_some()) { + return Some(UnsupportedReason::UnknownFilter); + } + + let probe = AppInfo::default(); + let known = |group: Option<&FilterGroup>, check: &dyn Fn(u32) -> bool| { + group.is_none_or(|g| g.options.iter().all(|o| check(*o))) + }; + let all_known = known(spec.group(GROUP_STATE), &|o| { + state_matches(o, false, false).is_some() + }) && known(spec.group(GROUP_FEATURES), &|o| { + feature_matches(o, &probe).is_some() + }) && known(spec.group(GROUP_SUBSCRIPTION), &|o| { + o == OPTION_EA_SUBSCRIPTION + }) && known(spec.group(GROUP_LANGUAGES), &|o| o < LANGUAGE_COUNT); + + (!all_known).then_some(UnsupportedReason::UnknownFilter) +} + +fn matches(spec: &FilterSpec, app_id: AppId_t, facts: &LibraryFacts) -> bool { + if let Some(group) = spec.group(GROUP_STATE) { + let played = facts + .playtimes + .get(&app_id) + .and_then(|p| p.last_played) + .is_some_and(|last| last > 0); + let installed = facts.installed.contains(&app_id); + if !group_matches(group, |o| { + state_matches(o, played, installed).unwrap_or(false) + }) { + return false; + } + } + + // Steam evaluates a blank overview for apps it has no metadata on. + let missing = AppInfo::default(); + let info = facts.app_info.get(&app_id).unwrap_or(&missing); + if let Some(group) = spec.group(GROUP_FEATURES) + && !group_matches(group, |o| feature_matches(o, info).unwrap_or(false)) + { + return false; + } + if let Some(group) = spec.group(GROUP_STORE_TAGS) + && !group_matches(group, |o| info.has_store_tag(o)) + { + return false; + } + if let Some(group) = spec.group(GROUP_SUBSCRIPTION) + && !group_matches(group, |o| { + o == OPTION_EA_SUBSCRIPTION && info.mastersub_appid == EA_PLAY_APP_ID + }) + { + return false; + } + // Valve also drops a game the filtered friend is the one lending, which + // nothing we can read names. + if let Some(group) = spec.group(GROUP_FRIENDS) + && !group_matches(group, |o| { + facts + .friends_owned + .get(&o) + .is_some_and(|owned| owned.contains(&app_id)) + }) + { + return false; + } + if let Some(group) = spec.group(GROUP_LANGUAGES) + && !group_matches(group, |o| info.has_language(o)) + { + return false; + } + if let Some(group) = spec.group(GROUP_CATEGORIES) + && !group_matches(group, |o| info.has_category(o)) + { + return false; + } + true +} + +fn parse_str(contents: &str) -> Result, SamError> { + let entries: Vec<(String, RawEntry)> = serde_json::from_str(contents).map_err(|e| { + dev_println!("ORCH", "Failed to parse collections: {e}"); + SamError::UnknownError + })?; + + let mut out = Vec::new(); + for (key, entry) in entries { + if !key.starts_with(KEY_PREFIX) { + continue; + } + let Some(value) = entry.value else { continue }; + let mut raw: RawCollection = match serde_json::from_str(&value) { + Ok(raw) => raw, + Err(e) => { + dev_println!("ORCH", "Skipping collection {key}: {e}"); + continue; + } + }; + raw.filter_spec + .take_if(|spec| spec.format_version != Some(SPEC_FORMAT_VERSION)); + if let Some(spec) = raw.filter_spec.as_mut() { + spec.strip_ignored_options(); + } + let unsupported = raw.filter_spec.as_ref().and_then(unsupported_reason); + out.push(Collection { raw, unsupported }); + } + Ok(out) +} + +pub fn parse(path: &Path) -> Result, SamError> { + let contents = std::fs::read_to_string(path).map_err(|e| { + dev_println!("ORCH", "Failed to read {}: {e}", path.display()); + SamError::UnknownError + })?; + parse_str(&contents) +} + +pub fn resolve( + collections: Vec, + library: &[AppId_t], + facts: &LibraryFacts, +) -> Vec { + collections + .into_iter() + .map(|collection| { + let unsupported = collection.unsupported.or_else(|| { + let missing_friend = collection + .friend_ids() + .iter() + .any(|id| !facts.friends_owned.contains_key(id)); + if missing_friend && !facts.online { + return Some(UnsupportedReason::Offline); + } + let starved = missing_friend + || (collection.needs_app_info() && !facts.have_app_info) + || (collection.needs_playtimes() && !facts.have_playtimes) + || (collection.needs_installed() && !facts.have_installed); + starved.then_some(UnsupportedReason::Unavailable) + }); + let raw = collection.raw; + let mut app_ids = Vec::new(); + + if unsupported.is_none() { + let mut members: HashSet = HashSet::new(); + if let Some(spec) = raw.filter_spec.as_ref().filter(|spec| !spec.is_empty()) { + members.extend( + library + .iter() + .copied() + .filter(|id| matches(spec, *id, facts)), + ); + } + // Steam's order: matches, additions, then removals (dynamic only). + members.extend(raw.added.iter().copied()); + if raw.filter_spec.is_some() { + for id in &raw.removed { + members.remove(id); + } + } + app_ids.extend(library.iter().copied().filter(|id| members.contains(id))); + } + + CollectionModel { + id: raw.id, + name: raw.name, + app_ids, + unsupported, + } + }) + .collect() +} + +/// `None` means we could not tell, which is not the same as nothing installed. +pub fn installed_apps(path: &Path) -> Option> { + #[derive(Deserialize)] + struct Folder { + #[serde(default)] + apps: HashMap, + } + + let contents = std::fs::read_to_string(path) + .inspect_err(|e| dev_println!("ORCH", "Failed to read {}: {e}", path.display())) + .ok()?; + let folders: HashMap = keyvalues_serde::from_str(&contents) + .inspect_err(|e| dev_println!("ORCH", "Failed to parse libraryfolders.vdf: {e}")) + .ok()?; + Some( + folders + .values() + .flat_map(|folder| folder.apps.keys()) + .filter_map(|id| id.parse::().ok()) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::local_config::AppPlaytime; + + const FIXTURE: &str = r#"[ + ["GameReleased", {"key":"GameReleased","timestamp":1,"value":"{}","version":"1"}], + ["user-collections.favorite", {"key":"user-collections.favorite","timestamp":2, + "value":"{\"id\":\"favorite\",\"name\":\"Favoris\",\"added\":[240,730,99999],\"removed\":[]}"}], + ["user-collections.uc-gone", {"key":"user-collections.uc-gone","timestamp":3, + "is_deleted":true,"version":"5"}], + ["user-collections.uc-farm", {"key":"user-collections.uc-farm","timestamp":4, + "value":"{\"id\":\"uc-farm\",\"name\":\"Farm\",\"added\":[555],\"removed\":[240],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":true},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-friend", {"key":"user-collections.uc-friend","timestamp":5, + "value":"{\"id\":\"uc-friend\",\"name\":\"Squad\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":true},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[12345],\"bAcceptUnion\":true},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-vr", {"key":"user-collections.uc-vr","timestamp":6, + "value":"{\"id\":\"uc-vr\",\"name\":\"VR\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":true},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[3],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-search", {"key":"user-collections.uc-search","timestamp":7, + "value":"{\"id\":\"uc-search\",\"name\":\"Search\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"portal\",\"filterGroups\":[]}}"}], + ["user-collections.uc-noversion", {"key":"user-collections.uc-noversion","timestamp":9, + "value":"{\"id\":\"uc-noversion\",\"name\":\"uc-noversion\",\"added\":[555],\"removed\":[],\"filterSpec\":{\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-ignored", {"key":"user-collections.uc-ignored","timestamp":9, + "value":"{\"id\":\"uc-ignored\",\"name\":\"uc-ignored\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[1],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[2],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-lang", {"key":"user-collections.uc-lang","timestamp":9, + "value":"{\"id\":\"uc-lang\",\"name\":\"uc-lang\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[0],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-static", {"key":"user-collections.uc-static","timestamp":9, + "value":"{\"id\":\"uc-static\",\"name\":\"uc-static\",\"added\":[240,730],\"removed\":[730]}"}], + ["user-collections.uc-newformat", {"key":"user-collections.uc-newformat","timestamp":9, + "value":"{\"id\":\"uc-newformat\",\"name\":\"uc-newformat\",\"added\":[240,777],\"removed\":[240],\"filterSpec\":{\"nFormatVersion\":3,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-blank", {"key":"user-collections.uc-blank","timestamp":9, + "value":"{\"id\":\"uc-blank\",\"name\":\"Blank\",\"added\":[777],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-legacy", {"key":"user-collections.uc-legacy","timestamp":9, + "value":"{\"id\":\"uc-legacy\",\"name\":\"Legacy\",\"added\":[777],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[15],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-both", {"key":"user-collections.uc-both","timestamp":9, + "value":"{\"id\":\"uc-both\",\"name\":\"Both\",\"added\":[555],\"removed\":[555],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-installed", {"key":"user-collections.uc-installed","timestamp":9, + "value":"{\"id\":\"uc-installed\",\"name\":\"Installed\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[1],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-union", {"key":"user-collections.uc-union","timestamp":9, + "value":"{\"id\":\"uc-union\",\"name\":\"Union\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4,6],\"bAcceptUnion\":true},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-all", {"key":"user-collections.uc-all","timestamp":9, + "value":"{\"id\":\"uc-all\",\"name\":\"All of\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4,6],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-removed", {"key":"user-collections.uc-removed","timestamp":9, + "value":"{\"id\":\"uc-removed\",\"name\":\"Removed\",\"added\":[],\"removed\":[730],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-deck", {"key":"user-collections.uc-deck","timestamp":9, + "value":"{\"id\":\"uc-deck\",\"name\":\"Deck\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[15,4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false}]}}"}], + ["user-collections.uc-future", {"key":"user-collections.uc-future","timestamp":9, + "value":"{\"id\":\"uc-future\",\"name\":\"Future\",\"added\":[],\"removed\":[],\"filterSpec\":{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[4],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[],\"bAcceptUnion\":false},{\"rgOptions\":[1],\"bAcceptUnion\":false}]}}"}] + ]"#; + + fn card_game() -> AppInfo { + AppInfo { + categories: HashSet::from([29]), + ..AppInfo::default() + } + } + + fn english_card_game() -> AppInfo { + AppInfo { + languages: HashSet::from([0]), + ..card_game() + } + } + + fn parsed() -> Vec { + parse_str(FIXTURE).expect("fixture should parse") + } + + fn resolved() -> HashMap { + let library = [240, 730, 555, 777, 999]; + let playtimes = PlaytimeMap::from([( + 240, + AppPlaytime { + playtime_minutes: Some(10), + last_played: Some(1_600_000_000), + }, + )]); + let app_info = HashMap::from([(730, english_card_game()), (999, card_game())]); + let installed = HashSet::new(); + let friends_owned = HashMap::from([(12345, HashSet::from([730, 777]))]); + let facts = LibraryFacts { + playtimes: &playtimes, + app_info: &app_info, + installed: &installed, + have_app_info: true, + have_playtimes: true, + have_installed: true, + friends_owned: &friends_owned, + online: true, + }; + resolve(parsed(), &library, &facts) + .into_iter() + .map(|c| (c.id.clone(), c)) + .collect() + } + + #[test] + fn non_collection_keys_and_tombstones_are_skipped() { + let ids: Vec = parsed().into_iter().map(|c| c.raw.id).collect(); + assert!( + !ids.iter().any(|id| id == "uc-gone"), + "tombstone kept: {ids:?}" + ); + assert!(!ids.iter().any(|id| id == "GameReleased")); + assert_eq!(ids.len(), 19); + } + + #[test] + fn a_static_collection_is_its_added_list_intersected_with_the_library() { + let models = resolved(); + let favorite = &models[FAVORITE_ID]; + assert_eq!(favorite.unsupported, None); + assert_eq!(favorite.app_ids, vec![240, 730]); + } + + #[test] + fn a_dynamic_collection_matches_then_adds_manually() { + let models = resolved(); + let farm = &models["uc-farm"]; + assert_eq!(farm.unsupported, None); + assert_eq!(farm.app_ids, vec![730, 555, 999]); + } + + #[test] + fn a_short_group_list_is_treated_as_empty_trailing_groups() { + assert!( + parsed() + .iter() + .any(|c| c.raw.id == "uc-farm" && c.unsupported.is_none()) + ); + } + + #[test] + fn installed_apps_are_collected_across_every_library_folder() { + let vdf = "\"libraryfolders\"\n{\n\t\"0\"\n\t{\n\t\t\"path\"\t\t\"/games\"\n\t\t\"apps\"\n\t\t{\n\t\t\t\"240\"\t\t\"1\"\n\t\t}\n\t}\n\t\"1\"\n\t{\n\t\t\"path\"\t\t\"/more\"\n\t\t\"apps\"\n\t\t{\n\t\t\t\"730\"\t\t\"2\"\n\t\t}\n\t}\n}\n"; + let path = std::env::temp_dir().join(format!( + "sam_test_libraryfolders_{}.vdf", + std::process::id() + )); + std::fs::write(&path, vdf).expect("fixture should write"); + let installed = installed_apps(&path); + std::fs::remove_file(&path).ok(); + assert_eq!(installed, Some(HashSet::from([240, 730]))); + } + + #[test] + fn a_missing_library_folders_file_reads_as_unknown_not_as_nothing_installed() { + assert_eq!( + installed_apps(Path::new("/nonexistent/libraryfolders.vdf")), + None + ); + } + + #[test] + fn a_spec_with_nothing_set_matches_nothing_rather_than_everything() { + let models = resolved(); + assert_eq!(models["uc-blank"].unsupported, None); + assert_eq!(models["uc-blank"].app_ids, vec![777]); + assert_eq!(models["uc-legacy"].app_ids, vec![777]); + } + + #[test] + fn the_positions_steam_never_tests_are_ignored_rather_than_refused() { + let models = resolved(); + assert_eq!(models["uc-ignored"].unsupported, None); + assert_eq!(models["uc-ignored"].app_ids, vec![240, 730, 555, 777, 999]); + } + + #[test] + fn a_language_filter_reads_the_languages_appinfo_lists() { + let models = resolved(); + assert_eq!(models["uc-lang"].unsupported, None); + assert_eq!(models["uc-lang"].app_ids, vec![730]); + } + + #[test] + fn a_language_past_the_ones_we_know_is_refused() { + let spec = format!( + "{{\"id\":\"x\",\"name\":\"x\",\"added\":[],\"removed\":[],\"filterSpec\": {{\"nFormatVersion\":2,\"strSearchText\":\"\",\"filterGroups\":[{}]}}}}", + (0..9) + .map(|i| if i == GROUP_LANGUAGES { + "{\"rgOptions\":[99],\"bAcceptUnion\":false}" + } else { + "{\"rgOptions\":[],\"bAcceptUnion\":false}" + }) + .collect::>() + .join(",") + ); + let json = format!( + "[[\"user-collections.x\",{{\"key\":\"user-collections.x\",\"value\":{}}}]]", + serde_json::to_string(&spec).expect("string should serialise") + ); + let parsed = parse_str(&json).expect("fixture should parse"); + assert_eq!( + parsed[0].unsupported, + Some(UnsupportedReason::UnknownFilter) + ); + } + + #[test] + fn a_static_collection_ignores_its_removed_list() { + assert_eq!(resolved()["uc-static"].app_ids, vec![240, 730]); + } + + #[test] + fn a_spec_in_an_unknown_format_version_is_treated_as_no_filter() { + let models = resolved(); + assert_eq!(models["uc-newformat"].unsupported, None); + assert_eq!(models["uc-newformat"].app_ids, vec![240, 777]); + assert_eq!(models["uc-noversion"].app_ids, vec![555]); + } + + #[test] + fn a_removal_beats_an_addition_of_the_same_game() { + assert_eq!(resolved()["uc-both"].app_ids, vec![730, 999]); + } + + #[test] + fn a_collection_is_refused_when_the_files_behind_it_could_not_be_read() { + let library = [240, 730]; + let playtimes = PlaytimeMap::new(); + let app_info = HashMap::new(); + let installed = HashSet::new(); + let friends_owned = HashMap::new(); + let facts = LibraryFacts { + playtimes: &playtimes, + app_info: &app_info, + installed: &installed, + have_app_info: false, + have_playtimes: false, + have_installed: false, + friends_owned: &friends_owned, + online: true, + }; + let models: HashMap = resolve(parsed(), &library, &facts) + .into_iter() + .map(|c| (c.id.clone(), c)) + .collect(); + assert_eq!( + models["uc-union"].unsupported, + Some(UnsupportedReason::Unavailable), + "needs appinfo.vdf" + ); + assert_eq!( + models["uc-installed"].unsupported, + Some(UnsupportedReason::Unavailable), + "needs the play state files" + ); + assert_eq!(models[FAVORITE_ID].unsupported, None); + assert_eq!(models["uc-blank"].unsupported, None); + } + + #[test] + fn only_the_collections_that_needed_the_missing_file_are_refused() { + let starve = |playtimes: bool, installed: bool| { + let times = PlaytimeMap::new(); + let app_info = HashMap::from([(730, card_game()), (999, card_game())]); + let apps = HashSet::new(); + let facts = LibraryFacts { + playtimes: ×, + app_info: &app_info, + installed: &apps, + have_app_info: true, + have_playtimes: playtimes, + have_installed: installed, + friends_owned: &HashMap::new(), + online: true, + }; + resolve(parsed(), &[240, 730], &facts) + .into_iter() + .map(|c| (c.id.clone(), c.unsupported)) + .collect::>>() + }; + + let no_playtimes = starve(false, true); + assert_eq!( + no_playtimes["uc-farm"], + Some(UnsupportedReason::Unavailable) + ); + assert_eq!(no_playtimes["uc-installed"], None); + + let no_installed = starve(true, false); + assert_eq!(no_installed["uc-farm"], None); + assert_eq!( + no_installed["uc-installed"], + Some(UnsupportedReason::Unavailable) + ); + } + + #[test] + fn a_group_is_a_union_or_an_intersection_of_its_options() { + let models = resolved(); + assert_eq!(models["uc-union"].app_ids, vec![730, 999]); + assert!(models["uc-all"].app_ids.is_empty()); + } + + #[test] + fn a_removal_takes_a_game_back_out_of_a_filters_own_matches() { + assert_eq!(resolved()["uc-removed"].app_ids, vec![999]); + } + + #[test] + fn the_option_steam_ignores_is_dropped_rather_than_failed() { + let models = resolved(); + assert_eq!(models["uc-deck"].unsupported, None); + assert_eq!(models["uc-deck"].app_ids, vec![730, 999]); + } + + #[test] + fn a_group_added_after_this_evaluator_makes_a_collection_unsupported() { + assert_eq!( + resolved()["uc-future"].unsupported, + Some(UnsupportedReason::UnknownFilter) + ); + } + + #[test] + fn filters_we_cannot_reproduce_are_refused_rather_than_guessed() { + let models = resolved(); + assert_eq!( + models["uc-vr"].unsupported, + Some(UnsupportedReason::UnknownFilter) + ); + assert_eq!( + models["uc-search"].unsupported, + Some(UnsupportedReason::SearchText) + ); + for id in ["uc-vr", "uc-search"] { + assert!( + models[id].app_ids.is_empty(), + "{id} should resolve to nothing" + ); + } + } + + #[test] + fn a_friend_filter_keeps_the_games_that_friend_owns() { + let models = resolved(); + assert_eq!(models["uc-friend"].unsupported, None); + assert_eq!(models["uc-friend"].app_ids, vec![730, 777]); + } + + #[test] + fn a_friend_whose_library_could_not_be_read_refuses_the_collection() { + let library = [240, 730]; + let playtimes = PlaytimeMap::new(); + let app_info = HashMap::new(); + let installed = HashSet::new(); + let friends_owned = HashMap::new(); + let facts = LibraryFacts { + playtimes: &playtimes, + app_info: &app_info, + installed: &installed, + have_app_info: true, + have_playtimes: true, + have_installed: true, + friends_owned: &friends_owned, + online: true, + }; + let models: HashMap = resolve(parsed(), &library, &facts) + .into_iter() + .map(|c| (c.id.clone(), c)) + .collect(); + assert_eq!( + models["uc-friend"].unsupported, + Some(UnsupportedReason::Unavailable) + ); + assert!(models["uc-friend"].app_ids.is_empty()); + } +} diff --git a/src/backend/tests.rs b/src/backend/tests.rs index b4e8f66..09b9402 100644 --- a/src/backend/tests.rs +++ b/src/backend/tests.rs @@ -26,7 +26,28 @@ mod tests { static STEAM: Mutex<()> = Mutex::new(()); fn steam_guard() -> MutexGuard<'static, ()> { - STEAM.lock().unwrap_or_else(|e| e.into_inner()) + let guard = STEAM.lock().unwrap_or_else(|e| e.into_inner()); + // A machine can hold several installs and the locator picks by preference, + // not by what is live; only one running install is unambiguous. + #[cfg(target_os = "linux")] + if let [root] = crate::utils::steam_ns::running_steam_install_roots().as_slice() { + let _ = crate::utils::steam_locator::TEST_INSTALL_ROOT.set(root.clone()); + } + // Same refusal the orchestrator makes: connecting to another live Steam + // half-succeeds, and every call after that is refused for no stated reason. + #[cfg(target_os = "linux")] + assert!( + crate::utils::steam_ns::loaded_install_is_running(), + "Steam is not running from {}, the install these tests load. Start \ + that Steam, or set SAM_STEAM_INSTALL_ROOT to the one that is running.", + crate::utils::steam_locator::SteamLocator::get_local_steam_install_root_folders() + .first() + .map_or_else( + || "any known install".to_owned(), + |p| p.display().to_string() + ) + ); + guard } #[test] diff --git a/src/gui_frontend/achievement_manual_view/header.rs b/src/gui_frontend/achievement_manual_view/header.rs index 30eba7f..766f03e 100644 --- a/src/gui_frontend/achievement_manual_view/header.rs +++ b/src/gui_frontend/achievement_manual_view/header.rs @@ -17,6 +17,7 @@ use super::copy_controls::CopyControls; use crate::gui_frontend::gobjects::mode_state::{ GUnlockModeState, MODE_AUTOCOMMIT, MODE_COPY_TIMING, MODE_DEFERRED, }; +use crate::gui_frontend::gobjects::online_state::{GOnlineState, online_state}; use crate::gui_frontend::i18n::tr; use gtk::glib::{self, clone}; use gtk::prelude::*; @@ -170,6 +171,22 @@ pub(super) fn create_header( } )); + let online = online_state(); + let apply_online = clone!( + #[weak] + copy_toggle, + #[weak] + instant_toggle, + move |state: &GOnlineState| { + copy_toggle.set_sensitive(state.online()); + if !state.online() && copy_toggle.is_active() { + instant_toggle.set_active(true); + } + } + ); + apply_online(&online); + online.connect_online_notify(apply_online); + // Each mode reveals only its own controls. let visibility_apply = clone!( #[weak] diff --git a/src/gui_frontend/app_list_view/mod.rs b/src/gui_frontend/app_list_view/mod.rs index 00c0b74..581a971 100644 --- a/src/gui_frontend/app_list_view/mod.rs +++ b/src/gui_frontend/app_list_view/mod.rs @@ -23,21 +23,24 @@ mod sidebar; use crate::backend::app_lister::{AppModel, AppModelType}; use crate::backend::local_stats::LocalIndex; +use crate::backend::steam_collections::{CollectionModel, HIDDEN_ID}; use crate::backend::user_unlock_times::account_id; use crate::gui_frontend::MainApplication; use crate::gui_frontend::app_list_view_callbacks::switch_from_app_list_to_app; use crate::gui_frontend::app_view::create_app_view; use crate::gui_frontend::application_actions::{ - set_selection_actions_enabled, set_timed_unlock_actions_enabled, setup_app_actions, + set_app_action_enabled, set_selection_actions_enabled, set_timed_unlock_actions_enabled, + setup_app_actions, }; use crate::gui_frontend::dialogs::{choose_steam_install_then, show_message_dialog}; +use crate::gui_frontend::gobjects::online_state::{GOnlineState, online_state}; use crate::gui_frontend::gobjects::steam_app::GSteamAppObject; use crate::gui_frontend::gsettings::get_settings; use crate::gui_frontend::i18n::tr; use crate::gui_frontend::profile_view::build_profile_view; use crate::gui_frontend::profile_view::identity::{Identity, SharedIdentity, load_identity}; use crate::gui_frontend::request::{ - AppProgress, GetRunningApps, LaunchApp, Request, SetStealthMode, StopApp, + AppProgress, GetCollections, GetRunningApps, LaunchApp, Request, SetStealthMode, StopApp, }; use crate::gui_frontend::ui_components::{ create_context_menu_button, set_context_popover_to_app_list_context, @@ -66,7 +69,7 @@ use refresh_actions::{ create_rescan_counts_action, }; use settings_bindings::setup_settings_bindings; -use sidebar::{build_sidebar, sort_needs_counts}; +use sidebar::{build_sidebar, drop_counts_dependent_settings, sort_needs_counts}; use std::cell::{Cell, RefCell}; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; @@ -81,6 +84,48 @@ pub(super) struct FilterState { pub hide_never_launched: Cell, pub hide_no_unlocked: Cell, pub hide_without_achievements: Cell, + pub hide_steam_hidden: Cell, +} + +#[derive(Default)] +pub(super) struct Collections { + models: RefCell>, + selected: RefCell>>, + hidden: RefCell>, +} + +impl Collections { + fn app_ids_of(models: &[CollectionModel], id: &str) -> Option> { + models + .iter() + .find(|model| model.id == id && model.id != HIDDEN_ID && model.unsupported.is_none()) + .map(|model| model.app_ids.iter().copied().collect()) + } + + fn store(&self, models: Vec, selected_id: &str) { + *self.hidden.borrow_mut() = models + .iter() + .find(|model| model.id == HIDDEN_ID && model.unsupported.is_none()) + .map(|model| model.app_ids.iter().copied().collect()) + .unwrap_or_default(); + *self.models.borrow_mut() = models; + self.select(selected_id); + } + + fn select(&self, id: &str) { + *self.selected.borrow_mut() = Self::app_ids_of(&self.models.borrow(), id); + } + + fn shows(&self, app_id: u32) -> bool { + self.selected + .borrow() + .as_ref() + .is_none_or(|ids| ids.contains(&app_id)) + } + + fn is_hidden_in_steam(&self, app_id: u32) -> bool { + self.hidden.borrow().contains(&app_id) + } } impl FilterState { @@ -101,6 +146,8 @@ impl FilterState { .set(settings.boolean("filter-hide-no-unlocked")); self.hide_without_achievements .set(settings.boolean("filter-hide-without-achievements")); + self.hide_steam_hidden + .set(settings.boolean("filter-hide-steam-hidden")); } fn depends_on_counts(&self) -> bool { @@ -110,12 +157,6 @@ impl FilterState { } } -const COUNT_FILTER_KEYS: &[&str] = &[ - "filter-hide-fully-unlocked", - "filter-hide-no-unlocked", - "filter-hide-without-achievements", -]; - #[cfg(feature = "adwaita")] const SIDEBAR_COLLAPSE_WIDTH: i32 = 1150; @@ -489,6 +530,7 @@ pub fn create_main_ui( let search_text_lower: Rc> = Rc::new(RefCell::new(String::new())); let counts_ready: Rc> = Rc::new(Cell::new(false)); let counts_wanted_by_profile: Rc> = Rc::new(Cell::new(false)); + let collections: Rc = Rc::new(Collections::default()); let list_custom_filter = gtk::CustomFilter::new(clone!( #[strong] @@ -497,6 +539,8 @@ pub fn create_main_ui( counts_ready, #[strong] search_text_lower, + #[strong] + collections, move |obj| { let app = obj.downcast_ref::().unwrap(); @@ -513,6 +557,13 @@ pub fn create_main_ui( if filter_state.hide_never_launched.get() && app.last_played() == 0 { return false; } + let app_id = app.app_id(); + if filter_state.hide_steam_hidden.get() && collections.is_hidden_in_steam(app_id) { + return false; + } + if !collections.shows(app_id) { + return false; + } if counts_ready.get() { let total = app.achievement_count(); @@ -621,9 +672,12 @@ pub fn create_main_ui( // Only landed counts can change what a completion filter decides, and // the search box mutates the store twice per keystroke. move |counts_moved: bool| { - let needs_counts = filter_state.depends_on_counts() - || sort_needs_counts(sort_mode_cache.borrow().as_str()) - || counts_wanted_by_profile.get(); + // Offline an uncovered app settles as a fabricated 0/0, and the + // completion filters would decide on that. + let needs_counts = online_state().online() + && (filter_state.depends_on_counts() + || sort_needs_counts(sort_mode_cache.borrow().as_str()) + || counts_wanted_by_profile.get()); let (loaded, total) = achievement_loader.counts_progress(&list_store); let ready = total > 0 && loaded == total; if ready { @@ -707,14 +761,93 @@ pub fn create_main_ui( )); } )); + let collections_generation = Rc::new(Cell::new(0u64)); + let refresh_collections: Rc = Rc::new(clone!( + #[weak] + list_store, + #[weak] + list_custom_filter, + #[strong] + collections, + #[strong] + collections_generation, + #[strong] + sidebar, + #[strong] + settings, + #[strong] + on_filters_changed, + move || { + let library: Vec = (0..list_store.n_items()) + .filter_map(|i| list_store.item(i).and_downcast::()) + .filter(|app| !app.is_synthetic()) + .map(|app| app.app_id()) + .collect(); + if library.is_empty() { + return; + } + let generation = collections_generation.get() + 1; + collections_generation.set(generation); + let handle = spawn_blocking(move || (GetCollections { library }).request()); + MainContext::default().spawn_local(clone!( + #[weak] + list_custom_filter, + #[strong] + collections, + #[strong] + collections_generation, + #[strong] + sidebar, + #[strong] + settings, + #[strong] + on_filters_changed, + async move { + let models = match handle.await { + Ok(Ok(models)) => models, + Ok(Err(e)) => { + crate::dev_println!("CLIENT", "No collections: {e}"); + Vec::new() + } + Err(e) => { + eprintln!("[CLIENT] Spawn blocking error: {e:?}"); + Vec::new() + } + }; + if collections_generation.get() != generation { + return; + } + let selected = settings.string("filter-collection"); + sidebar.set_collections(&models, selected.as_str()); + collections.store(models, selected.as_str()); + list_custom_filter.changed(gtk::FilterChange::Different); + on_filters_changed(); + } + )); + } + )); + sidebar.connect_collection_selected(clone!( + #[strong] + settings, + move |id| { + if settings.string("filter-collection") != id + && let Err(e) = settings.set_string("filter-collection", &id) + { + eprintln!("[CLIENT] Error saving filter-collection setting: {e:?}"); + } + } + )); let on_library_loaded: Rc = Rc::new(clone!( #[strong] counts_prefilled, #[strong] prefill_counts, + #[strong] + refresh_collections, move || { counts_prefilled.set(false); prefill_counts(); + refresh_collections(); } )); let on_open_app: Rc = Rc::new(clone!( @@ -818,6 +951,28 @@ pub fn create_main_ui( &sync_counts_state, ); application.add_action(&action_rescan_counts); + + // After the action exists, or the immediate call finds nothing to disable. + let apply_online_counts = clone!( + #[strong] + sync_counts_state, + #[weak] + application, + #[weak] + list_stack, + #[weak] + menu_model, + move |state: &GOnlineState| { + set_app_action_enabled(&application, "rescan_achievement_counts", state.online()); + if !state.online() && list_stack.visible_child_name().as_deref() == Some("profile") { + set_context_popover_to_app_list_context(&menu_model, &application); + list_stack.set_visible_child_name("list"); + } + sync_counts_state(false); + } + ); + apply_online_counts(&online_state()); + online_state().connect_online_notify(apply_online_counts); let on_rescan_all: Rc = Rc::new(clone!( #[strong] action_rescan_counts, @@ -882,18 +1037,7 @@ pub fn create_main_ui( achievement_loader.cancel_backlog(); counts_wanted_by_profile.set(false); - if sort_needs_counts(settings.string("app-sort").as_str()) - && let Err(e) = settings.set_string("app-sort", "alphabetical") - { - eprintln!("[CLIENT] Error saving app-sort setting: {e:?}"); - } - for key in COUNT_FILTER_KEYS { - if settings.boolean(key) - && let Err(e) = settings.set_boolean(key, false) - { - eprintln!("[CLIENT] Error saving {key} setting: {e:?}"); - } - } + drop_counts_dependent_settings(&settings); } )); @@ -914,6 +1058,7 @@ pub fn create_main_ui( &list_custom_sorter, filter_state.clone(), sort_mode_cache.clone(), + collections.clone(), on_filters_changed.clone(), on_apps_retired, ); @@ -1724,6 +1869,8 @@ pub fn create_main_ui( }); } app_stack.set_visible_child_name("loading"); + // Already visible, so no notify, so the action below stays + // enabled -- the first load and the first probe both ride on it. list_stack.set_visible_child_name("loading"); action_refresh_app_list.activate(None); action_refresh_app_list.set_enabled(false); diff --git a/src/gui_frontend/app_list_view/refresh_actions.rs b/src/gui_frontend/app_list_view/refresh_actions.rs index 37dfb9d..cbf87b1 100644 --- a/src/gui_frontend/app_list_view/refresh_actions.rs +++ b/src/gui_frontend/app_list_view/refresh_actions.rs @@ -20,6 +20,7 @@ use crate::gui_frontend::application_actions::{ set_app_action_enabled, set_selection_actions_enabled, set_timed_unlock_actions_enabled, }; use crate::gui_frontend::gobjects::achievement::GAchievementObject; +use crate::gui_frontend::gobjects::online_state::probe; use crate::gui_frontend::gobjects::stat::GStatObject; use crate::gui_frontend::gobjects::steam_app::GSteamAppObject; use crate::gui_frontend::i18n::tr; @@ -88,6 +89,9 @@ pub fn create_refresh_app_list_action( } .request() }); + // Not on the success branch: an empty or failed library is the + // likeliest offline outcome. + probe(); MainContext::default().spawn_local(clone!( #[weak] grid_view, diff --git a/src/gui_frontend/app_list_view/settings_bindings.rs b/src/gui_frontend/app_list_view/settings_bindings.rs index 0071771..5fead9b 100644 --- a/src/gui_frontend/app_list_view/settings_bindings.rs +++ b/src/gui_frontend/app_list_view/settings_bindings.rs @@ -14,8 +14,9 @@ // along with this program. If not, see . use crate::gui_frontend::MainApplication; -use crate::gui_frontend::app_list_view::FilterState; +use crate::gui_frontend::app_list_view::{Collections, FilterState}; use crate::gui_frontend::dialogs::show_message_dialog; +use crate::gui_frontend::gobjects::online_state::{GOnlineState, online_state}; use crate::gui_frontend::gsettings::get_settings; use crate::gui_frontend::i18n::{STEAM_LANGUAGES, tr, tr_noop}; use crate::gui_frontend::request::{Request, SetStealthMode}; @@ -38,8 +39,10 @@ const FILTER_KEYS: &[&str] = &[ "filter-hide-never-launched", "filter-hide-no-unlocked", "filter-hide-without-achievements", + "filter-hide-steam-hidden", ]; +#[allow(clippy::too_many_arguments)] pub fn setup_settings_bindings( application: &MainApplication, settings: &Settings, @@ -47,6 +50,7 @@ pub fn setup_settings_bindings( list_custom_sorter: &CustomSorter, filter_state: Rc, sort_mode_cache: Rc>, + collections: Rc, on_filters_changed: Rc, on_apps_retired: Rc, ) { @@ -72,6 +76,23 @@ pub fn setup_settings_bindings( ); } + settings.connect_changed( + Some("filter-collection"), + clone!( + #[weak] + list_custom_filter, + #[strong] + collections, + #[strong] + on_filters_changed, + move |s, _| { + collections.select(s.string("filter-collection").as_str()); + list_custom_filter.changed(gtk::FilterChange::Different); + on_filters_changed(); + } + ), + ); + // Sort radio: two-way bound to gsettings; re-sorts on any change. application.add_action(&settings.create_action("app-sort")); settings.connect_changed( @@ -225,7 +246,49 @@ pub fn setup_settings_bindings( ); // Pushing can fail before the orchestrator exists; the spawn path resends. - application.add_action(&settings.create_action("appear-in-game")); + let appear_in_game = SimpleAction::new_stateful( + "appear-in-game", + None, + &settings.boolean("appear-in-game").to_variant(), + ); + appear_in_game.connect_activate(clone!( + #[strong] + settings, + move |action, _| { + let value = !action.state().and_then(|s| s.get::()).unwrap_or(true); + if let Err(e) = settings.set_boolean("appear-in-game", value) { + eprintln!("[CLIENT] Error saving appear-in-game setting: {e:?}"); + } + } + )); + application.add_action(&appear_in_game); + settings.connect_changed( + Some("appear-in-game"), + clone!( + #[strong] + appear_in_game, + move |s, _| appear_in_game.set_state(&s.boolean("appear-in-game").to_variant()) + ), + ); + + // Stealth writes need a stats load Steam refuses offline. + let apply_online = clone!( + #[strong] + settings, + #[strong] + appear_in_game, + move |state: &GOnlineState| { + let online = state.online(); + if !online + && !settings.boolean("appear-in-game") + && let Err(e) = settings.set_boolean("appear-in-game", true) + { + eprintln!("[CLIENT] Error forcing appear-in-game on: {e:?}"); + } + appear_in_game.set_enabled(online && settings.is_writable("appear-in-game")); + } + ); + let wanted_stealth = Arc::new(AtomicBool::new(!settings.boolean("appear-in-game"))); settings.connect_changed( Some("appear-in-game"), @@ -271,6 +334,11 @@ pub fn setup_settings_bindings( ), ); + // After the listener above: forcing the key on is what pushes stealth off. + let online = online_state(); + apply_online(&online); + online.connect_online_notify(apply_online); + // Disable animations: cached in a global AtomicBool that SteamAppCard reads. ANIMATIONS_DISABLED.store(settings.boolean("disable-animations"), Ordering::Relaxed); application.add_action(&settings.create_action("disable-animations")); diff --git a/src/gui_frontend/app_list_view/sidebar.rs b/src/gui_frontend/app_list_view/sidebar.rs index 1feda97..ba25614 100644 --- a/src/gui_frontend/app_list_view/sidebar.rs +++ b/src/gui_frontend/app_list_view/sidebar.rs @@ -17,17 +17,25 @@ //! widgets are views onto GSettings keys; `settings_bindings` re-runs the //! filter and sorter when they change. +use crate::backend::steam_collections::{ + CollectionModel, FAVORITE_ID, HIDDEN_ID, UnsupportedReason, +}; +use crate::gui_frontend::gobjects::online_state::{GOnlineState, online_state}; use crate::gui_frontend::i18n::{tr, tr_noop}; use crate::gui_frontend::profile_view::identity::Identity; use crate::gui_frontend::widgets::shimmer_image::ShimmerImage; use gtk::gio::Settings; use gtk::glib; use gtk::glib::clone; +use gtk::pango::EllipsizeMode; use gtk::prelude::*; use gtk::{ - Align, Box, Button, CheckButton, Label, Orientation, PolicyType, ProgressBar, ScrolledWindow, - Separator, Spinner, + Align, Box, Button, CheckButton, DropDown, Label, ListItem, Orientation, PolicyType, + ProgressBar, ScrolledWindow, Separator, SignalListItemFactory, Spinner, StringList, + StringObject, }; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; pub(super) const SIDEBAR_WIDTH: i32 = 232; const AVATAR_SIZE: i32 = 48; @@ -35,6 +43,7 @@ const AVATAR_SIZE: i32 = 48; struct FilterSpec { key: &'static str, label: &'static str, + needs_counts: bool, /// The checkbox shows the negation of the key. Only `filter-junk` uses it: /// junk is hidden by default, so a `Hide junk` box would sit permanently /// ticked for everyone. @@ -44,31 +53,43 @@ struct FilterSpec { const FILTERS: &[FilterSpec] = &[ FilterSpec { key: "filter-hide-without-achievements", + needs_counts: true, label: tr_noop("Hide with no achievements"), invert: false, }, FilterSpec { key: "filter-hide-fully-unlocked", + needs_counts: true, label: tr_noop("Hide at 100%"), invert: false, }, FilterSpec { key: "filter-hide-no-unlocked", + needs_counts: true, label: tr_noop("Hide at 0%"), invert: false, }, FilterSpec { key: "filter-hide-never-launched", + needs_counts: false, label: tr_noop("Hide never launched"), invert: false, }, FilterSpec { key: "filter-only-idling", + needs_counts: false, label: tr_noop("Only currently idling"), invert: false, }, + FilterSpec { + key: "filter-hide-steam-hidden", + needs_counts: false, + label: tr_noop("Hide hidden in Steam"), + invert: false, + }, FilterSpec { key: "filter-junk", + needs_counts: false, label: tr_noop("Show junk"), invert: true, }, @@ -113,6 +134,21 @@ const SORT_MODES: &[SortSpec] = &[ }, ]; +pub(super) fn drop_counts_dependent_settings(settings: &Settings) { + if sort_needs_counts(settings.string("app-sort").as_str()) + && let Err(e) = settings.set_string("app-sort", "alphabetical") + { + eprintln!("[CLIENT] Error saving app-sort setting: {e:?}"); + } + for spec in FILTERS.iter().filter(|spec| spec.needs_counts) { + if settings.boolean(spec.key) + && let Err(e) = settings.set_boolean(spec.key, false) + { + eprintln!("[CLIENT] Error saving {} setting: {e:?}", spec.key); + } + } +} + pub(super) fn sort_needs_counts(value: &str) -> bool { SORT_MODES .iter() @@ -127,6 +163,42 @@ pub(super) struct Sidebar { loading_button: Button, loading_spinner: Spinner, loading_progress: ProgressBar, + collection_dropdown: DropDown, + collection_names: StringList, + collection_entries: Rc>>, + collection_rebuilding: Rc>, +} + +struct CollectionEntry { + id: String, + unsupported: Option, +} + +fn unsupported_tooltip(reason: UnsupportedReason) -> String { + match reason { + UnsupportedReason::SearchText => { + tr("Filters on a search term, which SamRewritten cannot reproduce exactly.") + } + UnsupportedReason::UnknownFilter => { + tr("Uses a Steam filter SamRewritten cannot reproduce exactly.") + } + UnsupportedReason::Unavailable => { + tr("Needs information from Steam that could not be read. Refresh to try again.") + } + UnsupportedReason::Offline => { + tr("Needs your friends' games, which Steam can only tell us when it is online.") + } + } + .to_string() +} + +fn collection_label(model: &CollectionModel) -> String { + match model.id.as_str() { + // Steam freezes a system collection's name in whichever language made it. + FAVORITE_ID => tr("Favorites").to_string(), + _ if model.name.is_empty() => model.id.clone(), + _ => model.name.clone(), + } } fn section_label(text: &str) -> Label { @@ -224,6 +296,16 @@ pub(super) fn build_sidebar(settings: &Settings) -> Sidebar { content.append(&profile_button); content.append(&Separator::new(Orientation::Horizontal)); + let offline_notice = Label::builder() + .label(tr("Steam is offline. What needs its servers is turned off.").as_str()) + .xalign(0.0) + .wrap(true) + .visible(false) + .margin_top(6) + .css_classes(["caption", "warning"]) + .build(); + content.append(&offline_notice); + let loading_spinner = Spinner::builder().valign(Align::Center).build(); let loading_title = Label::builder() .label(tr("Fetching completion…").as_str()) @@ -264,12 +346,98 @@ pub(super) fn build_sidebar(settings: &Settings) -> Sidebar { content.append(&loading_button); content.append(§ion_label(tr("Filters").as_str())); + let mut counted: Vec = Vec::new(); for spec in FILTERS { let check = CheckButton::with_label(tr(spec.label).as_str()); wire_check(settings, spec, &check); + if spec.needs_counts { + counted.push(check.clone()); + } content.append(&check); } + content.append(§ion_label(tr("Steam collection").as_str())); + let collection_names = StringList::new(&[tr("All games").as_str()]); + let collection_entries: Rc>> = + Rc::new(RefCell::new(vec![CollectionEntry { + id: String::new(), + unsupported: None, + }])); + let collection_rebuilding: Rc> = Rc::new(Cell::new(false)); + let list_factory = SignalListItemFactory::new(); + list_factory.connect_setup(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let label = Label::builder() + .xalign(0.0) + .ellipsize(EllipsizeMode::End) + .build(); + item.set_child(Some(&label)); + }); + list_factory.connect_bind(clone!( + #[strong] + collection_entries, + move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(label) = item.child().and_downcast::