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
- GTK version preview
+ Adwaita version preview
- 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
-
+```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 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 "Chargement..."
@@ -117,7 +117,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 "Annuler"
@@ -142,9 +142,9 @@ msgstr "Steam est déjà installé ?"
#: 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 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に場"
"所を指定できます。手順や問題の報告については 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 "Carregando..."
@@ -117,7 +117,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 "Cancelar"
@@ -143,9 +143,9 @@ msgstr "A Steam está instalada?"
#: 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 ""
"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 установлен в нестандартном месте, укажите путь через переменные "
"окружения. Инструкции и форма для сообщения о проблеме — на \n"
"Language-Team: LANGUAGE \n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\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 ""
@@ -302,32 +302,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 ""
@@ -421,111 +421,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 ""
@@ -949,11 +988,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 ""
@@ -1128,29 +1167,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/tr.po b/po/tr.po
index b920ba6..4ffdaae 100644
--- a/po/tr.po
+++ b/po/tr.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 11:12+0000\n"
"Last-Translator: Deniz Çolak <28dnzclk@gmail.com>\n"
"Language-Team: Turkish Steam zaten yüklü mü?"
#: 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'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 за допомогою змінних середовища. Перегляньте інструкції на \n"
"Language-Team: Chinese (Traditional Han script) 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 ""
#: 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