Skip to content

Add CS Weekly Drop Reset - #175

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

Add CS Weekly Drop Reset#175
spix18 wants to merge 1 commit into
SteamClientHomebrew:mainfrom
spix18:main

Conversation

@spix18

@spix18 spix18 commented May 29, 2026

Copy link
Copy Markdown

CS Weekly Drop Reset

A plugin that adds a live countdown to the next Counter-Strike weekly drop reset, right on the Counter-Strike 2 page in your Steam library. It mounts a native-looking tile into the play bar's stats row, so it blends in with Steam's own UI.

CS Weekly Drop Reset on the Counter-Strike 2 library page

Features

  • Live countdown to the next weekly drop reset, updated every second.
  • Native styling — reuses Steam's own play-bar classes so the tile looks built-in.
  • Hover tooltip with the reset schedule and the exact reset time in your local timezone.
  • Fully local — frontend-only, no backend, zero network calls, no third-party links, no telemetry.
  • 10 languages, following your Steam client language, with correct plural rules handled via Intl.PluralRules (e.g. Russian's three grammatical forms).

Drop reset schedule

Counter-Strike weekly drops reset every Wednesday at 01:00 UTC. The tile shows the remaining time as Dd HH:MM:SS (the day count is omitted in the final 24 hours); the tooltip shows the next reset in your local time.

How it works

Steam's library keeps cached page trees stacked in the DOM, so neither the URL nor simple rect checks reveal which app's page is displayed. The plugin reads the real route (/library/app/730) from the window's browser manager and mounts a React tile into the one stats section that passes an elementFromPoint hit test — the page tree actually on screen. Navigation is tracked via history hooks, popstate/hashchange, a debounced MutationObserver, and a 1-second polling fallback. Tiles are unmounted cleanly when you leave the CS2 page.

Task Checklist

Developer

  • I am the original author or an authorized maintainer of this plugin.
  • I have complied with all license requirements for the libraries used, including providing appropriate notices where necessary.
  • My plugin is fully open source and does not depend on any external paid services, except for widely trusted and well-known platforms. Additionally, neither I nor anyone associated with me profits from any such services.

Plugin Functionality

  • I have tested the plugin on both the Stable and Beta Steam update channels. (Tested thoroughly on Stable — live-verified mounting, countdown accuracy, mount/unmount lifecycle, and all 10 locales. Beta channel testing is pending.)
  • My plugin is unique, or provides additional or alternative functionality to plugins already on the store. (No existing store plugin shows the Counter-Strike weekly drop reset countdown.)

Backend Configuration

  • No: I use a standard Millennium python backend in my plugin. (The plugin declares useBackend: false — it is frontend-only.)
  • No: I use custom binaries that or rely on other FOSS projects that aren't written directly using Millennium's python backend.

Community Contribution

  • I have tested and left feedback on two other plugin pull requests.
  • I have added links to those feedback comments in this PR.

Testing Instructions

  • Verified by a third party on Steam Client Stable.
  • Verified by a third party on Steam Client Beta.

@github-actions github-actions Bot changed the title Add cs-weekly-drop plugin Add CS Weekly Drop Reset Jul 29, 2026
@shdwmtr
shdwmtr requested a review from Copilot July 30, 2026 00:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@MuhammedResulBilkil

Copy link
Copy Markdown

Read the source at the pinned commit 3f25d9a (v1.2.3), then installed it and ran it on Steam Stable with Millennium. Findings ordered by measured impact rather than by how alarming they look in the source.

1. Russian pluralisation is wrong across most of the countdown's range.

countdown.ts:45-46 picks the singular for 1 and the plural for everything else. Right for en/de/fr/es/pt, harmless for zh/ja/ko where both keys hold the same string. Russian needs three forms. I ran your countdown.ts verbatim against Intl.PluralRules('ru') across the whole range:

ru day       wrong for n = 2,3,4                                      (3/7)
ru hour      wrong for n = 2,3,4,21,22,23                             (6/24)
ru minute    wrong for n = 2,3,4,21,22,23,24,31,...,54               (19/60)
ru second    wrong for n = 2,3,4,21,22,23,24,31,...,54               (19/60)

Stepping one second at a time through a full week, the tooltip carries at least one incorrect Russian form for 80.0 % of the week (483768 / 604800 seconds). A real sample three days out: 2 дней, 23 часов, 59 минут, 57 секунд, where Russian wants 2 дня, 23 часа. Note n = 21 is wrong and isn't a "few" case — 21 takes the singular in Russian, and the binary rule hands it the plural.

Intl.PluralRules(currentLocale()) returns one / few / many / other and is built into the runtime, so this costs no dependency — just an extra key in the bundles.

2. The error boundary sits one level below the code it's protecting.

RenderGuard is returned from DropResetTile (tile.tsx:133), so it only catches throws from its own children — not from DropResetTile's own render, which is where the risky work happens. nextReset, countdownTo, longForm and localStamp all run at tile.tsx:94-110, before the boundary exists; localStampDate.toLocaleString is the realistic thrower. Moving it into mounting.tsxroot.render(createElement(RenderGuard, null, createElement(DropResetTile))) — puts it where it can help.

Related: the try/catch around root.render at mounting.tsx:20-29 won't catch render errors either. React 18 renders concurrently, so nothing throws synchronously out of that call, and the node.remove() in the catch never runs for the case it looks like it's there for.

3. The tile mounts twice on the CS2 page.

Two visible cards, 144×48 each, at viewport (1500, 76) and (1500, 396)findDropSections is matching two separate GameStatsSection hosts. They're independent React roots with independent one-second intervals, so they drift: I sampled them reading 5d 17:46:02 and 5d 17:46:01 in the same instant. If the page is only meant to carry one stats row, the second match is spurious; if two are legitimate, the clocks should probably still share a tick.

4. history.pushState / replaceState are patched and never restored.

index.tsx:83-95. Confirmed live — String(history.pushState) in the desktop window returns function(...t){const o=e(...t);return s(),o}, not native code. After the plugin is disabled the wrappers stay installed for the life of the client, still calling enqueue into a closure with no reason to exist. No re-entrancy guard either, so if observe() ever runs twice for one window they stack.

5. findClassModule verifies two of the eight class names the tile depends on. tile.tsx:9-11 checks GameStatsSection and PlayBarLabel; the render then reads GameStat, LastPlayed, GameStatIcon, GameStatRight, PlayBarDetailLabel, LastPlayedInfo, and index.tsx also uses PlayBarGameName and Container. cx() drops undefined, so a Steam rename degrades to an unstyled tile rather than a crash — the right failure mode, but silent, and "looks native" is the feature.

6. The card opens a third-party site in the system browser with no opt-out. tile.tsx:117launch(DROP_PAGE)OpenInSystemBrowser for p337.info. The whole card is the click target and nothing signals that clicking leaves Steam.

7. Trivial. $schema in plugin.json points at .../src/sys/plugin-schema.json (404); current is .../src/system/....


On the always-on polling, which I'd flagged from the source and then measured. index.tsx:115 installs win.setInterval(enqueue, 1000) and index.tsx:106 a subtree observer on doc.body, in every window, never torn down; on a page with CS2 labels each sync() calls getBoundingClientRect() per label, forcing layout. Structurally it's more machinery than the job needs. But the cost isn't there: v8 CPU profile, 12 s on /library/home with no CS2 section mounted, 200 µs sampling, 20393 samples — the window is 99.62 % idle, and nothing attributable to this plugin appears above 0.15 %. Worth tidying on principle, not worth prioritising.

Things I checked expecting trouble and found none. Teardown on route change is clean: leaving /library/app/730 for /library/home took the tile count to 0, no orphans. And nextReset (countdown.ts:14-22) is correct — brute-forced over 26304 hourly samples spanning 2026–2029, every result lands exactly on Wednesday 01:00:00.000 UTC with 0 < gap <= 7d, min 1.00 h, max 168.00 h, zero failures. It's computed end-to-end in UTC so DST never enters, the advance === 0 && slot <= from branch is right at the boundary (at exactly 01:00:00 it rolls a full week rather than showing zero; at 01:00:01 it reads 6d 23:59:59), and month/year rollover falls out of setUTCDate. The 24 h day-drop is exact: 1d 00:00:00 at 24 h, 23:59:59 one second later. The live tile agreed — it read 5d 17:49:23 at a moment I independently computed 5 d 17:49 to 2026-08-05 01:00 UTC. All ten locale bundles carry the same fourteen keys, so nothing silently falls back to English.

Tested on Steam Stable, plugin confirmed loaded in PLUGIN_LIST, no console errors or warnings from it in either the desktop or browser context.

Disclosure: I have a plugin submission open in this queue.

@spix18

spix18 commented Jul 30, 2026

Copy link
Copy Markdown
Author

Read the source at the pinned commit 3f25d9a (v1.2.3), then installed it and ran it on Steam Stable with Millennium. Findings ordered by measured impact rather than by how alarming they look in the source.

1. Russian pluralisation is wrong across most of the countdown's range.

countdown.ts:45-46 picks the singular for 1 and the plural for everything else. Right for en/de/fr/es/pt, harmless for zh/ja/ko where both keys hold the same string. Russian needs three forms. I ran your countdown.ts verbatim against Intl.PluralRules('ru') across the whole range:

ru day       wrong for n = 2,3,4                                      (3/7)
ru hour      wrong for n = 2,3,4,21,22,23                             (6/24)
ru minute    wrong for n = 2,3,4,21,22,23,24,31,...,54               (19/60)
ru second    wrong for n = 2,3,4,21,22,23,24,31,...,54               (19/60)

Stepping one second at a time through a full week, the tooltip carries at least one incorrect Russian form for 80.0 % of the week (483768 / 604800 seconds). A real sample three days out: 2 дней, 23 часов, 59 минут, 57 секунд, where Russian wants 2 дня, 23 часа. Note n = 21 is wrong and isn't a "few" case — 21 takes the singular in Russian, and the binary rule hands it the plural.

Intl.PluralRules(currentLocale()) returns one / few / many / other and is built into the runtime, so this costs no dependency — just an extra key in the bundles.

2. The error boundary sits one level below the code it's protecting.

RenderGuard is returned from DropResetTile (tile.tsx:133), so it only catches throws from its own children — not from DropResetTile's own render, which is where the risky work happens. nextReset, countdownTo, longForm and localStamp all run at tile.tsx:94-110, before the boundary exists; localStampDate.toLocaleString is the realistic thrower. Moving it into mounting.tsxroot.render(createElement(RenderGuard, null, createElement(DropResetTile))) — puts it where it can help.

Related: the try/catch around root.render at mounting.tsx:20-29 won't catch render errors either. React 18 renders concurrently, so nothing throws synchronously out of that call, and the node.remove() in the catch never runs for the case it looks like it's there for.

3. The tile mounts twice on the CS2 page.

Two visible cards, 144×48 each, at viewport (1500, 76) and (1500, 396)findDropSections is matching two separate GameStatsSection hosts. They're independent React roots with independent one-second intervals, so they drift: I sampled them reading 5d 17:46:02 and 5d 17:46:01 in the same instant. If the page is only meant to carry one stats row, the second match is spurious; if two are legitimate, the clocks should probably still share a tick.

4. history.pushState / replaceState are patched and never restored.

index.tsx:83-95. Confirmed live — String(history.pushState) in the desktop window returns function(...t){const o=e(...t);return s(),o}, not native code. After the plugin is disabled the wrappers stay installed for the life of the client, still calling enqueue into a closure with no reason to exist. No re-entrancy guard either, so if observe() ever runs twice for one window they stack.

5. findClassModule verifies two of the eight class names the tile depends on. tile.tsx:9-11 checks GameStatsSection and PlayBarLabel; the render then reads GameStat, LastPlayed, GameStatIcon, GameStatRight, PlayBarDetailLabel, LastPlayedInfo, and index.tsx also uses PlayBarGameName and Container. cx() drops undefined, so a Steam rename degrades to an unstyled tile rather than a crash — the right failure mode, but silent, and "looks native" is the feature.

6. The card opens a third-party site in the system browser with no opt-out. tile.tsx:117launch(DROP_PAGE)OpenInSystemBrowser for p337.info. The whole card is the click target and nothing signals that clicking leaves Steam.

7. Trivial. $schema in plugin.json points at .../src/sys/plugin-schema.json (404); current is .../src/system/....

On the always-on polling, which I'd flagged from the source and then measured. index.tsx:115 installs win.setInterval(enqueue, 1000) and index.tsx:106 a subtree observer on doc.body, in every window, never torn down; on a page with CS2 labels each sync() calls getBoundingClientRect() per label, forcing layout. Structurally it's more machinery than the job needs. But the cost isn't there: v8 CPU profile, 12 s on /library/home with no CS2 section mounted, 200 µs sampling, 20393 samples — the window is 99.62 % idle, and nothing attributable to this plugin appears above 0.15 %. Worth tidying on principle, not worth prioritising.

Things I checked expecting trouble and found none. Teardown on route change is clean: leaving /library/app/730 for /library/home took the tile count to 0, no orphans. And nextReset (countdown.ts:14-22) is correct — brute-forced over 26304 hourly samples spanning 2026–2029, every result lands exactly on Wednesday 01:00:00.000 UTC with 0 < gap <= 7d, min 1.00 h, max 168.00 h, zero failures. It's computed end-to-end in UTC so DST never enters, the advance === 0 && slot <= from branch is right at the boundary (at exactly 01:00:00 it rolls a full week rather than showing zero; at 01:00:01 it reads 6d 23:59:59), and month/year rollover falls out of setUTCDate. The 24 h day-drop is exact: 1d 00:00:00 at 24 h, 23:59:59 one second later. The live tile agreed — it read 5d 17:49:23 at a moment I independently computed 5 d 17:49 to 2026-08-05 01:00 UTC. All ten locale bundles carry the same fourteen keys, so nothing silently falls back to English.

Tested on Steam Stable, plugin confirmed loaded in PLUGIN_LIST, no console errors or warnings from it in either the desktop or browser context.

Disclosure: I have a plugin submission open in this queue.

thank you for the review, i will get to work and fix all

@spix18

spix18 commented Aug 1, 2026

Copy link
Copy Markdown
Author

Read the source at the pinned commit 3f25d9a (v1.2.3), then installed it and ran it on Steam Stable with Millennium. Findings ordered by measured impact rather than by how alarming they look in the source.

1. Russian pluralisation is wrong across most of the countdown's range.

countdown.ts:45-46 picks the singular for 1 and the plural for everything else. Right for en/de/fr/es/pt, harmless for zh/ja/ko where both keys hold the same string. Russian needs three forms. I ran your countdown.ts verbatim against Intl.PluralRules('ru') across the whole range:

ru day       wrong for n = 2,3,4                                      (3/7)
ru hour      wrong for n = 2,3,4,21,22,23                             (6/24)
ru minute    wrong for n = 2,3,4,21,22,23,24,31,...,54               (19/60)
ru second    wrong for n = 2,3,4,21,22,23,24,31,...,54               (19/60)

Stepping one second at a time through a full week, the tooltip carries at least one incorrect Russian form for 80.0 % of the week (483768 / 604800 seconds). A real sample three days out: 2 дней, 23 часов, 59 минут, 57 секунд, where Russian wants 2 дня, 23 часа. Note n = 21 is wrong and isn't a "few" case — 21 takes the singular in Russian, and the binary rule hands it the plural.

Intl.PluralRules(currentLocale()) returns one / few / many / other and is built into the runtime, so this costs no dependency — just an extra key in the bundles.

2. The error boundary sits one level below the code it's protecting.

RenderGuard is returned from DropResetTile (tile.tsx:133), so it only catches throws from its own children — not from DropResetTile's own render, which is where the risky work happens. nextReset, countdownTo, longForm and localStamp all run at tile.tsx:94-110, before the boundary exists; localStampDate.toLocaleString is the realistic thrower. Moving it into mounting.tsxroot.render(createElement(RenderGuard, null, createElement(DropResetTile))) — puts it where it can help.

Related: the try/catch around root.render at mounting.tsx:20-29 won't catch render errors either. React 18 renders concurrently, so nothing throws synchronously out of that call, and the node.remove() in the catch never runs for the case it looks like it's there for.

3. The tile mounts twice on the CS2 page.

Two visible cards, 144×48 each, at viewport (1500, 76) and (1500, 396)findDropSections is matching two separate GameStatsSection hosts. They're independent React roots with independent one-second intervals, so they drift: I sampled them reading 5d 17:46:02 and 5d 17:46:01 in the same instant. If the page is only meant to carry one stats row, the second match is spurious; if two are legitimate, the clocks should probably still share a tick.

4. history.pushState / replaceState are patched and never restored.

index.tsx:83-95. Confirmed live — String(history.pushState) in the desktop window returns function(...t){const o=e(...t);return s(),o}, not native code. After the plugin is disabled the wrappers stay installed for the life of the client, still calling enqueue into a closure with no reason to exist. No re-entrancy guard either, so if observe() ever runs twice for one window they stack.

5. findClassModule verifies two of the eight class names the tile depends on. tile.tsx:9-11 checks GameStatsSection and PlayBarLabel; the render then reads GameStat, LastPlayed, GameStatIcon, GameStatRight, PlayBarDetailLabel, LastPlayedInfo, and index.tsx also uses PlayBarGameName and Container. cx() drops undefined, so a Steam rename degrades to an unstyled tile rather than a crash — the right failure mode, but silent, and "looks native" is the feature.

6. The card opens a third-party site in the system browser with no opt-out. tile.tsx:117launch(DROP_PAGE)OpenInSystemBrowser for p337.info. The whole card is the click target and nothing signals that clicking leaves Steam.

7. Trivial. $schema in plugin.json points at .../src/sys/plugin-schema.json (404); current is .../src/system/....

On the always-on polling, which I'd flagged from the source and then measured. index.tsx:115 installs win.setInterval(enqueue, 1000) and index.tsx:106 a subtree observer on doc.body, in every window, never torn down; on a page with CS2 labels each sync() calls getBoundingClientRect() per label, forcing layout. Structurally it's more machinery than the job needs. But the cost isn't there: v8 CPU profile, 12 s on /library/home with no CS2 section mounted, 200 µs sampling, 20393 samples — the window is 99.62 % idle, and nothing attributable to this plugin appears above 0.15 %. Worth tidying on principle, not worth prioritising.

Things I checked expecting trouble and found none. Teardown on route change is clean: leaving /library/app/730 for /library/home took the tile count to 0, no orphans. And nextReset (countdown.ts:14-22) is correct — brute-forced over 26304 hourly samples spanning 2026–2029, every result lands exactly on Wednesday 01:00:00.000 UTC with 0 < gap <= 7d, min 1.00 h, max 168.00 h, zero failures. It's computed end-to-end in UTC so DST never enters, the advance === 0 && slot <= from branch is right at the boundary (at exactly 01:00:00 it rolls a full week rather than showing zero; at 01:00:01 it reads 6d 23:59:59), and month/year rollover falls out of setUTCDate. The 24 h day-drop is exact: 1d 00:00:00 at 24 h, 23:59:59 one second later. The live tile agreed — it read 5d 17:49:23 at a moment I independently computed 5 d 17:49 to 2026-08-05 01:00 UTC. All ten locale bundles carry the same fourteen keys, so nothing silently falls back to English.

Tested on Steam Stable, plugin confirmed loaded in PLUGIN_LIST, no console errors or warnings from it in either the desktop or browser context.

Disclosure: I have a plugin submission open in this queue.

Thanks for the thorough review , the measured pluralization numbers and the live double-mount evidence made
replicating easy. All fixed in 26075b7 (v1.2.5), which the submodule pin now points to. Point by point:

  1. Russian pluralisation , fixed. longForm() now resolves forms through
    Intl.PluralRules(currentLocale()).select(value) with a fallback chain (unit_ → singular/plural keys).
    Two-form locales behave exactly as before; russian.json gained day_few/hour_few/minute_few/second_few
    (дня/часа/минуты/секунды). Verified: n = 2,3,4 → few, 21,31 → one, 5–20,25–30 → many on all four units; your sample
    now reads 2 дня, 23 часа, 59 минут, 57 секунд. Zero dependencies, as you noted.

  2. Error boundary, fixed, and you identified the real thrower. RenderGuard now wraps at the mount site
    (root.render()), so nextReset / countdownTo / longForm / localStamp all run inside the
    boundary. Also removed the misleading try/catch around root.render , React renders concurrently, so it could never
    catch; the try/catch now covers only createRoot.

  3. Double mount, fixed in v1.2.5, better than my first attempt. v1.2.4's "first match in DOM order" was wrong
    because Steam keeps cached page trees stacked in the document: the top GameStatsSection clone is definitively
    covered by an overlay (verified via elementFromPoint on the live client), so a DOM-order pick could mount invisibly.
    The tile now resolves the current app from the window's MainWindowBrowserManager.m_lastLocation (route
    /library/app/730, no more DOM text matching), then mounts into the one stats section that passes an elementFromPoint
    hit-test (same-tree overlays like Steam tooltips pass; stacked clones don't). Exactly one tile, on the displayed
    page, unmounted on route change.

  4. history wrappers, mitigated, with a caveat I want to be honest about. @steambrew/client exposes no
    plugin-disable hook. What I could do: a WeakSet makes double-patching impossible (closing the stacking gap),
    and the wrappers store the originals and restore them on the window's unload. The residual case , plugin disabled
    while the window stays alive, leaves a wrapper forwarding to a no-op on non-CS2 pages, below the noise floor you
    measured.

  5. Class-name verification, fixed. findClassModule now requires every play-bar class the plugin uses
    (GameStatsSection, GameStat, LastPlayed, GameStatIcon, GameStatRight, PlayBarLabel, PlayBarDetailLabel,
    LastPlayedInfo; the two previously also-listed classes are gone since detection is route-based now). A Valve rename
    still degrades instead of crashing, but no longer silently , it logs a console warning.

  6. Third-party link, removed entirely rather than disclosed. Disclosing wasn't the fix I wanted: this plugin is a
    countdown and nothing more, and shipping a tile that launches any third-party website contradicts that. DROP_PAGE,
    launch(), and the card's onClick are gone; the tile is display-only. The plugin now makes zero outgoing connections,
    which strengthens the "no network calls, no telemetry" claim in the README.

  7. $schema, fixed. sys/ → system/.

On the polling: deliberately kept. I agree it's more machinery than strictly needed, but your own 12s profile showed
the window at 99.62% idle with nothing attributable above 0.15%, and the interval+observer pair is the only thing
covering navigation paths that emit no history/DOM events in some client builds. Happy to revisit if you'd like it
gone anyway.

All of this was verified live on Steam Stable (mount on /library/app/730: exactly one on-screen tile; navigate away:
detached within a second; navigate back: single remount). tsc --noEmit clean, production build through
millennium-ttc passes.

Thanks again, especially for proving nextReset correct over 26,304 samples; that saved me defending code you'd
already validated.

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.

3 participants