Skip to content

Add Steam Wrapped - #220

Open
wopln wants to merge 1 commit into
SteamClientHomebrew:mainfrom
wopln:add-steam-wrapped-clean
Open

Add Steam Wrapped#220
wopln wants to merge 1 commit into
SteamClientHomebrew:mainfrom
wopln:add-steam-wrapped-clean

Conversation

@wopln

@wopln wopln commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Steam Wrapped to the Millennium Plugin Database as a submodule.

Plugin repository:
https://github.com/wopln/Steam-Wrapped

Features

  • A Steam Wrapped entry in the Store navigation bar.
  • A dashboard with selectable periods such as this month, last month, the last three months, this year, and custom ranges.
  • Locally tracked game sessions with live playtime for the currently running game.
  • Period-based totals for playtime, games played, achievements, and favorite genre.
  • Gaming insights including most played game, longest session, peak play time, and a 24-hour activity histogram.
  • Recent achievements and recently played games, with links back to their native Steam Library pages.
  • A high-resolution Share Summary PNG export of the dashboard.

@github-actions github-actions Bot changed the title Add Steam Wrapped plugin Add Steam Wrapped Aug 6, 2026
@wopln
wopln force-pushed the add-steam-wrapped-clean branch from 832efed to 15b5fc1 Compare August 18, 2026 12:22
@Norphirion

Copy link
Copy Markdown

Disclosure: I am the author of #218 & #219 and tested this PR as part of the Community Contribution requirement.

Reviewed and built on Windows 11, Steam Client Beta, Millennium v3.5.0-beta.2, Node 24.18.0, pnpm 10.34.5, using the exact pinned commit 0885bc4983b5298b60a5bf66d27458145ed2c756 (v1.1.0). I did not install the plugin; this is a source audit, a build reproduction, and a set of live checks run inside my own Steam Store page and client JavaScript contexts. What I ran and what I did not is listed at the end.

This is a substantial plugin and the structure is genuinely good: small focused modules, a clear split between the client-side tracker and the WebKit page, and the nav button clones Steam's own Browse button rather than restyling one from scratch, which is exactly the practice asked for elsewhere in this repo. Three things need attention before publication, and the first one I would not have found without opening the Store.

1. The Store entry point never appears for non-English users

webkit/navigation/store-navigation.ts finds its anchor by exact English text:

const browseElement = candidates.find(
  (element) => element.textContent?.replace(/\s+/g, " ").trim() === "Browse",
);

If nothing matches, findBrowseTab() returns undefined, ensure() returns early, and no button is inserted. That button is the plugin's only entry point.

Steam localises that label. I fetched the Store front page in three languages and checked for the literal string:

Language Nav label present Literal Browse present
English Browse yes
French Parcourir no
German Durchsuchen no
Spanish Explorar no

I then confirmed it on a live client rather than leaving it as inference. With the Steam client switched to French and the Store page open (document.documentElement.lang === "fr"), I replayed findBrowseTab() verbatim in that page's context:

findBrowseTab()      -> undefined
button would insert  -> false
nav labels present   -> Parcourir, Recommandations, Catégories,
                        Matériel, Manières de jouer, Sections spéciales

So on a non-English client the match cannot succeed and the plugin silently does nothing at all. No error, no fallback, no entry point, and nothing in the console to tell the user why.

The good news is that the fix is small, because the element itself is language independent. The French Parcourir button carries exactly the same classes as the English Browse button:

english: _175B12uOwmeGBNcSaQFe-Z _3wlHWKFbgRAZPFtWEr65YT Focusable
french:  _175B12uOwmeGBNcSaQFe-Z _3wlHWKFbgRAZPFtWEr65YT Focusable

and in both languages it is the first child of its container. Anchoring on the first button of that nav row, or resolving the shared class through findClassModule, would work in every language and be no more fragile than the current text match.

Two related observations from probing the live Store page, which make this worse rather than incidental:

#store_nav_area .tab   -> 0 elements
.store_nav .tab        -> 0 elements
#store_nav_area a      -> 0 elements
.store_nav a           -> 0 elements
button                 -> 14 elements   <- the only selector that matches

The four store_nav selectors are dead against the current React Store, so the generic button selector plus the exact "Browse" string is the sole working path, not a last-resort fallback. And the six nav buttons all share the same hashed classes (_175B12uOwmeGBNcSaQFe-Z _3wlHWKFbgRAZPFtWEr65YT Focusable) inside a single container, with Browse first, so anchoring on position within that container, or on the first button of the nav row, would be language independent and no more fragile than what is there now.

2. No licence

There is no LICENSE file, and package.json carries "private": true with no license field. The submission checklist has a licensing item and an open-source item, and right now neither has an answer in the repository. Everything else needed to answer them is present, so this looks like an oversight rather than a decision.

3. Data collection, retention, and disclosure

This is the part I would most want addressed, because the plugin's whole purpose is building a personal history and it does so quietly.

Tracking starts at load, not at first use. definePlugin calls tracker.start() and achievementProvider.start() immediately, which registers RegisterForAppLifetimeNotifications and a 5 second reconcile interval. From the moment the plugin is enabled it records every game session, whether or not the user ever opens the dashboard.

Nothing bounds the history. SessionStore.update() pushes to an array and writes the whole thing back:

window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, sessions: this.sessions }));

There is no cap, no window, no pruning of old sessions, and the full array is re-serialised on every session start and end. The achievement history has the same shape. Over years this only grows, against a localStorage quota shared with Steam itself.

There is no way to delete it. I grepped for a reset path; the only .clear() calls are on in-memory caches. Nothing removes play-session-history, steam-wrapped-achievement-history or steam-wrapped-game-metadata. Uninstalling the plugin leaves them behind, since localStorage outlives the plugin. A "clear my history" control, and removing the keys on dismount if the user asks for it, would cover this.

The README does not say any of it. It says "Locally tracked game sessions" and nothing more: not what is recorded, not where, not for how long, not how to remove it. For a plugin of this kind that paragraph is worth writing.

One unnecessary credentialed request. frontend/index.tsx runs in the client context, whose origin is https://steamloopback.host, and calls:

await fetch(`https://store.steampowered.com/api/appdetails?appids=...`, { credentials: "include" });

That is cross-origin, and credentials: "include" deliberately attaches the user's Steam Store cookies to it. appdetails is a public endpoint and does not need them. I ran the same request from that same origin with credentials: "omit" and got 200 with the genre field populated, so dropping to omit costs nothing and stops sending a session cookie where none is required.

To be fair on the overall picture, because this is the question a reviewer should answer plainly: only the app id ever leaves the machine, to Valve's own public API, and I found no telemetry, no analytics, and no third-party endpoint anywhere in the frontend or the WebKit code. Everything else is local.

A shared-namespace key without a prefix. Two of the three keys are namespaced (steam-wrapped-achievement-history, steam-wrapped-game-metadata) but the largest one is play-session-history, unprefixed. That localStorage origin is shared: on my client it holds 11 keys belonging to Steam itself (PopupSavedDimensions_*, AppGridDisplaySettings, a cloud-storage namespace) and to other Millennium plugins. A generic name in a shared namespace is a collision waiting to happen, and renaming it now, with a one-time migration, is much cheaper than after release.

4. Build and packaging

No pnpm lockfile. Only bun.lock is tracked, and PluginDatabase builds with pnpm, so CI resolves dependencies fresh on every run. It does build today: I ran the CI sequence (pnpm install then pnpm run build with NODE_ENV=production) and it succeeded on Node 24 with pnpm 10.34.5. But that is luck rather than design. tsconfig.json sets moduleResolution: "node", which TypeScript 7 removed, and the only thing keeping TypeScript 7 out of the tree is that typescript is declared as ^5.8.3 and therefore capped below 6. @steambrew/ttc is ^3.2.6, so the toolchain itself is unpinned. Committing pnpm-lock.yaml and switching to moduleResolution: "bundler" would make this deliberate. This exact combination has broken the build on two other submissions in this repo.

.millennium/ is committed. .gitignore contains only node_modules/. To be fair I checked rather than assumed: the committed bundles are byte-for-byte identical to a fresh build from source, so nothing stale is being shipped today. The argument is still that CI rebuilds them anyway, so the tracked copies can only ever drift.

Source maps are shipped to users. The production build emits index.js.map (52 KB) and webkit.js.map (920 KB) alongside 354 KB of code, and prepare-dist.sh copies the whole .millennium directory into the package. That is roughly 970 KB of source maps in every install. Disabling map output for prod builds would cut the download by about three quarters.

plugin.json contradicts itself. It declares "useBackend": false and "backendType": "lua" at the same time, and there is no backend directory in the repository. One of the two should go; the backendType line looks like a leftover.

$schema 404s. It points at src/sys/plugin-schema.json. The file moved: https://raw.githubusercontent.com/SteamClientHomebrew/Millennium/main/src/system/plugin-schema.json returns 200. Most plugins here carry the stale path, so this is worth fixing rather than dropping.

5. Smaller points

  • definePlugin adds a beforeunload listener that onDismount never removes, so a disable and re-enable cycle leaves the previous one attached. The rest of the client-side teardown is good: onDismount unregisters both subscriptions and clears the reconcile interval, and the module even stops a previous instance on reload.
  • html2canvas is a runtime dependency on a caret range. It is the reason the WebKit bundle is 338 KB, which is fine for what it does, but pinning it exactly would match the care taken elsewhere.
  • The repository carries about 1.6 MB of screenshots under docs/. Not shipped to users, just repository weight.
  • allowTaint: false with useCORS: true on the canvas export is the correct careful choice, and the PNG path degrades through several fallbacks before giving up. Worth saying, since it would have been easy to get wrong.

What I ran, and what I did not

Verified: full source read, greps for network, storage and dynamic execution, the CI install and build sequence, the localisation of the Store nav label in four languages, findBrowseTab() replayed verbatim on a live French client, the live Store DOM against all five of the plugin's selectors in both English and French, the appdetails call without credentials from the plugin's actual origin, and the committed bundles against a fresh build.

Not done: I did not install the plugin, so I have not exercised the dashboard, the period selector, the histogram or the PNG export at runtime. Everything above was measured rather than inferred; where an early assumption of mine turned out to be wrong, and two of them were, I have reported the measured result instead.

@wopln
wopln force-pushed the add-steam-wrapped-clean branch from 15b5fc1 to b081939 Compare August 21, 2026 06:53
@wopln

wopln commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@Norphirion Thank you for the detailed review and testing. I've addressed the reported issues in v1.1.1:

  • Fixed the Store entry point for non-English Steam clients.
  • Added an MIT license.
  • Documented data collection, retention, and privacy.
  • Added bounded, namespaced storage with migration preserving existing history.
  • Added a confirmed Clear local history control.
  • Removed unnecessary credentialed requests.
  • Added a pnpm lockfile and pinned dependencies.
  • Removed generated bundles and source maps from Git tracking.
  • Fixed the manifest, schema, TypeScript resolution, and unload cleanup.

The Plugin Database CI build passes, and the PR now points to the v1.1.1 release commit. Thanks again for helping improve the plugin.

@Norphirion

Copy link
Copy Markdown

Re-checked on a8f5f13 (v1.1.1), on Windows 11, Steam Client Beta build 1787097529 (2026-08-18), Millennium v3.5.0-beta.2, Node 24.18.0 and pnpm 10.34.5. I verified each item rather than taking the list at face value.

The Store entry point now works on a non-English client. This was the one that needed a real client rather than a code read, so I ran your new findFirstModernNavButton() verbatim inside a French Steam Store page (document.documentElement.lang === "fr"):

candidate rows      -> 1
  6 buttons, class _175B12uOwmeGBNcSaQFe-Z _3wlHWKFbgRAZPFtWEr65...
  labels: Parcourir, Recommandations, Catégories,
          Matériel, Manières de jouer, Sections spéciales
selected button     -> "Parcourir"
entry point created -> true

Worth noting how clean that is: exactly one row survives the filter, so there is no ambiguity to resolve. The carousel dots fail the identical-className test because their classes carry a per-index suffix, and the back/next pair is below the three-button floor. The heuristic is doing real work rather than getting lucky.

I then took my own advice and checked a second layout, a Store game page (/app/4026250/), still in French. The anchor logic is correct there too, but the check surfaced one thing worth fixing.

Loading that page fresh, the rule picks the right row:

candidate rows -> 2
  6 buttons, first = "Parcourir"            <- selected
  3 buttons, first = "Liste d'anniversaire"

The wishlist / follow / ignore row is a genuine competitor on game pages, and the sort correctly prefers the six-button nav row. Good.

But the detection is not idempotent: your own button disqualifies the row it was inserted into. Re-running the same scan on that page with the button already present:

candidate rows -> 1
  3 buttons, first = "Liste d'anniversaire"  <- would be selected

The nav row drops out because buttons.every(b => b.className === buttons[0].className) fails once steam-wrapped-store-nav-button is appended to the shared native class list. The only surviving row is the wishlist row, so a second insertion would land in the middle of the game page rather than in the nav bar.

Today this is masked, because ensure() early-returns on document.getElementById(NAV_BUTTON_ID) before ever reaching the scan, and I confirmed on the live page that the button is correctly placed, first in the nav row ahead of Parcourir, wearing the native classes plus your marker. So this is latent fragility rather than a bug I can trigger, and I want to be clear about that distinction.

It is still worth closing, because it costs one line and it removes a dependency on the early return staying correct forever:

.filter((button) => button.id !== NAV_BUTTON_ID)

or comparing against the native class list rather than the full one.

Storage. Key is now steam-wrapped-play-session-history, the legacy key is read on load, merged with dedup by session id, written back as version: 2 and then removed, so existing history survives the rename. Bounds are 5,000 sessions, 5,000 achievements and 500 metadata entries.

Clear control. Present, gated behind an explicit window.confirm, and it clears all four keys including the legacy one. Good that it says "This cannot be undone".

Credentials. credentials: "omit". Still one fetch to one host, Valve's public app-details endpoint, and I re-grepped for any new network or dynamic-execution path: nothing new.

Unload. beforeunload is now removed in onDismount, along with the window-level handler reference.

Packaging. .millennium/ and dist/ ignored, no bundle or source map tracked, MIT LICENSE present and "license": "MIT" in package.json, $schema corrected to src/system/... (I confirmed that URL returns 200), the contradictory backendType: "lua" removed, moduleResolution: "bundler", every dependency pinned exactly, pnpm-lock.yaml committed, and a packageManager field on top. pnpm install --frozen-lockfile, pnpm run typecheck and pnpm run build all pass.

Two things you did not claim but did anyway, worth recording. Source maps are no longer emitted at all, not merely untracked, so the packaged plugin drops from about 1,327 KB to 360 KB, a 73% reduction for users. And the privacy section in the README is more specific than what I asked for: it names the exact fields stored, the bounds, the migration, and warns that removing the plugin does not clear Steam WebUI local storage. That last sentence is the one most projects leave out.

One cosmetic note: @steambrew/ttc is pinned at 3.2.6 and the build prints an advisory suggesting 3.3.7. The build completes fine, so this is only a heads-up, and pinning was the right call regardless.

Nothing further from me. Thanks for taking the review seriously, particularly the data-retention part, which you could reasonably have argued was out of scope.

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants