Skip to content

Add Non-Steam Store Link - #223

Open
eiztee wants to merge 1 commit into
SteamClientHomebrew:mainfrom
eiztee:main
Open

Add Non-Steam Store Link#223
eiztee wants to merge 1 commit into
SteamClientHomebrew:mainfrom
eiztee:main

Conversation

@eiztee

@eiztee eiztee commented Aug 13, 2026

Copy link
Copy Markdown
image

A Millennium plugin that adds a "Find on Steam Store" chip next to Last Played/Playtime for games you've added to Steam manually (non-Steam shortcuts). Clicking it opens a Steam Store search for that game's name in your default browser.

Built on the same "game stat chip" anchor point and popup/route-watching pattern as the [Size on Disk] plugin by k0d13.

@github-actions github-actions Bot changed the title Add millenium-non-steam-store-link-button plugin Add Non-Steam Store Link Aug 13, 2026
@Norphirion

Copy link
Copy Markdown

Disclosure: I am the author of #218 & #219 and tested this PR as part of the Community Contribution requirement. My own date-added plugin injects into the same GameStatsSection anchor as this one, so treat the compatibility note at the end with that in mind.

Reviewed on Windows 11, Steam Client Beta, Millennium v3.5.0-beta.2, using the exact pinned plugin commit e4501b53a6a69f44dc49856aa179237f36c3da97. I could not build or install it, for the reason in the first point. To check the runtime behaviour anyway, I replayed the plugin's own lookup logic verbatim inside Steam's JavaScript context and measured what it returns; those results are in point 4.

Thanks for putting this together, the idea is genuinely useful: non-Steam shortcuts are a dead end in the library UI today. Two structural problems need sorting before this can ship, and one behavioural one that I think matters more than it looks.

1. Blocker: the repository contains no source code

The pinned commit has four tracked files:

.millennium/Dist/index.js   19179 bytes   (prebuilt bundle)
README.md
plugin.json
metadata.json

There is no package.json, no source directory, no tsconfig.json, and no LICENSE.

Two consequences.

The store CI cannot build it. The workflow runs pnpm install then pnpm run build before prepare-dist.sh. Reproduced on the pinned commit:

ERR_PNPM_NO_PKG_MANIFEST  No package.json found in <repo>
exit code: 1

That fails the job. Note that prepare-dist.sh only copies .millennium, it never produces it, so the committed bundle does not rescue the build.

The submission checklist asks for the plugin to be fully open source. Shipping only the compiled artifact means reviewers and users can audit the output but not the input. I read the whole bundle, and to be fair it is not minified and not obfuscated, so it is auditable in practice. But a plugin that cannot be rebuilt from its own repository cannot be verified to match its source, and cannot be patched by anyone else.

Publishing the TypeScript or JavaScript sources plus a package.json with the standard millennium-ttc build scripts would fix both at once.

Related, and worth removing while you are in there:

  • .millennium/ should be gitignored rather than committed. The store builds it, so a committed copy can only ever go stale. (I got this exact correction on my own PR, which is how I know it is the convention here.)
  • metadata.json should not be committed either. prepare-dist.sh writes it during packaging, overwriting whatever is in the repository, so the committed {"commit": "", "id": ""} is dead weight.
  • No LICENSE file, and no license field anywhere since there is no package.json. The checklist has a licensing item, so this needs a real answer.
  • $schema in plugin.json points at shdwmtr/millennium, which is the pre-rename org path and 404s. The current template URL 404s too, so honestly the line is worth dropping entirely.

2. The PR currently has a merge conflict

GitHub reports the branch as not mergeable, most likely .gitmodules drifting against main. A rebase should clear it.

3. Nothing is unregistered when the plugin unloads

In the bundle's lifecycle wiring:

const { Unregister: unregisterCreated } = Steam.PopupManager.AddPopupCreatedCallback(onPopup);
const { Unregister: unregisterDestroyed } = Steam.PopupManager.AddPopupDestroyedCallback(...);

Both Unregister functions are destructured and then never used, and the plugin's default export returns nothing, so there is no onDismount either. After disabling or reloading the plugin, the popup callbacks stay registered.

The location watcher is handled correctly by comparison: watchLocation returns a cleanup, it is collected into the popup's cleanup set, and it runs on popup destroy. It is only the plugin-level teardown that is missing.

One smaller leak in the same area: when the chip is re-injected on a route change, the previous wrapper is removed from the DOM but its React root is never unmounted, so a root is orphaned per navigation.

Plugin supports onDismount. Returning one that calls both Unregister functions, unmounts the roots and removes the injected nodes would make a disable actually take effect. I had the same gap in my own plugin and it was fair when a reviewer raised it.

4. The chip does not open a search, and it often opens the wrong game

The README and the PR description both say it "opens a Steam Store search for that game's name". The code does something else: it fetches the search page, scrapes the first result row, and navigates straight to that app.

const response = await fetch(searchUrl);
const doc = parser.parseFromString(html, "text/html");
const firstResult = doc.querySelector("a.search_result_row, .search_result_row");
const href = firstResult?.getAttribute("href");
if (href) targetUrl = href;
...
window.SteamClient.URL.ExecuteSteamURL(`steam://openurl/${targetUrl}`);

I ran that exact sequence inside Steam's JavaScript context against the live store, using names of the kind non-Steam shortcuts actually have:

Shortcut name Top result the plugin would open
Balatro Balatro, correct
Setup My Dream Setup
Launcher Squirrel Launcher
osu! McOsu, Donation, 1

Non-Steam shortcuts are overwhelmingly launchers, emulators, installers and games that are not on Steam at all, which is precisely the population where the top hit is wrong. Silently opening an unrelated store page is worse than opening the search page, because the user has no signal that the guess failed.

Three further notes on that block:

  • Each click downloads and parses the full search page. I measured about 680 KB of HTML per click, then a full DOMParser pass over it.
  • It depends on the search_result_row class, which is Valve's markup and can change without notice. The day it does, the plugin silently falls back to the search URL, which is the good outcome, but the scraping was never load-bearing to begin with.
  • Navigating to https://store.steampowered.com/search/?term=<name> directly needs no fetch, no parsing, no guess, and matches what the README already promises. If you do want a best match, HTML scraping is the fragile way to get it.

5. Hardening: the scraped href is used unvalidated

href comes out of remote HTML and goes straight into a protocol handler:

window.SteamClient.URL.ExecuteSteamURL(`steam://openurl/${targetUrl}`);

There is no check that it is https, no check that the host is store.steampowered.com, and no encoding of the value being interpolated into the steam:// URL. In practice the HTML comes from Valve over HTTPS so the realistic risk is low, and I am not claiming an exploit here. But a two line origin and scheme check before handing a remote string to a protocol handler is cheap insurance, and the real hrefs carry a ?snr=... query string that is being concatenated into a steam:// URL without escaping. I did not test whether that query string survives the handler intact, since doing so meant navigating my own client.

Also worth removing: line 245 of the bundle logs at console.warn on every single click, which is a debug leftover rather than a warning.

6. Compatibility note

This injects into the same .GameStatsSection anchor as size-on-disk, which the description credits, and as my own date-added. All three append rather than replace, and this one uses a data-nssl-store-link marker and order: 9999, so I would not expect a clash. I am flagging it only because I have a plugin in that same spot and would rather say so than not.

Happy to re-review once the sources are in the repository, and to actually install and test it at that point.

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