Compare commits

..

103 commits

Author SHA1 Message Date
Shekhar Vaidya
fe889e85a7 fix(frontend): use npm install instead of npm ci (no lockfile committed) 2026-09-02 11:41:07 +05:30
Shekhar Vaidya
16f034e18c feat(frontend): add nginx config for SPA routing 2026-09-02 11:38:34 +05:30
Shekhar Vaidya
3407bc31c4 feat(frontend): add multi-stage Dockerfile for Coolify static build 2026-09-02 11:29:07 +05:30
Shekhar Vaidya
fba4502055 docs: log Aug 17 Sentinel check-in, file per-language checksums idea
TLS fingerprint fix (v0.3.7) checked in 17 days post-release: aggregate
CLI Sentinel-rejection rate still ~21%, unchanged -- inconclusive given
mixed-version population, revisit once 0.3.7+ dominates usage.

New backlog item: per-language SHA256 checksums on product pages.
Confirmed live that Microsoft's own download pages publish a static
per-locale hash table for the current build, not reachable through any
API call MSDL/CLI already makes. Since it only changes when a product
ID is replaced, no scraper needed -- just copy it by hand at the same
time a new product gets added to the catalog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 23:52:21 +05:30
Shekhar Vaidya
d562bf8d0e fix(frontend): Windows 10 lifecycle text/badge were stale (dated Oct 2025, now Aug 2026)
Homepage's featured card said "Security support until October 2025" --
that date has already passed, so it read as a future deadline that
wasn't. Updated to reflect actual status (mainstream support ended,
consumer ESU runs through Oct 2026) -- kept tight to match the other
three cards' description length so the grid stays even (the first
draft ran long enough to wrap to 3 lines and grow that one card's
height past its siblings).

Also found products.json still tagged both Windows 10 22H2 variants
(2618, 2378) as "EOL SOON" -- inconsistent with the homepage's own
"EOL"/"END OF LIFE" badge for the same product, and equally stale
for the same reason. products.json's badge field renders verbatim
on the product detail page, so this was live and user-facing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 23:28:21 +05:30
Shekhar Vaidya
f6659fcc42 docs(ci): add AUR install method to release notes template
Now that msdl-bin is actually published on AUR, future releases'
auto-generated "What's New" -> Install section should mention it
too, matching what's already in README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 13:14:34 +05:30
Shekhar Vaidya
34e2b35486 docs: msdl-bin is published on AUR now, not just prepared
README still said the AUR package was "prepared but not yet
published" -- it went live today (msdl-bin 0.3.7-1, now that AUR
registration reopened). Added it as a proper install method alongside
winget/Homebrew, matching their existing formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 13:12:38 +05:30
Shekhar Vaidya
f27e2931b4 chore(aur): bump msdl-bin to v0.3.7, note AUR registration reopened
.SRCINFO regenerated with real makepkg this time (Docker was running),
confirming the same values as the manual field-mapping used previously.

AUR disabled new account registrations 2026-06-15 after a malware
campaign; reopened as of today. Updated the README note accordingly --
this package is ready to actually publish once an account + SSH key
are set up, which is a manual step only the maintainer can do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 12:49:31 +05:30
Shekhar Vaidya
33f3a46777 chore(release): bump to v0.3.7 -- backend version string + winget manifests; fix(ci): winget submission needs a Windows runner
Version bump: backend/main.go latestCLIVersion and the local
winget/manifests/*.yaml mirror to 0.3.7, with the real
InstallerUrl/InstallerSha256 for the cli/v0.3.7 release asset.

CI fix: the first real winget-submission attempt (v0.3.7, now that
WINGET_TOKEN is finally set) failed immediately -- wingetcreate isn't
published as a NuGet/dotnet-tool package at all, it's a native Windows
binary requiring .NET 6 + VC++ Redistributable. `dotnet tool install
--global wingetcreate` on ubuntu-latest could never have worked; this
path was just never exercised before since the empty-token check
always short-circuited it first. Split winget submission into its own
job on windows-latest, downloading wingetcreate.exe directly from its
GitHub release instead of dotnet tool install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 12:29:08 +05:30
Shekhar
cf318eb15c
Merge pull request #40 from starkSV/feat/cli-tls-fingerprint-hardening
feat(cli): TLS/HTTP2 fingerprint hardening via tls-client
2026-07-31 12:18:56 +05:30
Shekhar Vaidya
78be837a4a docs: explain the Sentinel WAF saga, retire completed plans, commit the needs-warming spec
PROGRESS.md and CLAUDE.md now document the full investigation: three
weekly checkpoints confirming the backend's direct link-fetch is
100% dead, the crowdsourced CLI-contribution cache carrying the real
load (286 accepted, 0 rejected), the two merged backend fixes
(lockdown TTL, /proxy product_id validation), and the CLI TLS
fingerprint hardening in this branch (functionally verified, not yet
validated against the Sentinel-rejection-rate trend).

Removed two plan+spec pairs under docs/superpowers/ (the original CLI
build plan and the telemetry/update-check plan+design) -- both fully
shipped, redundant with PROGRESS.md's own record of what happened.

Committed docs/superpowers/specs/2026-07-13-needs-warming-design.md
for the first time -- it's been sitting untracked in the working tree
for weeks. Unlike the two removed docs, this one is a live, unbuilt
proposal (a public page surfacing which products are currently
Sentinel-locked with no cached fallback), not completed work, so it's
kept and now tracked in the backlog instead of discarded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 12:08:38 +05:30
Shekhar Vaidya
9e22cd8bf8 feat(cli): TLS/HTTP2 fingerprint hardening via tls-client
Three checkpoints over 17 days confirmed a stable ~20% Sentinel
rejection rate on the CLI's own requests, despite running from
residential IPs specifically to avoid ASN-based blocking. That
persistence points at TLS ClientHello fingerprinting as an additional
signal: Go's stdlib crypto/tls produces a handshake that looks nothing
like a real browser, independent of IP or headers.

Swaps the CLI's HTTP transport (session setup, SKU lookup, download
link fetch, eval link resolution) from net/http to
github.com/bogdanfinn/tls-client, which wraps utls with a maintained
Chrome browser profile (TLS + HTTP2 fingerprint together, not just
TLS -- a browser-consistent one without the other is its own tell).
Bumped msUA's claimed Chrome version to 133 to match the chosen
profile, since a mismatched UA vs. TLS fingerprint is itself
detectable.

The custom simpleCookieJar is gone -- tls-client provides its own
cookie jar. fetchEvalLinks got the same treatment for consistency,
even though the Eval Center path isn't currently blocked.

Verified: go build/vet/test all pass, and a live end-to-end run
(msdl --id 3262 --lang English) returned a real signed download link
and successfully contributed it back to the shared cache. That proves
the flow still works functionally through the new client -- it does
NOT prove the fingerprint theory, since the old client already
succeeded ~80% of the time. The real test is watching the Sentinel-
rejection-rate telemetry over a comparable multi-day window after
this ships.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 12:02:44 +05:30
Shekhar
6ace2ceb19
Merge pull request #39 from starkSV/fix/sentinel-lockdown-and-proxy-validation
fix(backend): back off Sentinel lockdown retries, validate product_id in /proxy
2026-07-31 11:49:57 +05:30
Shekhar Vaidya
6ab849be5e fix(backend): back off Sentinel lockdown retries, validate product_id in /proxy
Log analysis over 17 days showed 181 Sentinel-block retry attempts on
/proxy, all 181 failed (0% success). Retrying every 90 minutes yields
zero value right now and just adds more blocked-request noise against
our own IP for nothing in return. Bumped lockdownTTL to 5h so it backs
off harder while still recovering same-day if Microsoft's block ever
eases.

Also found /proxy never validated product_id against the known
catalog, so a request for a nonexistent ID (e.g. product_id=2861,
never a real product) still burned a full outbound Microsoft session
attempt and a Sentinel-block hit for something that could never
succeed anyway. Now rejected with 404 before any Microsoft call,
reusing the existing validContributeProducts allow-list.

Verified locally: unknown product_id -> 404 (no MS fetch attempted in
logs), missing params -> 400 unchanged, known product_id -> proceeds
to a real MS session attempt as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:35:44 +05:30
Shekhar Vaidya
5a806eea87 fix(frontend): remove redundant, ignored _redirects rule
Cloudflare's deploy log has been flagging the SPA fallback rule
(/* /index.html 200) as a false-positive infinite loop and ignoring
it on every deploy. Investigated live: direct navigation to /about, a
dynamic route, and a genuinely invalid path all return 200 with the
correct content already, since Cloudflare Pages' own default fallback
serves index.html for any unmatched path. The rule was dead weight --
removing it to stop the recurring warning noise. Closes the tracked
issue in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:19:50 +05:30
Shekhar Vaidya
73eee730c9 fix(frontend): CLI curl-command copy bug, stale/missing content, and Fido attribution cleanup
- CliHandoff: the visible curl install command was a hardcoded truncated
  placeholder ("...install.sh"), so the copy button worked but manually
  selecting/copying the text gave a broken command. Now shows the real URL
  and lets CSS `truncate` handle the visual clipping.
- Dock: adds a full-width backdrop-blur shelf under the floating desktop
  dock so it doesn't visually blend into scrolled content (page bg is
  near-black, so plain color gradients were invisible -- blur is what
  actually reads). Desktop only; the mobile dock is already a flush bar.
- HomePage: hero now mentions "Open source" (linked, dotted underline) --
  previously absent anywhere on the landing page.
- StatsBar: "releases available" count now includes eval/enterprise
  editions, not just the consumer catalog (17 -> combined total).
- About/Privacy/Disclaimer: these hadn't been updated since the CLI
  shipped. Privacy Policy now discloses the CLI's anonymous telemetry and
  crowdsourced link-contribution behavior (previously undisclosed). About
  page's stale hardcoded product list replaced with a link to the catalog,
  plus a new section explaining the CLI. Disclaimer now covers the CLI
  binary under the same terms.
- Fido/Rufus attribution was scattered with duplicate linked mentions on
  the same pages; consolidated to one canonical linked credit per surface
  (About's Credits section, README's intro) instead of repeating it.
- FAQAccordion: fixed a factual error ("ported to Go and Node.js" -- no
  Node.js backend exists), and linked the actual repo in the open-source
  FAQ answer (previously just asserted it with no link).
- sitemap.xml: removed a dead entry for product 48, which was already
  removed from the catalog (Microsoft returns 502 for it).
- README: fixed three inaccuracies in the /contribute API doc (wrong auth
  header, wrong body field name, wrong status codes) against the actual
  backend/main.go implementation, and added the missing telemetry/sentinel
  fields to the /metrics example response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:10:38 +05:30
Shekhar Vaidya
b516804541 chore: add GitHub Sponsors funding link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 13:13:48 +05:30
Shekhar Vaidya
47698d8129 chore(aur): bump msdl-bin to v0.3.6
Local mirror only -- AUR account registration is still disabled
(since 2026-06-15), so this isn't pushed to aur.archlinux.org yet.
Kept in sync so it's ready to publish the moment registration
reopens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:49:07 +05:30
Shekhar
61fdcffaaa
Merge pull request #38 from starkSV/chore/cli-v0.3.6-bump
chore(release): bump to v0.3.6 -- backend version string + winget manifests
2026-07-14 11:35:08 +05:30
Shekhar Vaidya
a831cddb8e chore(release): bump to v0.3.6 -- backend version string + winget manifests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:33:48 +05:30
Shekhar
8019db0314
Merge pull request #36 from starkSV/feat/check-new-releases-script
feat: new-release detector tools + missing 25H2 V2 China catalog entries
2026-07-14 11:29:25 +05:30
Shekhar
da1e894e6b
Merge pull request #37 from starkSV/chore/cleanup-stale-docs
chore: remove superseded implementation plan and stale issue draft
2026-07-14 11:27:20 +05:30
Shekhar Vaidya
c53eae3f0e chore: remove superseded implementation plan and stale issue draft
IMPLEMENTATION_PLAN.md (Phases 1-5: CLI direct-to-Microsoft, Redis L2
cache, /contribute crowdsourcing, web graceful degradation, CLI
handoff UI, distribution) had shipped in full but still read "Status:
Proposed" -- folded a summary of what actually landed into
PROGRESS.md instead.

issues.md's one drafted issue (inactive products still triggering
backend requests) is already fixed -- ProductDetailPage.tsx gates the
SKU-info effect on meta.active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:18:39 +05:30
Shekhar Vaidya
c30c2d2b3b docs: finish check-new-releases README rewrite
Prior commit staged an intermediate version of this file. Completes
the Method 1 / Method 2 writeup with usage examples, the comparison
table, and the field-name bug/session-permit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:10:11 +05:30
Shekhar Vaidya
da8787e93a feat: add check-new-releases tools; fix msdls_v3.py; add missing 25H2 V2 China variants
Moves msdls_v3.py from repo root into scripts/check-new-releases/,
alongside a new Go tool, documented as Method 1 (Python brute-force
range scan, can write results to a catalog-shaped JSON) and Method 2
(Go auto-discovery via page-scrape + bounded adjacent-ID probe, no
range-guessing needed). Only Windows 11 is checked -- 8.1 is fully
frozen and Windows 10 is past end-of-life, so neither will produce a
new consumer ISO again.

Fixed msdls_v3.py's release-name extraction: it checked EditionName/
ReleaseName/FriendlyName, none of which exist in the real API
response -- the actual field is ProductDisplayName, confirmed live.
Also added the vlscppe session-permit call to setup_session(), which
was skipped entirely; higher-risk gap for this script's brute-force
range-scan usage pattern than for a single lookup.

Both tools' live runs surfaced a real catalog gap: product IDs
3322/3323/3325/3326 (Home/Pro China variants of the 25H2 "V2" refresh)
exist on Microsoft's side but weren't in our catalog. Added them to
cli/catalog.go, products.json, validContributeProducts, and
sitemap.xml, and renamed 3321/3324 from our own guessed "(Updated
Oct)" label to Microsoft's actual name for this refresh, "(V2)"
(their internal name is "Windows 11 25H2__V2"; cleaned up the
double-underscore for user-facing display).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:07:45 +05:30
Shekhar
f67fe84348
Merge pull request #35 from starkSV/fix/remove-brew-note
fix(frontend): remove redundant brew formula-name disclaimer from CLI page
2026-07-14 00:59:09 +05:30
Shekhar Vaidya
3ceb179ae6 fix(frontend): remove redundant brew formula-name disclaimer from CLI page
The msdl-cli vs msdl naming footnote is useful context in README/release
notes, but unnecessary clutter for a casual visitor on the web CLI page --
the "recommended" badge and the command itself are enough.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 00:58:35 +05:30
Shekhar
11e7f7274f
Merge pull request #34 from starkSV/fix/homebrew-formula-collision
fix: update Homebrew install command to msdl-cli formula
2026-07-14 00:54:20 +05:30
Shekhar Vaidya
4dce41483d fix: update Homebrew install command to msdl-cli formula
homebrew/core already has an unrelated package literally named "msdl"
(a streaming-protocol downloader). brew install msdl silently
resolved to that instead of our tap, since Homebrew always prefers
core for a bare-name collision -- confirmed in production when a
user's `brew install msdl` pulled homebrew-core's msdl 1.2.7-r2
instead of ours. The tap's formula is renamed to msdl-cli
(starkSV/homebrew-msdl, separate repo) to fix this permanently; the
installed command itself is unaffected, still just `msdl`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 00:52:57 +05:30
Shekhar
2fae096b15
Merge pull request #33 from starkSV/feat/install-script
feat: add curl | bash installer for Linux/macOS without Homebrew
2026-07-13 22:29:55 +05:30
Shekhar Vaidya
5eec0351e7 feat: add curl | bash installer for Linux/macOS without Homebrew
Adds a GET /install.sh endpoint on the backend (embedded via Go's
embed package, so the script stays a real, shellcheck-able .sh file
rather than a Go string literal). Auto-detects OS/arch (including
Termux on Android via $PREFIX) and always resolves the latest GitHub
release via the /releases/latest/download/ redirect, so unlike the
winget/brew/AUR manifests it needs no per-release maintenance.

Verified end-to-end in an ephemeral Ubuntu container (both the normal
/usr/local/bin path and the Termux $PREFIX path), and locally against
a running backend instance.

Documented alongside the existing winget/Homebrew instructions in
README.md, the release notes template, the web CLI page, and the
per-product CliHandoff card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 22:28:23 +05:30
Shekhar
151f70d67f
Merge pull request #32 from starkSV/feat/aur-package
feat: add AUR msdl-bin package; document Homebrew tap everywhere
2026-07-13 13:40:17 +05:30
Shekhar Vaidya
eddf7802bb docs: note AUR publishing is blocked; add Homebrew install everywhere
Arch Linux disabled new AUR account registrations on 2026-06-15 after
a malware campaign compromised 1,500+ AUR packages. There's no
announced reopening date, so aur/msdl-bin/README.md now says so
clearly instead of implying publishing is just a pending step.

Adds the Homebrew tap install command (brew tap starkSV/msdl && brew
install msdl) to every place winget is already documented: README.md,
the release workflow's notes, the web CLI page, and the per-product
CliHandoff card. Also adds the missing linux-arm64 row to the manual
download tables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 13:39:30 +05:30
Shekhar Vaidya
c14c18e955 feat: generate .SRCINFO via Docker; pin AUR files to LF line endings
.SRCINFO generated with the real makepkg tool inside an ephemeral
archlinux container -- no VM or native Arch install needed, and no
risk of hand-written formatting drift from the makepkg-canonical
output.

Also adds .gitattributes forcing LF for PKGBUILD/.SRCINFO regardless
of platform checkout settings -- both are parsed by shell/makepkg
tooling that breaks on CRLF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 13:26:26 +05:30
Shekhar Vaidya
e660e95d49 feat: add AUR msdl-bin package; document Homebrew tap
Tracks the AUR PKGBUILD in-repo for version history, mirroring how
winget/manifests/ already works. Also corrects CONTRIBUTING.md's claim
that winget auto-updates via CI -- WINGET_TOKEN isn't set, so that
step always no-ops; package manager updates are all submitted
manually by the maintainer after each release.

Homebrew tap published separately at starkSV/homebrew-msdl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 13:16:12 +05:30
Shekhar
f514d972a3
Merge pull request #31 from starkSV/chore/cli-v0.3.5-bump
chore(release): bump to v0.3.5 -- backend version string + winget manifests
2026-07-13 12:58:55 +05:30
Shekhar Vaidya
388f115db6 chore(release): bump to v0.3.5 -- backend version string + winget manifests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 12:58:06 +05:30
Shekhar
a314cdcb5e
Merge pull request #30 from starkSV/fix/help-lang-example
feat(cli): homepage landing screen, search fix, and --lang example fix
2026-07-13 12:53:42 +05:30
Shekhar
1432cb3158
Merge pull request #29 from starkSV/feat/sentinel-distinct-sources
feat(backend): approximate distinct-source count for Sentinel rejections
2026-07-13 12:46:06 +05:30
Shekhar Vaidya
327c5e195d simplify(backend): drop date-scoping for Sentinel stats, use flat all-time fields
Matches the shape actually asked for and how every other telemetry
counter in this file already works (all-time cumulative via HIncrBy,
no expiry, no per-day key rotation): /metrics now exposes flat
sentinel_errors and sentinel_distinct_sources_est fields instead of a
nested, date-scoped sentinel_today object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 12:43:31 +05:30
Shekhar Vaidya
b252ba1c30 feat(backend): approximate distinct-source count for Sentinel rejections
CLI telemetry showed a growing count of Sentinel rejections but no way
to tell whether they come from one user retrying repeatedly or many
distinct users independently getting blocked.

Adds a per-day Redis HyperLogLog (msdl:telemetry:sentinel_hll:<date>)
seeded from the reporting IP whenever a CLI error contains "Sentinel",
plus a parallel daily counter. /metrics now exposes sentinel_today:
{date, errors_today, distinct_sources_est}. No raw IPs are stored --
HLL is a one-way cardinality estimator, and both keys expire after 48h.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 12:37:01 +05:30
Shekhar Vaidya
3994a82d6d feat(cli): homepage landing screen for bare msdl; fix search substring bug
The bare `msdl` invocation showed all 22 products (17 consumer + 5
eval) as a flat numbered list. Replaces it with a curated landing
screen: 5 popular products (picked from real usage telemetry) plus a
2-item eval shortlist, with "list" as an escape hatch to today's full
picker and free text routed through the existing search flow.

Also fixes a search bug: querying "windows 10" incorrectly matched
Windows 11 24H2 because "10" is a substring of its build number
(26100.1742). Query words now must start at a word boundary rather
than matching anywhere, so digit fragments inside unrelated numbers
can no longer collide -- while "arm" still fuzzy-matches "ARM64".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 12:24:43 +05:30
Shekhar Vaidya
b1ee80f9b6 fix(cli): use plain "English" in --lang examples, not "English (United States)"
Consumer Windows products list their English option as "English" (or
"English International"), not "English (United States)" -- the exact
example this used, which is why it was such a common source of --lang
mismatch errors. Applies to --help, the --lang flag description, and
the release workflow's usage example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 11:27:12 +05:30
Shekhar
6e374e6a45
Merge pull request #28 from starkSV/fix/changelog-generation
fix(ci): correct release changelog generation
2026-07-13 11:18:24 +05:30
Shekhar Vaidya
1d5fc9c0f5 fix(ci): correct release changelog generation
Two bugs combined to make v0.3.4's changelog show the previous
release's version-bump commit instead of the actual change:

1. The path filter (cli/ backend/ frontend/) excluded workflow-only
   changes, so a CI-only addition (like linux-arm64) never appeared.
2. The "bump version + winget manifest" commit is created *after* its
   own tag (it needs the release's SHA256, which doesn't exist until
   after tagging) -- so it always lands in the *next* release's diff
   range instead of its own, showing up as stale noise.

Adds .github/workflows/cli-release.yml to the path filter, filters
out "chore(release): bump" commits as noise, and falls back to a
generic line if a release genuinely has nothing else to show.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 11:16:24 +05:30
Shekhar
e1151ff0cf
Merge pull request #27 from starkSV/docs/contributing
docs: add CONTRIBUTING.md
2026-07-13 11:03:44 +05:30
Shekhar Vaidya
4bb0ba9f2a docs: add CONTRIBUTING.md
Moves the README's inline contributing notes into a dedicated file
and expands them with dev setup, PR conventions, and how CLI releases
work, so contributors have one place to look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 11:00:46 +05:30
Shekhar
98f61b1831
Merge pull request #26 from starkSV/feat/linux-arm64-build
feat(ci): add linux-arm64 build target for CLI releases
2026-07-13 10:50:49 +05:30
Shekhar Vaidya
e2e41e8ecf feat(ci): add linux-arm64 build target for CLI releases
Telemetry showed a growing number of Termux (Android) users running
the CLI, but the release workflow only builds linux-amd64 -- those
users had to build from source since Termux runs on ARM64. Adds a
linux-arm64 binary and install line so they can just curl it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 10:45:33 +05:30
Shekhar
c4fb7a251b
Merge pull request #25 from starkSV/chore/cli-v0.3.3-bump
chore(release): bump to v0.3.3 -- backend version string + winget manifests
2026-07-12 18:18:05 +05:30
Shekhar Vaidya
a3f75c1ce5 chore(release): bump to v0.3.3 -- backend version string + winget manifests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:17:30 +05:30
Shekhar
75295943b6
Merge pull request #24 from starkSV/feat/cli-error-messages
fix(cli): list available languages on --lang mismatch; fix telemetry truncation
2026-07-12 18:14:26 +05:30
Shekhar Vaidya
61a296b92b fix(cli): list available languages on --lang mismatch; fix telemetry truncation
Real-world telemetry showed --lang "English (United States)" failing
for several consumer products that only list "English International"
-- the exact example used in --help. The mismatch error now lists the
languages actually available, and the help text notes names vary by
product.

Also fixes truncateError to keep both head and tail of long error
strings (previously kept only the head, which for wrapped errors with
a long URL cut off the actual root cause at the end).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:13:48 +05:30
Shekhar
48a6cb5eb4
Merge pull request #23 from starkSV/fix/lockdown-cache-status
fix(backend): don't mislabel fresh lockdown-gate cache hits as stale
2026-07-08 19:48:19 +05:30
Shekhar Vaidya
8b430fab5d fix(backend): don't mislabel fresh lockdown-gate cache hits as stale
The Sentinel lockdown gate served any linkCache hit as "stale"
regardless of whether the entry was still within its own TTL. A link
just contributed via CLI (still valid for ~24h) would get labeled
stale, causing the frontend to hide it and show the CLI-only banner
again instead of the working link it just received.

Now checks the entry's actual expiry: still-fresh entries are labeled
"cached" (frontend shows the link normally); only genuinely expired
entries are labeled "stale".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 19:47:09 +05:30
Shekhar
e5d9dc3d61
Merge pull request #22 from starkSV/chore/cli-v0.3.2-bump
chore(release): bump to v0.3.2 -- backend version string + winget manifests
2026-07-08 12:45:06 +05:30
Shekhar Vaidya
ef64e06b99 chore(release): bump to v0.3.2 -- backend version string + winget manifests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 12:44:07 +05:30
Shekhar
6d55ee573f
Merge pull request #21 from starkSV/feat/telemetry-error-field
feat(telemetry): capture error message on failed CLI runs
2026-07-08 12:36:26 +05:30
Shekhar Vaidya
8ab322038b feat(telemetry): capture error message on failed CLI runs
Failure telemetry only recorded success=false with no context. Adds
a truncated error string (150 chars, CLI-side) forwarded to a new
msdl:telemetry:errors Redis hash so failure causes are diagnosable
from /metrics instead of guessed at.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 12:29:59 +05:30
Shekhar
8258bd139d
Merge pull request #20 from starkSV/feat/cli-v0.3.1
chore(release): bump to v0.3.1 -- backend version string + winget manifests
2026-06-30 08:14:14 +05:30
Shekhar Vaidya
d275340c93 chore(release): bump to v0.3.1 — backend version string + winget manifests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 08:10:25 +05:30
Shekhar Vaidya
bc857c4986 fix(cli): send telemetry synchronously; tag eval-via-picker correctly
Telemetry was fired as a goroutine then the process exited immediately,
killing it before the HTTP request could complete. Now called
synchronously — sendTelemetry already has a 2s internal timeout so
worst-case latency is unchanged for the user.

Also moves the combined picker into run() so eval products selected
interactively are tagged action=eval with the correct eval_slug instead
of action=interactive with no slug.

Adds What's New changelog section to release notes, auto-generated from
commits since the previous tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 07:50:13 +05:30
Shekhar Vaidya
5fa1b11835 chore(winget): bump manifests to v0.3.0
SHA256: FA6DA7B905CFCAD52A0DAE884641055B00A831914F38E78F86F927DED51B0639

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 07:28:58 +05:30
Shekhar Vaidya
2c068ff055 feat(cli): v0.3.0 — telemetry, arch picker, O/C menu, eval in picker
Backend:
- GET /cli/version update-check endpoint
- POST /telemetry anonymous usage counters (Redis-persisted)
- Telemetry counters in /metrics response

CLI:
- Version var injected via ldflags at build time
- Update notice on every run (non-blocking, 500ms timeout)
- Architecture picker when multiple links returned
- Post-fetch [O] Open in browser / [C] Copy / [Enter] Exit menu
- Combined interactive picker includes Evaluation/Enterprise products
  (Windows Server 2025/2022/2019/2016, Windows 11 Enterprise)
- openInBrowser: writes URL to temp .url file — & no longer truncated
- copyToClipboard: calls clip.exe via full SystemRoot path

Frontend:
- Winget elevated to primary install on CLI page + CliHandoff card
- CliHandoff command shows msdl (no .exe suffix)

Docs: README telemetry disclosure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 07:26:31 +05:30
Shekhar Vaidya
a9c713cd6a feat(cli): combined product picker with eval products + clipboard fix
- Interactive `msdl` (no flags) now lists consumer + eval products in one
  picker with an Evaluation / Enterprise separator; selecting a server
  product routes to the eval flow automatically
- copyToClipboard calls clip.exe by full SystemRoot path, bypassing
  cmd.exe PATH lookup that failed in some terminal environments
- CliHandoff card: winget primary install + msdl (no .exe) in run command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 07:25:03 +05:30
Shekhar Vaidya
7a267a2581 fix(cli): open browser via temp .url file to preserve & in signed URLs
cmd.exe treats & as a command separator, truncating Microsoft's signed
download URLs at the first query parameter boundary. Writing the URL to
a Windows Internet Shortcut (.url) file means cmd.exe only ever sees a
temp filename with no special characters; the browser reads the full URL
from the file contents.

Uses %COMSPEC% to locate cmd.exe reliably across environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 07:06:02 +05:30
Shekhar Vaidya
9e836503c0 Merge branch 'main' into feat/cli-telemetry 2026-06-30 06:40:46 +05:30
Shekhar Vaidya
d4973ab42a fix(backend): truncate HTTP error body in logs to 120 chars
Microsoft's 403 capacity page was logging hundreds of lines of HTML
per error. Both non-200 error paths now cap the body at 120 chars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 10:33:03 +05:30
Shekhar Vaidya
d2c5c3c082 feat(frontend): use tracked shortlink for CLI releases download button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:17:59 +05:30
Shekhar Vaidya
40327c33b4 fix(frontend): show lockdown message on error path when no stale data
When the backend returns 429+lockdown with no cached stale data to serve,
the error section now shows the human-readable lockdown explanation instead
of the raw "Sentinel marked this request as rejected." string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:12:51 +05:30
Shekhar Vaidya
e4e0e52971 Merge feat/sentinel-lockdown into main 2026-06-25 12:07:11 +05:30
Shekhar Vaidya
3141635300 feat(backend+frontend): Sentinel lockdown mode + stale UX overhaul
Backend (main.go):
- negCacheEntry gains IsSentinel bool; lockdownTTL = 90min constant
- Lockdown gate at top of handleProxy: serves stale with X-MSDL-Lockdown:1
  header or 429 {"error":"...","lockdown":true} — zero MS API calls made
- Sentinel stale path: sets 90min lockdown neg cache, adds lockdown header,
  suppresses background refresh (retrying extends the Sentinel block timer)
- Non-Sentinel stale path: unchanged (60s neg cache, bg refresh fires)
- Expose X-MSDL-Lockdown in Access-Control-Expose-Headers

Frontend (ProductDetailPage.tsx):
- isLockdown state: set from X-MSDL-Lockdown header or data.lockdown field
- Stale state hides expired download links entirely; shows CLI recommendation
  inside the card instead of sending users to look for options elsewhere
- Lockdown: red banner explaining 90min cooldown + highlighted CLI card,
  no Refresh button (it cannot help while Sentinel block is active)
- Normal stale: amber banner + highlighted CLI card + secondary Refresh button
- Outer CliHandoff suppressed when already shown inside card (stale states)

Frontend (CliHandoff.tsx):
- CLI command uses msdl.exe (primary audience is Windows users)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:07:00 +05:30
Shekhar Vaidya
3e45bfd0c7 feat(cli): architecture picker + post-fetch O/C/Enter menu
When multiple download links are returned (e.g. x32+x64 for consumer
ISOs), an architecture picker is shown before printing the URL.

After the URL is printed, an interactive menu lets the user open the
link in the default browser (O), copy it to the clipboard (C), or exit
(Enter). Menu is suppressed when stdout is not a terminal (piping).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 11:49:31 +05:30
Shekhar Vaidya
0525015ca9 fix(backend): detect Sentinel in JSON Errors field, evict session on block
Microsoft's WAF returns HTTP 200 with {"Errors":[{"Value":"Sentinel marked
this request as rejected."}]}. The existing Sentinel check only ran for
non-200 responses, so the session was never evicted — every subsequent
request reused the poisoned session and hit Sentinel again.

Add the same eviction + rateLimitError logic inside the errMap["Value"]
branch so the session is evicted regardless of the WAF's HTTP status code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:13:34 +05:30
Shekhar Vaidya
29d6b6b3f1 feat(frontend): elevate winget install to primary method on CLI page
Replaces the dim "coming soon" footnote with a featured green card at the
top of the Install section. Manual binary downloads moved to a secondary
"Other platforms" section below. Ships with the post-winget-approval release.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:38:40 +05:30
Shekhar Vaidya
1e00dcee2e fix(backend): validate telemetry fields; add method guard to /cli/version
- Add platform allowlist validation (windows, darwin, linux) and length caps for version (≤20) and product_id (≤10) in handleTelemetry to prevent unbounded Redis hash growth from attacker-controlled field values.
- Add HTTP method guard to handleCLIVersion to reject non-GET requests with 405 Method Not Allowed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:14:21 +05:30
Shekhar Vaidya
70f0da6464 docs: add CLI telemetry disclosure to README 2026-06-21 21:09:55 +05:30
Shekhar Vaidya
a21f05239a feat(cli): update check and anonymous telemetry on every run 2026-06-21 21:07:18 +05:30
Shekhar Vaidya
c047a2929f feat(cli): Version var injected via ldflags, updated --help text 2026-06-21 21:03:20 +05:30
Shekhar Vaidya
2978012120 feat(backend): include telemetry counters in /metrics response 2026-06-21 20:58:11 +05:30
Shekhar Vaidya
470abbc94b feat(backend): Redis persistence for metrics (seed + 5min flush + graceful shutdown)
- seedMetricsFromRedis: loads persisted counters into atomics on startup
- flushMetricsToRedis: writes all 12 counters to 3 Redis hashes on demand
- startMetricsFlusher: goroutine that flushes every 5 minutes
- Graceful shutdown: SIGINT/SIGTERM flushes metrics then drains HTTP in 10s
- http.ListenAndServe replaced with http.Server for Shutdown support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:48:08 +05:30
Shekhar Vaidya
cbe5ca2f9b feat(backend): POST /telemetry endpoint with Redis HINCRBY counters 2026-06-21 20:43:27 +05:30
Shekhar Vaidya
f968942763 feat(backend): GET /cli/version returns latest CLI version 2026-06-21 20:40:13 +05:30
Shekhar Vaidya
68466da7da docs: spec and implementation plan for CLI telemetry + update check
- docs/superpowers/specs/2026-06-21-cli-telemetry-update-check-design.md
- docs/superpowers/plans/2026-06-21-cli-telemetry-update-check.md

Covers: GET /cli/version, POST /telemetry (Redis HINCRBY), Redis persistence
for existing in-memory metrics (seed + 5min flush + graceful shutdown),
CLI update check goroutine, telemetry goroutine, --help update, MSDL_NO_TELEMETRY opt-out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:33:27 +05:30
Shekhar Vaidya
2a41874ad4 fix: evict session and serve stale on Sentinel WAF block
When Microsoft Sentinel rejects a request (HTTP 502 with 'Sentinel'
in body), the session was being reused on every retry — hammering the
same poisoned session and making the block worse.

Now: detect Sentinel response, evict the session from cache, and
return a rateLimitError so stale-on-failure kicks in and users get
the last cached link instead of an error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:46:16 +05:30
Shekhar Vaidya
1ca871a839 fix: support P1 (Unix timestamp) expiry param in contribute endpoint
Consumer ISO CDN URLs use P1 (Unix timestamp) instead of se (RFC3339).
extractRawExpiry now tries se first then falls back to P1, so CLI
contributions of consumer ISOs are no longer rejected.

Also refactors parseLinkExpiry to call extractRawExpiry (DRY) and
fixes stale log message referencing 'se param'.
2026-06-21 19:40:18 +05:30
Shekhar Vaidya
0bad3d1582 fix: update stale log message in contribute expiry validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:39:49 +05:30
Shekhar
84429ebaaf fix: support P1 query parameter for consumer download link expiry 2026-06-21 09:09:54 +05:30
Shekhar Vaidya
3a007662be docs: update README with CLI tool, Redis cache, /contribute endpoint
- Add CLI Tool section: install table, usage examples, crowdsourced cache opt-out
- Update project structure to include cli/ directory
- Add /contribute API reference
- Update caching section to mention Redis/Valkey L2
- Add CLI and Redis to tech stack table
- Bump winget manifests from 0.1.0 to 0.2.0 with updated SHA256

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:32:12 +05:30
Shekhar Vaidya
9c7ae6e6cb feat: Phase 4 — CLI showcase, CliHandoff, CORS fix, recently viewed clear
Merges feat/resilience-phase4 into main.

- CliHandoff component: shows install + msdl command when Microsoft WAF blocks backend
- /cli page: install guide, usage examples, contribute explanation
- CLI nav dock entry and homepage promo strip
- Recently viewed clear button
- Backend CORS fix for Tailscale/LAN subnets
2026-06-20 14:24:06 +05:30
Shekhar Vaidya
b27d7c0b9f feat: CLI page, dock entry, homepage promo, and clear recently viewed
- Add /cli page with install guide, usage examples, and contribute section
- Add CLI item to nav dock (between Products and About)
- Add homepage CLI promo strip (terminal snippet style, links to /cli)
- Add clear button to recently viewed section
- Integrate CliHandoff component into ProductDetailPage error states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:22:00 +05:30
Shekhar Vaidya
bf5dca1fe4 feat: Phase 4 web-to-CLI handoff UX
- Add CliHandoff component: shows install link + pre-filled msdl command
- Highlighted card (Recommended) shown inline on Sentinel/WAF errors
- Collapsed version shown below OfficialFallback on stale links
- Rename instructions with platform-specific filenames (msdl.exe / msdl)
- Fix backend CORS to allow local network origins (100.x, 192.168.x)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:16:43 +05:30
Shekhar Vaidya
6bc2785994 feat(cli): formatted download output and contribute message
Show the ISO filename above each link with a separator rule so the
output is easier to read. Updated contribute message to reference
MSDL Web App with URL instead of bare domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:21:19 +05:30
Shekhar Vaidya
36c875ab23 fix: use extractRawExpiry for /contribute expiry validation
parseLinkExpiry has a 1h safety floor that causes it to treat already-
expired URLs as valid. Replace with extractRawExpiry + direct se
timestamp comparison so any URL expiring < 1h out is rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 16:12:36 +05:30
Shekhar Vaidya
9a5f41290d feat: Phase 3 crowdsourced cache contribution
- Redis L2 persistent cache: initRedis() from REDIS_URL env, seed
  in-memory linkCache on startup, write-through on every fresh fetch
  and background refresh (silent fallback to memory-only if unavailable)
- POST /contribute endpoint: validates product catalog, expiry > 1h,
  Microsoft CDN host allow-list, per-IP token bucket (~5/min),
  CONTRIBUTE_SECRET header; writes to in-memory + Redis; logs all
  accepts and rejections
- CLI --no-contribute flag (also MSDL_NO_CONTRIBUTE=1 env var):
  background goroutine posts raw MS JSON to /contribute after every
  consumer fetch; best-effort 5s timeout; opt-out message to stderr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:07:35 +05:30
Shekhar
7ebf5a9560
feat: msdl CLI — direct Windows ISO downloads from user's machine (Phase 2)
feat: msdl CLI — direct Windows ISO downloads from user's machine (Phase 2)
2026-06-18 13:13:04 +05:30
Shekhar
72ad55eda8
feat: web graceful degradation for Microsoft WAF blocks (Phase 1)
feat: web graceful degradation for Microsoft WAF blocks (Phase 1)
2026-06-18 13:09:58 +05:30
Shekhar Vaidya
9acc5dbf31 feat(cli): interactive language picker for eval downloads 2026-06-18 13:08:05 +05:30
Shekhar Vaidya
4cc344e666 chore(winget): fill SHA256 for v0.1.0 Windows binary 2026-06-18 12:47:04 +05:30
Shekhar Vaidya
123643a947 feat: use short links for guide URLs in OfficialFallback; add Win 8.1 guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:29:38 +05:30
Shekhar Vaidya
3cd8528430 feat: add tech-latest guide links to official fallback component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:20:42 +05:30
Shekhar Vaidya
77056cb50f feat: Phase 1 graceful degradation for WAF-blocked links
- Backend: emit X-MSDL-Link-Status (fresh/cached/stale) and
  X-MSDL-Link-Expires headers on every /proxy response; expose
  both via Access-Control-Expose-Headers
- Frontend: read headers in handleGetLinks/handleRefreshLinks;
  show amber stale warning when backend is WAF-blocked
- Frontend: new OfficialFallback component (collapsible) with
  step-by-step guide to Microsoft's official download page,
  pre-filled with the user's selected language; shown on stale
- Plan: add Redis/Valkey persistence section to Phase 3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:13:46 +05:30
Shekhar Vaidya
5d1d402715 chore: remove product 48 (Win 8.1 Single Language) — Microsoft removed it server-side
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:59:39 +05:30
50 changed files with 3825 additions and 340 deletions

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
aur/msdl-bin/PKGBUILD text eol=lf
aur/msdl-bin/.SRCINFO text eol=lf
backend/install.sh text eol=lf

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: starkSV

View file

@ -12,6 +12,8 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
@ -20,11 +22,30 @@ jobs:
- name: Build binaries
working-directory: cli
run: |
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#cli/v}"
mkdir -p ../dist
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../dist/msdl-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ../dist/msdl-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o ../dist/msdl-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ../dist/msdl-windows-amd64.exe .
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-linux-arm64 .
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-windows-amd64.exe .
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep 'cli/v' | sed -n '2p')
if [ -n "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s" "$PREV_TAG".."${{ github.ref_name }}" -- cli/ backend/ frontend/ .github/workflows/cli-release.yml | grep -v '^- chore(winget)' | grep -v '^- chore(release): bump' | head -30)
if [ -z "$CHANGELOG" ]; then
CHANGELOG="- Maintenance release (see commit history for details)"
fi
else
CHANGELOG="- Initial release"
fi
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@ -33,6 +54,10 @@ jobs:
name: "msdl CLI ${{ github.ref_name }}"
files: dist/*
body: |
## What's New
${{ steps.changelog.outputs.changelog }}
## Install
**Windows (winget):**
@ -40,12 +65,38 @@ jobs:
winget install starkSV.msdl
```
**Linux/macOS:**
**Windows (direct download):** Download `msdl-windows-amd64.exe` below, rename to `msdl.exe`, place in a folder on your PATH.
**macOS/Linux (Homebrew):**
```bash
brew tap starkSV/msdl
brew install msdl-cli
```
(formula is `msdl-cli`, not `msdl` -- homebrew/core has an unrelated package named `msdl`; the installed command is still just `msdl`)
**Arch Linux (AUR):**
```bash
yay -S msdl-bin
# or: paru -S msdl-bin
```
[aur.archlinux.org/packages/msdl-bin](https://aur.archlinux.org/packages/msdl-bin)
**macOS/Linux (no Homebrew/AUR):**
```bash
curl -fsSL https://api.msdl.tech-latest.com/install.sh | bash
```
Auto-detects OS/arch (including Termux on Android) and installs the latest release.
**Linux/macOS (direct download):**
```bash
# Linux x86_64
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-linux-amd64 -o msdl
chmod +x msdl && sudo mv msdl /usr/local/bin/
# Linux ARM64 (also works in Termux on Android)
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-linux-arm64 -o msdl
chmod +x msdl && mv msdl $PREFIX/bin/ 2>/dev/null || sudo mv msdl /usr/local/bin/
# macOS Apple Silicon
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-darwin-arm64 -o msdl
chmod +x msdl && sudo mv msdl /usr/local/bin/
@ -55,19 +106,24 @@ jobs:
chmod +x msdl && sudo mv msdl /usr/local/bin/
```
**Windows (manual):** Download `msdl-windows-amd64.exe`, rename to `msdl.exe`, place in a directory on your PATH.
## Usage
```
msdl # fully interactive
msdl # fully interactive (includes Server/Enterprise)
msdl "windows 11 25h2" # filter products, pick language
msdl --id 3262 --lang "English (United States)" # direct, no prompts
msdl --eval server-2025 # evaluation ISOs
msdl --id 3262 --lang "English" # direct, no prompts
msdl --eval server-2025 # evaluation ISOs directly
msdl --list # list all products
```
submit-winget:
# wingetcreate is a native Windows tool (needs .NET 6 + VC++ Redistributable) --
# it is NOT published as a NuGet/dotnet-tool package, so it can only run on a
# windows-latest runner, not ubuntu-latest like the release job above.
needs: release
runs-on: windows-latest
steps:
- name: Submit winget update
if: success()
shell: bash
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
run: |
@ -75,10 +131,10 @@ jobs:
echo "WINGET_TOKEN not set, skipping winget update"
exit 0
fi
dotnet tool install --global wingetcreate
curl -L -o wingetcreate.exe https://github.com/microsoft/winget-create/releases/latest/download/wingetcreate.exe
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#cli/v}"
wingetcreate update starkSV.msdl \
./wingetcreate.exe update starkSV.msdl \
--version "$VERSION" \
--urls "https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-windows-amd64.exe" \
--submit \

View file

@ -37,8 +37,64 @@ negative cache (60s), dynamic TTL, stale-on-failure, jitter. Verified in product
- [x] **Recently viewed** — localStorage only. Shows on homepage. Consumer + eval pages both tracked. Expired state shown when link expiry known.
- ~~**File size**~~ — not feasible. Microsoft CDN does not return file size in the API response.
### Sentinel WAF resilience (ongoing)
Confirmed via three weekly `/metrics` + docker-log checkpoints (2026-07-14 → 07-21 → 07-31):
the backend's own direct link-fetch (`/proxy` → Microsoft) is **100% blocked**, permanently —
`link.ms_fetches: 0` every time, and zero even-attempted-and-failed fetches in the raw logs
(the lockdown gate short-circuits before reaching that code path). The site stays alive
entirely on cached/stale entries plus CLI-contributed links (286 accepted, 0 rejected,
confirmed 2026-07-31). `/skuinfo` and `/evallinks` are unaffected — only the download-link
endpoint is targeted.
The CLI's own residential-IP requests also see a stable ~20% Sentinel rejection rate across
all three checkpoints — not improving, not worsening. Microsoft's own API error response
literally names the system (`"Sentinel marked this request as rejected."`, `Type: 9` in
`Errors[]`), confirming it's a real, named product, not our guess. The clean structured-JSON
deny (not an HTML/JS challenge page) suggests a signature/reputation gate rather than full
interactive bot-management — TLS ClientHello fingerprinting is a plausible contributing
signal, since Go's stdlib `crypto/tls` doesn't look like any real browser, independent of IP.
- [x] **Back off Sentinel retries** — bumped `lockdownTTL` 90min → 5h (`backend/main.go`).
181 retries over 17 days, 0 successes; retrying that often was pure noise.
(fix/sentinel-lockdown-and-proxy-validation, merged)
- [x] **Validate `product_id` in `/proxy`** — was passing unknown IDs straight through to a
real Microsoft session attempt (found via a stray `product_id=2861`, never a real
product, in the logs). Now rejected with 404 before any outbound call. (same PR, merged)
- [x] **CLI TLS/HTTP2 fingerprint hardening** — swapped the CLI's transport from stdlib
`net/http` to `github.com/bogdanfinn/tls-client` (wraps `utls` with a maintained Chrome
profile). Shipped in `cli/v0.3.7` (merged, released 2026-07-31). **Checked in on
2026-08-17, 17 days post-release: inconclusive-to-negative.** Aggregate CLI
Sentinel-rejection rate is still ~21%, statistically unchanged from every pre-fix
checkpoint. Real caveat: mixed-version population (`0.3.6`: 1156 actions vs `0.3.7`: 493
in that window) means a `0.3.7`-only improvement could still be masked — `/metrics`
doesn't break the error down by CLI version, so this can't be fully isolated yet. Revisit
once `0.3.7`+ dominates usage share; if the aggregate still hasn't moved by then, treat
the fingerprint theory as disproven.
- [ ] **`/needs-warming` community page** — proposed, not built. Surfaces products currently
failing web users (active Sentinel/rate-limit lockdown, no cached/stale link available)
with a one-click CLI command to fix it. See
`docs/superpowers/specs/2026-07-13-needs-warming-design.md`.
- [ ] **Per-language SHA256 checksums on product pages** — Microsoft's own download pages
(e.g. `/software-download/windows11`) publish a static hash table (one SHA256 per
locale) for the *current* ISO build, confirmed live via direct network inspection
2026-08-18. It's not in any API response MSDL/CLI call (`GetProductDownloadLinksBySku`
never includes a hash, confirmed against Microsoft's own live page too) — it's static
HTML on the Windows-version download page, tied to the product/build, not the session.
Since it only changes when a product ID is replaced (same trigger as adding a new
catalog entry), no scraper/refresh job needed: copy the hash table by hand at the same
time a new product ID is added, per the existing "Adding a new consumer Windows release"
steps in CONTRIBUTING.md. Open questions before building: where to store it
(`products.json` field vs. separate file), which languages to cover (all 38 vs. just
the popular ones), and where to surface it (collapsible "Verify your download" section
on the product page, mirroring Microsoft's own UX; maybe CLI output too).
### Known bugs / open issues
- [ ] **`_redirects` Cloudflare Pages** — SPA fallback rule (`/* /index.html 200`) is flagged
as an infinite loop and ignored by Cloudflare Pages, potentially causing 404s on
direct navigation to non-home routes. Needs investigation and fix.
- [x] **`_redirects` Cloudflare Pages** — investigated 2026-07-21. The rule was indeed ignored
(Cloudflare's deploy log flags it as a false-positive infinite loop), but this turned out
to be a non-issue: Cloudflare Pages' own default fallback already serves `index.html` for
any unmatched path, so the explicit rule was redundant. Verified live: direct navigation to
`/about`, a dynamic route (`/product/3262`), and a genuinely invalid path all return HTTP 200
with the correct content (including React Router's own 404 page for the invalid one). Removed
`frontend/public/_redirects` entirely to stop the recurring deploy warning.

55
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,55 @@
# Contributing to MSDL
Thanks for considering a contribution. This project is a small, community-run tool — bug reports, product additions, and fixes are all welcome.
## Reporting bugs
Open a [GitHub issue](https://github.com/starkSV/windows-iso-downloader/issues). Include:
- Whether you hit it via the web app, the CLI, or both
- CLI version (`msdl --help` prints it) or which page on msdl.tech-latest.com
- The exact error message, if any
- Product/language you were trying to fetch
## Development setup
See the [README's "Running Locally" section](./README.md#running-locally) for backend and frontend setup. For the CLI:
```bash
cd cli
go run . --help
go test ./...
```
## Adding a new consumer Windows release
1. Find the product ID at `www.microsoft.com/software-download-connector/api/`
2. Add it to `frontend/public/data/products.json` (name, archs, badge, related, active)
3. Add it to `cli/catalog.go`'s `consumerProducts` slice
4. Update the product table in `README.md`
## Adding a new evaluation edition
1. Find the fwlink URL at `microsoft.com/en-us/evalcenter/download-*`
2. Add the slug and fwlink to the `evalProducts` map in `backend/main.go`
3. Add the same slug to `cli/catalog.go`'s `evalProducts` slice
4. Add the product config to `frontend/src/data/evalProducts.ts`
5. Update the eval table in `README.md`
## Making changes
- Small, one-line config fixes can go straight to `main`.
- Anything touching behavior (new feature, bug fix, refactor) should go through a feature branch and PR.
- **Test locally before opening a PR** — build and run the affected component (`go build`, `go test ./...`, `npm run dev`) rather than relying on review to catch it.
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `chore:`.
- Keep PRs focused — one fix or feature per PR, not a bundle of unrelated changes.
## CLI releases
CLI releases are cut by the maintainer via git tag (`cli/vX.Y.Z`), which triggers the GitHub Actions build for all platforms. Contributors don't need to worry about tagging or versioning — just get the fix or feature merged to `main`.
Package manager updates (winget, the [Homebrew tap](https://github.com/starkSV/homebrew-msdl), and the [`msdl-bin` AUR package](./aur/msdl-bin)) are submitted manually by the maintainer after each release, not automated in CI.
## Code style
No linter is enforced yet — match the surrounding code's style (Go: stdlib-first, minimal dependencies; frontend: existing Tailwind/component patterns). Don't add abstractions, comments, or error handling beyond what the change actually needs.

View file

@ -2,6 +2,89 @@
## Shipped
### Sentinel WAF investigation & resilience hardening — backend merged, CLI fix pending
Three weekly `/metrics` + raw docker-log checkpoints (2026-07-14 → 07-21 → 07-31) turned the
"Microsoft blocks our server IP" assumption from a one-off observation into a confirmed,
stable fact — and revealed the crowdsourcing architecture below is now doing all the real work.
**What the data actually showed:**
- `link.ms_fetches: 0` at every checkpoint — the backend's own direct link-fetch to Microsoft
has not succeeded once, ever, across 17+ days.
- Raw docker logs showed not just zero successes but zero *attempted-and-failed* fetches
either — the existing Sentinel lockdown gate short-circuits the request before it even
reaches Microsoft, serving cache/stale silently instead. 181 blocked session attempts over
the same window, all 181 unsuccessful.
- The site stayed online anyway: 286 CLI contributions accepted, 0 rejected, in that same
window — confirming the crowdsourced cache from the CLI (see architecture entry below) is
the thing actually keeping links fresh now, not the backend's own fetch.
- `/skuinfo` (125 successful Microsoft fetches) and `/evallinks` (126 cache hits, 0 failures)
are both unaffected — Sentinel targets the download-link endpoint specifically.
- The CLI's own residential-IP requests held a stable ~20% Sentinel-rejection rate across all
three checkpoints too — not improving, not worsening, despite running from IPs that
shouldn't trip ASN-based blocking. Traced the exact error text
(`"Sentinel marked this request as rejected."`) to Microsoft's own API response
(`Errors[0].Value` with `Type: 9`) — confirming "Sentinel" is Microsoft's real name for this
system, not our guess, and that it returns a clean structured JSON deny rather than an
HTML/JS challenge page (suggesting a signature/reputation gate rather than full interactive
bot management).
**Backend fixes (`fix/sentinel-lockdown-and-proxy-validation`, merged):**
- `lockdownTTL` bumped 90min → 5h. 181 retries, 0 successes — retrying that often was pure
noise against Microsoft (and our own IP's reputation) for zero return.
- `/proxy` now validates `product_id` against the existing catalog allow-list before
attempting a Microsoft session. Found via a stray `product_id=2861` (never a real product)
in the logs — the backend was burning a full outbound attempt and a Sentinel-block hit on
IDs that could never succeed anyway.
**CLI fix (`feat/cli-tls-fingerprint-hardening`, not yet merged):** swapped the CLI's HTTP
transport from stdlib `net/http` to `github.com/bogdanfinn/tls-client` (wraps `utls` with a
maintained Chrome TLS+HTTP2 fingerprint), on the theory that Go's default TLS handshake is
itself a detectable non-browser signal, independent of IP. Verified functionally correct
end-to-end (real link fetched and contributed), but that does *not* prove the theory — the
old client already succeeded ~80% of the time. Real validation is watching the
Sentinel-rejection-rate telemetry over a comparable multi-day window post-merge.
---
### CLI + resilience architecture (formerly `IMPLEMENTATION_PLAN.md`, Phases 1–5) — merged
Microsoft's Azure Sentinel WAF started blocking the backend's data-center IP (Hetzner) on
the link endpoint. Rather than fight it server-side, inverted the network footprint: the
CLI now talks to Microsoft directly from the user's own residential IP, and feeds fresh
links back into the shared backend cache.
- **`msdl` CLI (`cli/`)** — standalone Go binary, ports the full Microsoft session flow
(`cli/microsoft.go`: vlscppe permit → ov-df fingerprint → SKU lookup → signed link fetch)
so requests originate from the user's machine, not a blockable ASN. Also scrapes the
Eval Center directly for eval builds — no backend dependency either way.
- **Crowdsourced cache contribution** — after a successful CLI fetch, the raw Microsoft
response is POSTed to the backend's `POST /contribute` (validated against a product
allow-list, CDN-host allow-list, and expiry check; rate-limited per IP; gated by
`CONTRIBUTE_SECRET`). Opt out with `--no-contribute` or `MSDL_NO_CONTRIBUTE=1`.
- **Redis/Valkey L2 cache** — `REDIS_URL` env var; in-memory cache is seeded from Redis on
startup and written through on every fresh fetch, so a backend restart no longer loses
crowdsourced links. Falls back to memory-only mode silently if unset/unreachable.
Added `github.com/redis/go-redis/v9`.
- **Web graceful degradation** — `/proxy` responses carry `X-MSDL-Link-Status`
(`fresh` / `cached` / `stale`) and `X-MSDL-Link-Expires` headers. A stale (WAF-blocked
refresh) response now shows an explicit warning plus an `OfficialFallback` component
(data-driven from `products.json`, links to Microsoft's own download page) instead of
silently handing out a possibly-dead link.
- **CLI handoff UI** — `CliHandoff` component on the product page offers the install
one-liner + pre-filled `msdl` command as a guaranteed-fresh alternative when the cached
link is stale.
- **CLI telemetry** — `GET /cli/version` (update check) and `POST /telemetry` (anonymous
action/platform/version counts, no personal data) endpoints; opt out with
`--no-telemetry` / `MSDL_NO_TELEMETRY=1`. Counts surfaced in `/metrics`.
- **Catalog cleanup** — removed product `48` (Windows 8.1 Single Language), which
Microsoft had already pulled server-side (`ERROR [502]: no download links found`).
- **Distribution** — GitHub Actions release workflow (4-platform binaries on `cli/vX.Y.Z`
tags), winget manifest (`starkSV.msdl`), Homebrew tap (`msdl-cli` formula), AUR package
(`msdl-bin`), and a `curl | bash` installer for Linux/macOS without Homebrew.
---
### eval ISOs — PR #13 `feat/evalcenter-isos`
**Windows Server 2016–2025 + Windows 11 Enterprise evaluation editions**
@ -82,4 +165,3 @@ CF Worker ensures Hetzner IP is never exposed to Microsoft — rate-limit block
| Item | Notes |
|---|---|
| Per-IP / per-product rate limiter | Revisit after 1 month of production traffic data |
| `_redirects` Cloudflare Pages bug | SPA fallback rule flagged as infinite loop, may cause 404s on direct nav |

141
README.md
View file

@ -34,12 +34,91 @@ MSDL replicates the session flow that Microsoft uses to serve ISO download links
---
## CLI Tool
`msdl` is a standalone command-line tool that fetches Windows ISO links directly from your machine — no browser, no server in the middle.
```bash
# Interactive — pick product and language
msdl
# Direct with flags
msdl --id 3262 --lang "English"
# Evaluation / Server ISOs
msdl --eval server-2025
# Pipe directly into a downloader
msdl --id 3262 --lang "English" | wget -i -
msdl --id 3262 --lang "English" | xargs aria2c -x 16 -s 16
# List all available products
msdl --list
```
### Install
**Windows (winget):**
```
winget install starkSV.msdl
```
**macOS / Linux (Homebrew):**
```bash
brew tap starkSV/msdl
brew install msdl-cli
```
(the formula is named `msdl-cli`, not `msdl` — `homebrew/core` already has an unrelated package called `msdl`; the installed command is still just `msdl`)
**Arch Linux (AUR):**
```bash
yay -S msdl-bin
# or: paru -S msdl-bin
```
Package: [aur.archlinux.org/packages/msdl-bin](https://aur.archlinux.org/packages/msdl-bin). Source tracked here at [`aur/msdl-bin`](./aur/msdl-bin).
**macOS / Linux (no Homebrew/AUR):**
```bash
curl -fsSL https://api.msdl.tech-latest.com/install.sh | bash
```
Auto-detects OS/arch (including Termux on Android) and installs the latest release.
**Direct download:** Grab the latest binary from [GitHub Releases](https://github.com/starkSV/windows-iso-downloader/releases/latest) and rename it:
| Platform | File | Rename to |
|---|---|---|
| Windows | `msdl-windows-amd64.exe` | `msdl.exe` |
| macOS (Apple Silicon) | `msdl-darwin-arm64` | `msdl` |
| macOS (Intel) | `msdl-darwin-amd64` | `msdl` |
| Linux (x86_64) | `msdl-linux-amd64` | `msdl` |
| Linux (ARM64, incl. Termux on Android) | `msdl-linux-arm64` | `msdl` |
### Crowdsourced cache
By default, each successful fetch is contributed back to the web app's cache — so the next visitor gets a cached link instead of hitting Microsoft cold. Contribution is a background POST to `/contribute`. No personal data is sent — only the product ID, SKU ID, and the raw Microsoft JSON response. To opt out:
```bash
msdl --id 3262 --lang "English" --no-contribute
# or: MSDL_NO_CONTRIBUTE=1
```
### Usage telemetry
By default, each run sends an anonymous event to help us understand which products are popular and which platforms are used. No personal data is sent — only action type (`fetch`, `eval`, `list`, `interactive`), platform (`windows`, `darwin`, `linux`), CLI version, and whether the run succeeded. To opt out:
```bash
MSDL_NO_TELEMETRY=1 msdl --id 3262 --lang "English"
```
---
## Project Structure
```
windows-iso-downloader/
├── frontend/ # React 19 + TypeScript + Vite + Tailwind v4
├── backend/ # Go proxy server (recommended for production)
├── cli/ # Go CLI — direct ISO fetching from user's machine
├── cloudflare-worker/ # Optional CF Worker for distributed IP routing
└── README.md
```
@ -60,8 +139,6 @@ Browser → Backend → (CF Worker) → Microsoft API → Signed CDN URL
5. Cache result → return to browser
```
The flow mirrors [Fido.ps1](https://github.com/pbatard/Fido) by Pete Batard — the same script bundled with Rufus.
Outbound requests to Microsoft are optionally routed through a Cloudflare Worker (`cloudflare-worker/worker.js`). This distributes requests across Cloudflare's global edge IPs instead of a single server IP, preventing Microsoft's rate-limit block (error 715-123130) under high traffic. The Worker is opt-in via environment variables — omit them to go direct to Microsoft.
### Caching layer
@ -82,7 +159,7 @@ Additional resilience mechanisms:
- **Jitter** — ±5 min random offset on TTLs prevents synchronized mass-expiry spikes.
- **Cache eviction** — a background goroutine evicts expired entries every 30 minutes.
All caches are in-memory only and reset on restart. Monitor them via the [`/metrics` endpoint](#get-metricssecret).
All caches are in-memory and reset on restart. If `REDIS_URL` is set, a Redis/Valkey L2 cache supplements the in-memory layer: on startup the in-memory cache is seeded from Redis (restart-safe), and every fresh Microsoft fetch is written through to Redis. If Redis is unavailable the backend falls back to memory-only mode silently. Monitor the cache via the [`/metrics` endpoint](#get-metricssecret).
---
@ -173,6 +250,20 @@ Valid slugs: `server-2025`, `server-2022`, `server-2019`, `server-2016`, `win11-
}
```
### `POST /contribute`
Accepts a crowdsourced link from the CLI tool to warm the cache. Requires an `X-Contribute-Secret: <CONTRIBUTE_SECRET>` header.
```json
{
"product_id": "3262",
"sku_id": "0x0409",
"raw_json": { /* raw Microsoft JSON */ }
}
```
Returns `204 No Content` on acceptance; `422` on validation failure (unknown product, invalid SKU, expiry under 1 hour, disallowed download host); `400` on a malformed request body; `401` if the secret header doesn't match; `429` on rate limit (~5 req/min per IP).
### `GET /metrics?secret=<secret>`
Returns real-time cache statistics for the running instance. Auth via `?secret=` query param or `Authorization: Bearer <secret>` header. Requires `METRICS_SECRET` env var to be set.
@ -183,10 +274,22 @@ Returns real-time cache statistics for the running instance. Auth via `?secret=`
"link": { "requests": 38, "cache_hits": 35, "ms_fetches": 3, "neg_hits": 0, "stale": 0, "hit_rate": "92.1%", "cache_size": 6 },
"eval": { "requests": 12, "cache_hits": 12, "stale": 0, "hit_rate": "100.0%", "cache_size": 5 },
"neg_cache_size": 0,
"total_ms_fetches": 6
"total_ms_fetches": 6,
"sentinel_errors": 0,
"sentinel_distinct_sources_est": 0,
"telemetry": {
"actions": { "fetch": 364, "eval": 40, "interactive": 14 },
"platforms": { "windows": 176, "darwin": 70, "linux": 132 },
"products": { "3262": 109, "2618": 95 },
"results": { "success": 197, "failed": 221 },
"versions": { "0.3.6": 41 },
"errors": { "fetching download links: Sentinel marked this request as rejected.": 90 }
}
}
```
`sentinel_errors` / `sentinel_distinct_sources_est` track how often Microsoft's Sentinel WAF has blocked the backend's own outbound Microsoft calls. `telemetry` aggregates anonymous CLI usage events (see [Usage telemetry](#usage-telemetry) below) — omitted entirely if no CLI events have been recorded yet.
---
## Supported Products
@ -197,14 +300,13 @@ Returns real-time cache statistics for the running instance. Auth via `?secret=`
|---|---|---|
| Windows 11 25H2 | 3262 | x64 |
| Windows 11 25H2 | 3265 | ARM64 |
| Windows 11 25H2 (updated) | 3321 | x64 |
| Windows 11 25H2 (updated) | 3324 | ARM64 |
| Windows 11 25H2 (V2) | 3321 | x64 |
| Windows 11 25H2 (V2) | 3324 | ARM64 |
| Windows 11 24H2 | 3113 | x64 |
| Windows 11 24H2 | 3131 | ARM64 |
| Windows 10 22H2 | 2618 | x64 / x86 |
| Windows 10 22H2 Home China | 2378 | x64 |
| Windows 8.1 | 52 | x64 / x86 |
| Windows 8.1 Single Language | 48 | x64 / x86 |
### Evaluation editions (Server & Enterprise)
@ -241,10 +343,20 @@ Returns real-time cache statistics for the running instance. Auth via `?secret=`
|---|---|
| Language | Go 1.25+ |
| HTTP | `net/http` (stdlib, no framework) |
| Caching | 4-layer in-memory · `sync.RWMutex` · dynamic TTL |
| Caching | 4-layer in-memory · `sync.RWMutex` · dynamic TTL · Redis/Valkey L2 |
| Concurrency | `golang.org/x/sync/singleflight` |
| UUID | `github.com/google/uuid` |
| MDT parsing | `regexp` (stdlib) |
| Redis client | `github.com/redis/go-redis/v9` |
### CLI (`cli/`)
| | |
|---|---|
| Language | Go 1.25+ |
| Interactive UI | `github.com/charmbracelet/huh` |
| HTTP | `net/http` (stdlib) |
| Releases | GitHub Actions · cross-compiled for Windows / macOS / Linux |
---
@ -264,18 +376,7 @@ Recommended setup:
## Contributing
Pull requests welcome. To add a new consumer Windows release:
1. Find the product ID on `www.microsoft.com/software-download-connector/api/`
2. Add it to `frontend/public/data/products.json` (name, archs, badge, related, active)
3. Update the product table in this README
To add a new evaluation edition:
1. Find the fwlink URL on `microsoft.com/en-us/evalcenter/download-*`
2. Add the slug and fwlink to the `evalProducts` map in `backend/main.go`
3. Add the product config to `frontend/src/data/evalProducts.ts`
4. Update the eval table in this README
Pull requests welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for dev setup, how to add products/eval editions, and PR conventions.
---

16
aur/msdl-bin/.SRCINFO Normal file
View file

@ -0,0 +1,16 @@
pkgbase = msdl-bin
pkgdesc = Download Windows ISO files directly from Microsoft's servers
pkgver = 0.3.7
pkgrel = 1
url = https://msdl.tech-latest.com
arch = x86_64
arch = aarch64
license = MIT
provides = msdl
conflicts = msdl
source_x86_64 = msdl-bin-0.3.7-x86_64::https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv0.3.7/msdl-linux-amd64
sha256sums_x86_64 = 4714f47044814733ca2e20ae8f64327ad0b71b912d6fe6f677f18c90b3a5f1c0
source_aarch64 = msdl-bin-0.3.7-aarch64::https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv0.3.7/msdl-linux-arm64
sha256sums_aarch64 = 9059678043de3995f67ddc3083badf674b572d9bba8e8118e511f57d1f80d3eb
pkgname = msdl-bin

20
aur/msdl-bin/PKGBUILD Normal file
View file

@ -0,0 +1,20 @@
# Maintainer: starkSV <shekharvaidya2@gmail.com>
pkgname=msdl-bin
pkgver=0.3.7
pkgrel=1
pkgdesc="Download Windows ISO files directly from Microsoft's servers"
arch=('x86_64' 'aarch64')
url="https://msdl.tech-latest.com"
license=('MIT')
provides=('msdl')
conflicts=('msdl')
source_x86_64=("$pkgname-$pkgver-x86_64::https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv${pkgver}/msdl-linux-amd64")
sha256sums_x86_64=('4714f47044814733ca2e20ae8f64327ad0b71b912d6fe6f677f18c90b3a5f1c0')
source_aarch64=("$pkgname-$pkgver-aarch64::https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv${pkgver}/msdl-linux-arm64")
sha256sums_aarch64=('9059678043de3995f67ddc3083badf674b572d9bba8e8118e511f57d1f80d3eb')
package() {
install -Dm755 "$srcdir/$pkgname-$pkgver-$CARCH" "$pkgdir/usr/bin/msdl"
}

31
aur/msdl-bin/README.md Normal file
View file

@ -0,0 +1,31 @@
# msdl-bin (AUR)
> **✅ Registration reopened as of 2026-07-31:** Arch Linux disabled new AUR
> account registrations on 2026-06-15 after a malware campaign compromised
> 1,500+ AUR packages across several waves; registration is open again.
> This package (`PKGBUILD` + `.SRCINFO` below) is ready to publish — see
> "Publishing" below once an account + SSH key are set up at
> [aur.archlinux.org](https://aur.archlinux.org/register/).
`PKGBUILD` and `.SRCINFO` for the [msdl-bin](https://aur.archlinux.org/packages/msdl-bin) AUR package, tracked here so version bumps have the same history/review as the winget manifests.
`.SRCINFO` is generated with the real `makepkg` tool (via an ephemeral `archlinux` Docker container — no VM or native Arch install needed) whenever `PKGBUILD` changes:
```bash
docker run --rm -v "$(pwd)":/pkg -w /pkg archlinux:latest bash -c "
useradd -m builder && chown -R builder:builder /pkg &&
pacman -Sy --noconfirm --needed base-devel &&
su builder -c 'makepkg --printsrcinfo' > .SRCINFO
"
```
Publishing (requires an AUR account with an SSH key registered at aur.archlinux.org — this step can't be automated on someone else's behalf):
```bash
git clone ssh://aur@aur.archlinux.org/msdl-bin.git
cp PKGBUILD .SRCINFO msdl-bin/
cd msdl-bin
git add PKGBUILD .SRCINFO
git commit -m "Update to <version>"
git push
```

View file

@ -3,6 +3,9 @@ module msdl-backend-go
go 1.25.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/redis/go-redis/v9 v9.20.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
)

View file

@ -1,4 +1,10 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=

48
backend/install.sh Normal file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Installs the msdl CLI. Usage: curl -fsSL https://api.msdl.tech-latest.com/install.sh | bash
set -euo pipefail
REPO="starkSV/windows-iso-downloader"
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux) platform="linux" ;;
Darwin) platform="darwin" ;;
*)
echo "msdl: unsupported OS: $os (Windows users: winget install starkSV.msdl)" >&2
exit 1
;;
esac
case "$arch" in
x86_64|amd64) goarch="amd64" ;;
aarch64|arm64) goarch="arm64" ;;
*)
echo "msdl: unsupported architecture: $arch" >&2
exit 1
;;
esac
asset="msdl-${platform}-${goarch}"
url="https://github.com/${REPO}/releases/latest/download/${asset}"
tmp="$(mktemp)"
echo "Downloading ${asset}..."
curl -fsSL "$url" -o "$tmp"
chmod +x "$tmp"
if [ -n "${PREFIX:-}" ] && [ -d "${PREFIX}/bin" ]; then
# Termux (or similar $PREFIX-based environment) -- no sudo available or needed
mv "$tmp" "${PREFIX}/bin/msdl"
echo "Installed to ${PREFIX}/bin/msdl"
elif [ -w /usr/local/bin ]; then
mv "$tmp" /usr/local/bin/msdl
echo "Installed to /usr/local/bin/msdl"
else
sudo mv "$tmp" /usr/local/bin/msdl
echo "Installed to /usr/local/bin/msdl"
fi
echo "Run 'msdl --help' to get started."

File diff suppressed because it is too large Load diff

View file

@ -29,8 +29,12 @@ var consumerProducts = []Product{
{"3265", "Windows 11 Arm64 25H2 (26200.6584)"},
{"3266", "Windows 11 Arm64 25H2 Home China (26200.6584)"},
{"3267", "Windows 11 Arm64 25H2 Pro China (26200.6584)"},
{"3321", "Windows 11 25H2 (Updated Oct)"},
{"3324", "Windows 11 Arm64 25H2 (Updated Oct)"},
{"3321", "Windows 11 25H2 (V2)"},
{"3322", "Windows 11 25H2 Home China (V2)"},
{"3323", "Windows 11 25H2 Pro China (V2)"},
{"3324", "Windows 11 Arm64 25H2 (V2)"},
{"3325", "Windows 11 Arm64 25H2 Home China (V2)"},
{"3326", "Windows 11 Arm64 25H2 Pro China (V2)"},
}
var evalProducts = []EvalProduct{
@ -60,7 +64,7 @@ func searchProducts(query string) []Product {
name := strings.ToLower(p.Name)
match := true
for _, w := range words {
if !strings.Contains(name, w) {
if !containsWordStart(name, w) {
match = false
break
}
@ -72,6 +76,31 @@ func searchProducts(query string) []Product {
return results
}
// containsWordStart reports whether s contains substr starting at a word
// boundary: the start of s, or right after a non-alphanumeric character.
// Plain strings.Contains would let a query word match a digit fragment
// buried inside an unrelated number -- e.g. "10" matching inside a build
// number like "26100.1742" -- which made "windows 10" incorrectly return
// Windows 11 24H2 results.
func containsWordStart(s, substr string) bool {
from := 0
for {
i := strings.Index(s[from:], substr)
if i < 0 {
return false
}
pos := from + i
if pos == 0 || !isAlphanumeric(s[pos-1]) {
return true
}
from = pos + 1
}
}
func isAlphanumeric(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
}
func findEvalProduct(slug string) (EvalProduct, bool) {
for _, p := range evalProducts {
if p.Slug == slug {

View file

@ -1,6 +1,9 @@
package main
import "testing"
import (
"strings"
"testing"
)
func TestFindProductByID_found(t *testing.T) {
p, ok := findProductByID("3262")
@ -40,6 +43,35 @@ func TestSearchProducts_caseInsensitive(t *testing.T) {
}
}
func TestSearchProducts_noDigitFragmentCollision(t *testing.T) {
// "10" must not match inside the "26100.1742" build number of Windows 11
// 24H2/25H2 products -- regression test for the reported "windows 10"
// bug that returned Windows 11 results.
results := searchProducts("windows 10")
for _, p := range results {
if strings.Contains(p.Name, "11") {
t.Errorf("query %q matched %q, a Windows 11 product", "windows 10", p.Name)
}
}
if len(results) == 0 {
t.Fatal("expected windows 10 to match the actual Windows 10 products")
}
}
func TestSearchProducts_prefixSubstringStillWorks(t *testing.T) {
// "arm" should still fuzzy-match "ARM64" -- the word-boundary fix must
// only reject matches that start mid-digit-run, not all substrings.
results := searchProducts("arm")
if len(results) == 0 {
t.Fatal("expected arm to match ARM64 products")
}
for _, p := range results {
if !strings.Contains(strings.ToLower(p.Name), "arm") {
t.Errorf("unexpected match %q for query %q", p.Name, "arm")
}
}
}
func TestFindEvalProduct_found(t *testing.T) {
p, ok := findEvalProduct("server-2025")
if !ok {

View file

@ -1,3 +1,24 @@
module github.com/starkSV/msdl-cli
go 1.21
go 1.24.1
require (
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.15.1
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

48
cli/go.sum Normal file
View file

@ -0,0 +1,48 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8=
github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

114
cli/homepage.go Normal file
View file

@ -0,0 +1,114 @@
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// homepageFeaturedIDs are the consumer products shown on the bare `msdl`
// landing screen, picked from real usage telemetry rather than the full
// catalog. The full catalog is still one step away via "list".
var homepageFeaturedIDs = []string{"3262", "2618", "3321", "3113", "3265"}
// homepageEvalSlugs are the eval products shown on the landing screen.
var homepageEvalSlugs = []string{"server-2025", "win11-ent"}
type homepageKind int
const (
homepageKindProduct homepageKind = iota
homepageKindEval
homepageKindList
homepageKindSearch
)
type homepageChoice struct {
kind homepageKind
productID string
evalSlug string
query string
}
const homepageRule = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
// showHomepage prints the landing screen for a bare `msdl` invocation and
// reads the user's choice: a number, "list" for the full catalog, or free
// text to search by name.
func showHomepage(latestVersion string) (homepageChoice, error) {
fmt.Fprintln(os.Stderr, homepageRule)
fmt.Fprintln(os.Stderr, "msdl — Windows ISO Downloader")
fmt.Fprintln(os.Stderr, "Direct links from Microsoft's CDN. No browser, no Media Creation Tool.")
fmt.Fprintln(os.Stderr, homepageRule)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Popular:")
featured := make([]Product, 0, len(homepageFeaturedIDs))
for _, id := range homepageFeaturedIDs {
if p, ok := findProductByID(id); ok {
featured = append(featured, p)
}
}
for i, p := range featured {
fmt.Fprintf(os.Stderr, "%d. %s\n", i+1, p.Name)
}
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Evaluation / Enterprise:")
evalShortlist := make([]EvalProduct, 0, len(homepageEvalSlugs))
for _, slug := range homepageEvalSlugs {
if ep, ok := findEvalProduct(slug); ok {
evalShortlist = append(evalShortlist, ep)
}
}
for i, ep := range evalShortlist {
fmt.Fprintf(os.Stderr, "%d. %s\n", len(featured)+i+1, ep.Name)
}
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, `Commands: --list (full catalog) · --help`)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, homepageRule)
if latestVersion != "" && latestVersion != Version && Version != "dev" {
fmt.Fprintf(os.Stderr, "Version %s (Update Available %s) - a TechLatest Open-source Contribution\n", Version, latestVersion)
} else {
fmt.Fprintf(os.Stderr, "Version %s - a TechLatest Open-source Contribution\n", Version)
}
fmt.Fprintln(os.Stderr)
max := len(featured) + len(evalShortlist)
fmt.Fprintf(os.Stderr, `Choice [1-%d], search by name, or "list": `, max)
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return homepageChoice{}, fmt.Errorf("reading input: %w", err)
}
return homepageChoice{}, fmt.Errorf("no input")
}
return parseHomepageInput(scanner.Text(), featured, evalShortlist)
}
// parseHomepageInput interprets the raw input line from the homepage prompt.
func parseHomepageInput(input string, featured []Product, evalShortlist []EvalProduct) (homepageChoice, error) {
text := strings.TrimSpace(input)
if text == "" {
return homepageChoice{}, fmt.Errorf(`enter a choice, search term, or "list"`)
}
if strings.EqualFold(text, "list") {
return homepageChoice{kind: homepageKindList}, nil
}
if n, err := strconv.Atoi(text); err == nil {
max := len(featured) + len(evalShortlist)
if n < 1 || n > max {
return homepageChoice{}, fmt.Errorf(`enter a number between 1 and %d, a search term, or "list"`, max)
}
if n <= len(featured) {
return homepageChoice{kind: homepageKindProduct, productID: featured[n-1].ID}, nil
}
return homepageChoice{kind: homepageKindEval, evalSlug: evalShortlist[n-len(featured)-1].Slug}, nil
}
return homepageChoice{kind: homepageKindSearch, query: text}, nil
}

166
cli/homepage_test.go Normal file
View file

@ -0,0 +1,166 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func testFeatured() []Product {
return []Product{
{ID: "3262", Name: "Windows 11 25H2 (26200.6584)"},
{ID: "2618", Name: "Windows 10 22H2 v1 (19045.2965)"},
}
}
func testEvalShortlist() []EvalProduct {
return []EvalProduct{
{Slug: "server-2025", Name: "Windows Server 2025"},
{Slug: "win11-ent", Name: "Windows 11 Enterprise"},
}
}
func TestParseHomepageInput_product(t *testing.T) {
choice, err := parseHomepageInput("1", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindProduct || choice.productID != "3262" {
t.Errorf("got %+v, want product 3262", choice)
}
}
func TestParseHomepageInput_eval(t *testing.T) {
choice, err := parseHomepageInput("3", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindEval || choice.evalSlug != "server-2025" {
t.Errorf("got %+v, want eval server-2025", choice)
}
}
func TestParseHomepageInput_evalSecond(t *testing.T) {
choice, err := parseHomepageInput("4", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindEval || choice.evalSlug != "win11-ent" {
t.Errorf("got %+v, want eval win11-ent", choice)
}
}
func TestParseHomepageInput_outOfRange(t *testing.T) {
_, err := parseHomepageInput("5", testFeatured(), testEvalShortlist())
if err == nil {
t.Fatal("expected error for out-of-range choice")
}
}
func TestParseHomepageInput_listCaseInsensitive(t *testing.T) {
for _, s := range []string{"list", "List", "LIST"} {
choice, err := parseHomepageInput(s, testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error for %q: %v", s, err)
}
if choice.kind != homepageKindList {
t.Errorf("input %q: got kind %v, want list", s, choice.kind)
}
}
}
func TestParseHomepageInput_searchFallback(t *testing.T) {
choice, err := parseHomepageInput("windows 10", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindSearch || choice.query != "windows 10" {
t.Errorf("got %+v, want search query \"windows 10\"", choice)
}
}
func TestParseHomepageInput_emptyInput(t *testing.T) {
_, err := parseHomepageInput(" ", testFeatured(), testEvalShortlist())
if err == nil {
t.Fatal("expected error for empty input")
}
}
// captureHomepage runs showHomepage with stdin/stderr redirected to pipes so
// the rendered output and the parsed choice can both be inspected without a
// real terminal.
func captureHomepage(t *testing.T, stdin string, latestVersion string) (homepageChoice, string) {
t.Helper()
oldStdin, oldStderr := os.Stdin, os.Stderr
defer func() { os.Stdin, os.Stderr = oldStdin, oldStderr }()
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdin = inR
inW.WriteString(stdin)
inW.Close()
outR, outW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stderr = outW
choice, hpErr := showHomepage(latestVersion)
outW.Close()
var buf bytes.Buffer
io.Copy(&buf, outR)
if hpErr != nil {
t.Fatalf("unexpected error: %v", hpErr)
}
return choice, buf.String()
}
func TestShowHomepage_rendersFeaturedAndEval(t *testing.T) {
choice, output := captureHomepage(t, "1\n", "")
if choice.kind != homepageKindProduct || choice.productID != "3262" {
t.Errorf("got %+v, want product 3262", choice)
}
for _, want := range []string{
"1. Windows 11 25H2 (26200.6584)",
"2. Windows 10 22H2 v1 (19045.2965)",
"6. Windows Server 2025",
"7. Windows 11 Enterprise",
`Commands: --list (full catalog) · --help`,
`Choice [1-7], search by name, or "list":`,
} {
if !strings.Contains(output, want) {
t.Errorf("output missing %q, got:\n%s", want, output)
}
}
}
func TestShowHomepage_updateAvailableFooter(t *testing.T) {
oldVersion := Version
Version = "0.3.3"
defer func() { Version = oldVersion }()
_, output := captureHomepage(t, "1\n", "0.3.4")
if !strings.Contains(output, "Version 0.3.3 (Update Available 0.3.4)") {
t.Errorf("output missing update-available footer, got:\n%s", output)
}
}
func TestShowHomepage_noUpdateFooter(t *testing.T) {
oldVersion := Version
Version = "0.3.4"
defer func() { Version = oldVersion }()
_, output := captureHomepage(t, "1\n", "0.3.4")
if strings.Contains(output, "Update Available") {
t.Errorf("footer should not mention an update when already current, got:\n%s", output)
}
if !strings.Contains(output, "Version 0.3.4 - a TechLatest Open-source Contribution") {
t.Errorf("output missing plain version footer, got:\n%s", output)
}
}

View file

@ -1,12 +1,163 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
)
// Version is injected at build time via -ldflags "-X main.Version=0.3.0".
// Falls back to "dev" for local builds.
var Version = "dev"
// contributeURL is the MSDL backend endpoint that warms the shared link cache.
// Override with MSDL_API_URL env var, e.g. export MSDL_API_URL=http://localhost:3002
const contributeSecret = "msdl-contribute-2026"
// urlFilename extracts the bare filename from a URL (strips query string and path).
func urlFilename(rawURL string) string {
s := rawURL
if i := strings.LastIndex(s, "?"); i > 0 {
s = s[:i]
}
if i := strings.LastIndex(s, "/"); i >= 0 {
return s[i+1:]
}
return s
}
// apiBaseURL returns the backend base URL, with MSDL_API_URL override.
func apiBaseURL() string {
if u := os.Getenv("MSDL_API_URL"); u != "" {
return strings.TrimRight(u, "/")
}
return "https://api.msdl.tech-latest.com"
}
func contributeURL() string {
return apiBaseURL() + "/contribute"
}
// fetchLatestVersion fetches /cli/version and returns the latest version
// string, or "" on any error or after a 500ms timeout.
func fetchLatestVersion() string {
ch := make(chan string, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL()+"/cli/version", nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var body struct {
Latest string `json:"latest"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return
}
ch <- body.Latest
}()
select {
case latest := <-ch:
return latest
case <-time.After(500 * time.Millisecond):
return ""
}
}
// printUpdateNotice prints a notice if latest is newer than the running
// binary's version.
func printUpdateNotice(latest string) {
if latest != "" && latest != Version && Version != "dev" {
fmt.Fprintf(os.Stderr, "\n A new version of msdl is available: v%s\n", latest)
fmt.Fprintf(os.Stderr, " Download: https://github.com/starkSV/windows-iso-downloader/releases/latest\n\n")
}
}
type cliTelemetryPayload struct {
Action string `json:"action"`
ProductID string `json:"product_id,omitempty"`
EvalSlug string `json:"eval_slug,omitempty"`
Platform string `json:"platform"`
Version string `json:"version"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// truncateError caps an error string for telemetry (avoids leaking huge bodies,
// keeps Redis field values small). Keeps both head and tail: the head usually
// names what failed (e.g. "language X not available for Y"), while wrapped
// errors put the root cause last (e.g. "fetching X: <root cause>") — a long
// middle section (URLs, option lists) is the least useful part to keep.
func truncateError(err error) string {
if err == nil {
return ""
}
s := err.Error()
const max = 150
if len(s) <= max {
return s
}
const headLen = 60
const sep = " ... "
tailLen := max - headLen - len(sep)
return s[:headLen] + sep + s[len(s)-tailLen:]
}
// sendTelemetry posts a single telemetry event. Fire-and-forget: all errors
// are silently ignored so telemetry never affects the user experience.
func sendTelemetry(p cliTelemetryPayload) {
body, err := json.Marshal(p)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL()+"/telemetry", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
func postContribute(productID, skuID string, rawJSON []byte) {
payload := struct {
ProductID string `json:"product_id"`
SkuID string `json:"sku_id"`
RawJSON json.RawMessage `json:"raw_json"`
}{ProductID: productID, SkuID: skuID, RawJSON: rawJSON}
body, _ := json.Marshal(payload)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, contributeURL(), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Contribute-Secret", contributeSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
@ -23,23 +174,50 @@ func run(args []string) error {
Usage:
msdl [search terms] interactive: filter + pick product, pick language
msdl --id 3262 skip product picker
msdl --id 3262 --lang "English (United States)" no prompts, print URL directly
msdl --id 3262 --lang "English" no prompts, print URL directly
(language names vary by product — omit --lang
or check the error's "available" list to see them)
msdl --eval [slug] evaluation ISOs (server-2025, win11-ent, ...)
msdl --list list all products and exit
Flags:`)
fs.PrintDefaults()
fmt.Fprintln(os.Stderr, `
Environment:
MSDL_NO_TELEMETRY=1 Disable anonymous usage reporting and update checks
MSDL_NO_CONTRIBUTE=1 Disable cache contribution
MSDL_API_URL=<url> Override backend URL (default: https://api.msdl.tech-latest.com)
More info: https://msdl.tech-latest.com/cli`)
}
productID := fs.String("id", "", "consumer product ID (skips product picker)")
langFlag := fs.String("lang", "", `language name, e.g. "English (United States)"`)
langFlag := fs.String("lang", "", `language name, e.g. "English"`)
evalMode := fs.Bool("eval", false, "fetch evaluation ISOs")
listMode := fs.Bool("list", false, "list all products and exit")
noContributeFlag := fs.Bool("no-contribute", false, "skip sharing the link with the msdl.tech cache")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil // fs.Usage already printed
}
return err
}
query := strings.Join(fs.Args(), " ")
noContribute := *noContributeFlag || os.Getenv("MSDL_NO_CONTRIBUTE") == "1"
noTelemetry := os.Getenv("MSDL_NO_TELEMETRY") == "1"
isHomepage := *productID == "" && !*evalMode && !*listMode && query == "" && isTerminal()
// Update check — blocks up to 500ms, then continues regardless. The
// homepage screen folds this into its own footer instead of printing a
// separate notice.
latest := ""
if !noTelemetry {
latest = fetchLatestVersion()
if !isHomepage {
printUpdateNotice(latest)
}
}
if *listMode {
fmt.Fprintln(os.Stderr, "Consumer products:")
@ -50,16 +228,83 @@ Flags:`)
for _, p := range evalProducts {
fmt.Fprintf(os.Stderr, " %-20s %s\n", p.Slug, p.Name)
}
if !noTelemetry {
go sendTelemetry(cliTelemetryPayload{
Action: "list",
Platform: runtime.GOOS,
Version: Version,
Success: true,
})
}
return nil
}
if *evalMode {
return runEval(query)
telAction := "interactive"
telProductID := ""
telEvalSlug := ""
var err error
switch {
case *evalMode:
telAction = "eval"
telEvalSlug = query
err = runEval(query)
case *productID != "":
telAction = "fetch"
telProductID = *productID
err = runConsumer(*productID, query, *langFlag, noContribute)
case isHomepage:
choice, hpErr := showHomepage(latest)
if hpErr != nil {
return hpErr
}
switch choice.kind {
case homepageKindProduct:
telAction = "fetch"
telProductID = choice.productID
err = runConsumer(choice.productID, "", *langFlag, noContribute)
case homepageKindEval:
telAction = "eval"
telEvalSlug = choice.evalSlug
err = runEval(choice.evalSlug)
case homepageKindSearch:
telAction = "fetch"
err = runConsumer("", choice.query, *langFlag, noContribute)
case homepageKindList:
isEval, product, ep, pickErr := pickCombined()
if pickErr != nil {
return pickErr
}
if isEval {
telAction = "eval"
telEvalSlug = ep.Slug
err = runEval(ep.Slug)
} else {
telAction = "fetch"
telProductID = product.ID
err = runConsumer(product.ID, "", *langFlag, noContribute)
}
}
default:
err = runConsumer(*productID, query, *langFlag, noContribute)
}
return runConsumer(*productID, query, *langFlag)
if !noTelemetry {
sendTelemetry(cliTelemetryPayload{
Action: telAction,
ProductID: telProductID,
EvalSlug: telEvalSlug,
Platform: runtime.GOOS,
Version: Version,
Success: err == nil,
Error: truncateError(err),
})
}
return err
}
func runConsumer(productID, query, langName string) error {
func runConsumer(productID, query, langName string, noContribute bool) error {
var product Product
if productID != "" {
@ -105,7 +350,11 @@ func runConsumer(productID, query, langName string) error {
}
}
if lang.ID == "" {
return fmt.Errorf("language %q not available for %s", langName, product.Name)
names := make([]string, len(langs))
for i, l := range langs {
names[i] = l.Language
}
return fmt.Errorf("language %q not available for %s — available: %s", langName, product.Name, strings.Join(names, ", "))
}
} else {
lang, err = pickLanguage(langs)
@ -115,13 +364,65 @@ func runConsumer(productID, query, langName string) error {
}
fmt.Fprintf(os.Stderr, "Fetching download link...\n")
links, err := fetchDownloadLinks(client, sessionID, product.ID, lang.ID)
links, rawJSON, err := fetchDownloadLinks(client, sessionID, product.ID, lang.ID)
if err != nil {
return fmt.Errorf("fetching download links: %w", err)
}
for _, link := range links {
// Fire contribute in background before printing so the goroutine starts immediately.
var wg sync.WaitGroup
if !noContribute {
wg.Add(1)
go func() {
defer wg.Done()
postContribute(product.ID, lang.ID, rawJSON)
}()
}
fmt.Fprintln(os.Stderr, "")
var selectedURI string
if isTerminal() && len(links) > 1 {
link, err := pickArchitecture(links)
if err != nil {
return err
}
name := urlFilename(link.URI)
fmt.Fprintf(os.Stderr, "\n %s\n", name)
fmt.Fprintf(os.Stderr, " %s\n\n", strings.Repeat("─", len(name)))
fmt.Println(link.URI)
selectedURI = link.URI
} else {
for _, link := range links {
name := urlFilename(link.URI)
fmt.Fprintf(os.Stderr, " %s\n", name)
fmt.Fprintf(os.Stderr, " %s\n", strings.Repeat("─", len(name)))
fmt.Println(link.URI) // stdout — stays clean for piping
fmt.Fprintln(os.Stderr, "")
}
if len(links) == 1 {
selectedURI = links[0].URI
}
}
if !noContribute {
fmt.Fprintln(os.Stderr, " ✓ Shared with MSDL Web App (https://msdl.tech-latest.com/)")
fmt.Fprintln(os.Stderr, " Opt out: --no-contribute")
fmt.Fprintln(os.Stderr, "")
}
if isTerminal() && selectedURI != "" {
postFetchMenu(selectedURI)
}
if !noContribute {
// Wait up to 5s for the contribution to complete before process exit.
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
}
}
return nil
}
@ -152,12 +453,33 @@ func runEval(slug string) error {
return fmt.Errorf("no eval links returned for %s", ep.Slug)
}
for _, link := range links {
if link.Arch != "" {
fmt.Printf("%s\t%s\n", link.Arch, link.URL)
} else {
fmt.Println(link.URL)
if len(links) == 1 {
fmt.Println(links[0].URL)
if isTerminal() {
postFetchMenu(links[0].URL)
}
return nil
}
fmt.Fprintln(os.Stderr, "\nSelect a download:")
for i, link := range links {
label := link.Lang
if link.Arch != "" && link.Lang != "" {
label = fmt.Sprintf("%-6s %s", link.Lang, link.Arch)
} else if link.Arch != "" {
label = link.Arch
}
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, label)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, len(links))
if err != nil {
return err
}
selected := links[n-1].URL
fmt.Println(selected)
if isTerminal() {
postFetchMenu(selected)
}
return nil
}

View file

@ -7,17 +7,25 @@ import (
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tls_client "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
// msTLSProfile is the browser TLS/HTTP2 fingerprint the CLI presents to Microsoft.
// Kept in step with msUA's claimed Chrome version -- a mismatched UA vs. TLS
// fingerprint is itself a detectable signal, so if one changes, change both.
var msTLSProfile = profiles.Chrome_133
const (
msUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
msUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
msProfile = "606624d44113"
msLocale = "en-US"
msOrgID = "y6jn8c31"
@ -47,37 +55,6 @@ type EvalLink struct {
URL string
}
type simpleCookieJar struct {
mu sync.Mutex
cookies []*http.Cookie
}
func (j *simpleCookieJar) SetCookies(_ *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
for _, c := range cookies {
found := false
for i, existing := range j.cookies {
if existing.Name == c.Name {
j.cookies[i] = c
found = true
break
}
}
if !found {
j.cookies = append(j.cookies, c)
}
}
}
func (j *simpleCookieJar) Cookies(_ *url.URL) []*http.Cookie {
j.mu.Lock()
defer j.mu.Unlock()
out := make([]*http.Cookie, len(j.cookies))
copy(out, j.cookies)
return out
}
func newSessionID() string {
b := make([]byte, 16)
rand.Read(b)
@ -116,10 +93,16 @@ func mapDownloadType(n int) string {
}
}
func newSession() (*http.Client, string) {
func newSession() (tls_client.HttpClient, string) {
sessionID := newSessionID()
jar := &simpleCookieJar{}
client := &http.Client{Timeout: 15 * time.Second, Jar: jar}
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
tls_client.WithTimeoutSeconds(15),
tls_client.WithClientProfile(msTLSProfile),
tls_client.WithCookieJar(tls_client.NewCookieJar()),
)
if err != nil {
return nil, sessionID
}
q1 := url.Values{}
q1.Set("org_id", msOrgID)
@ -158,7 +141,7 @@ func newSession() (*http.Client, string) {
return client, sessionID
}
func msGet(client *http.Client, reqURL, productID string) ([]byte, error) {
func msGet(client tls_client.HttpClient, reqURL, productID string) ([]byte, error) {
req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("User-Agent", msUA)
req.Header.Set("Referer", referer(productID))
@ -249,7 +232,7 @@ func parseDownloadLinks(raw []byte) ([]DownloadLink, error) {
return links, nil
}
func fetchLanguages(client *http.Client, sessionID, productID string) ([]Language, error) {
func fetchLanguages(client tls_client.HttpClient, sessionID, productID string) ([]Language, error) {
q := url.Values{}
q.Set("profile", msProfile)
q.Set("productEditionId", productID)
@ -266,7 +249,8 @@ func fetchLanguages(client *http.Client, sessionID, productID string) ([]Languag
return parseSkuInfo(raw)
}
func fetchDownloadLinks(client *http.Client, sessionID, productID, skuID string) ([]DownloadLink, error) {
// fetchDownloadLinks returns parsed links and the raw Microsoft JSON (for cache contribution).
func fetchDownloadLinks(client tls_client.HttpClient, sessionID, productID, skuID string) ([]DownloadLink, []byte, error) {
wq := url.Values{}
wq.Set("profile", msProfile)
wq.Set("productEditionId", productID)
@ -289,9 +273,13 @@ func fetchDownloadLinks(client *http.Client, sessionID, productID, skuID string)
"https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?"+q.Encode(),
productID)
if err != nil {
return nil, err
return nil, nil, err
}
return parseDownloadLinks(raw)
links, err := parseDownloadLinks(raw)
if err != nil {
return nil, nil, err
}
return links, raw, nil
}
func extractFwlinks(pageHTML string) []string {
@ -330,7 +318,13 @@ func detectLang(rawURL string) string {
}
func fetchEvalLinks(evalURL string) ([]EvalLink, error) {
client := &http.Client{Timeout: 20 * time.Second}
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
tls_client.WithTimeoutSeconds(20),
tls_client.WithClientProfile(msTLSProfile),
)
if err != nil {
return nil, fmt.Errorf("creating http client: %w", err)
}
req, _ := http.NewRequest("GET", evalURL, nil)
req.Header.Set("User-Agent", msUA)
req.Header.Set("Accept", "text/html,application/xhtml+xml")

View file

@ -5,6 +5,8 @@ import (
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
)
@ -76,3 +78,121 @@ func pickEvalProduct(products []EvalProduct) (EvalProduct, error) {
}
return products[n-1], nil
}
func isTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
func pickCombined() (isEval bool, p Product, ep EvalProduct, err error) {
total := len(consumerProducts) + len(evalProducts)
fmt.Fprintln(os.Stderr, "\nSelect a product:")
for i, prod := range consumerProducts {
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, prod.Name)
}
fmt.Fprintln(os.Stderr, "\n ── Evaluation / Enterprise ──")
offset := len(consumerProducts)
for i, e := range evalProducts {
fmt.Fprintf(os.Stderr, " %2d. %s\n", offset+i+1, e.Name)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, total)
if err != nil {
return false, Product{}, EvalProduct{}, err
}
if n <= offset {
return false, consumerProducts[n-1], EvalProduct{}, nil
}
return true, Product{}, evalProducts[n-offset-1], nil
}
func pickArchitecture(links []DownloadLink) (DownloadLink, error) {
fmt.Fprintln(os.Stderr, "Select architecture:")
for i, l := range links {
label := l.Architecture
if label == "" {
label = fmt.Sprintf("link %d", i+1)
}
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, label)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, len(links))
if err != nil {
return DownloadLink{}, err
}
return links[n-1], nil
}
func openInBrowser(rawURL string) error {
switch runtime.GOOS {
case "windows":
// Write URL to a temp .url file (Windows Internet Shortcut format).
// The URL never touches cmd.exe — only the temp filename does, which
// contains no special characters.
f, err := os.CreateTemp("", "msdl-*.url")
if err != nil {
return err
}
name := f.Name()
defer os.Remove(name)
if _, err := fmt.Fprintf(f, "[InternetShortcut]\r\nURL=%s\r\n", rawURL); err != nil {
f.Close()
return err
}
f.Close()
cmdExe := os.Getenv("COMSPEC")
if cmdExe == "" {
cmdExe = `C:\Windows\System32\cmd.exe`
}
return exec.Command(cmdExe, "/c", "start", "", name).Run()
case "darwin":
return exec.Command("open", rawURL).Run()
default:
return exec.Command("xdg-open", rawURL).Run()
}
}
func copyToClipboard(text string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
clipExe := `C:\Windows\System32\clip.exe`
if sr := os.Getenv("SystemRoot"); sr != "" {
clipExe = sr + `\System32\clip.exe`
}
cmd = exec.Command(clipExe)
case "darwin":
cmd = exec.Command("pbcopy")
default:
if _, err := exec.LookPath("xclip"); err == nil {
cmd = exec.Command("xclip", "-selection", "clipboard")
} else {
cmd = exec.Command("xsel", "--clipboard", "--input")
}
}
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
func postFetchMenu(uri string) {
fmt.Fprint(os.Stderr, "\n [O] Open in browser [C] Copy to clipboard [Enter] Exit\n > ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return
}
switch strings.ToLower(strings.TrimSpace(scanner.Text())) {
case "o":
if err := openInBrowser(uri); err != nil {
fmt.Fprintln(os.Stderr, " Could not open browser:", err)
}
case "c":
if err := copyToClipboard(uri); err != nil {
fmt.Fprintln(os.Stderr, " Could not copy to clipboard:", err)
} else {
fmt.Fprintln(os.Stderr, " ✓ Copied to clipboard")
}
}
}

View file

@ -0,0 +1,184 @@
# Spec: "Needs Warming" Community Help Page
**Status:** Proposed
**Date:** 2026-07-13
**Author:** Shekhar + Grok (original idea), refined against live codebase by Claude
**Related:** Crowdsourced cache warming (`/contribute`), Sentinel lockdown resilience, CLI contribution flow
---
## 1. Goal
Give visible, honest signal for products currently failing web users because Microsoft's Sentinel WAF (or a rate limit) has blocked the backend and no cached link is available to fall back on. Point people at the CLI as the fix, and let a successful fetch/contribution clear the item automatically.
This is not a new mechanism bolted onto the project — it's a public window into resilience state (`negCache` / `linkCache`) that already exists and already drives the per-product "blocked, use CLI" banner. This just makes that signal visible in aggregate, with a stronger call to action.
**Change from the original draft:** a dedicated page, not a homepage section. Original draft (written by Grok from the GitHub repo alone, without runtime visibility into cache state) proposed a parallel tracking system; this version derives the list from state that already exists, which is both less code and immune to drift between two copies of "is this broken."
---
## 2. What Actually Needs Tracking (verified against `backend/main.go`)
`handleProxy` (main.go:1189-1404) has exactly three outcomes when a product/SKU is under an active Sentinel or rate-limit block:
| Outcome | Code path | User experience | Needs warming? |
|---|---|---|---|
| `serving cached` | negCache active, `linkCache` entry still within its own TTL (e.g. just contributed) | Gets a working link | **No** |
| `serving stale` | negCache active, `linkCache` entry expired but still returned | Gets a (possibly near-expired) link | **No** |
| `no stale available` | negCache active, nothing in `linkCache` at all | Gets a 429, no link | **Yes** — this is the actual failure |
So "needs warming" = **an active `negCache` entry with no corresponding valid-or-stale `linkCache` entry** — computed on read, not tracked as separate state. No new manager struct, no `Record()`/`Remove()` calls to keep in sync, no new Redis schema.
**Known gap, scoped out of MVP:** a fetch that fails with a non-rate-limit error (network blip, malformed response) and has no stale cache to fall back on returns an error directly (main.go:1377) without ever touching `negCache`. In practice this hasn't been the dominant failure mode this month — Sentinel/rate-limit blocks have been — so the MVP doesn't cover it. If it turns out to matter, closing the gap is a two-line addition (write a `negCacheEntry` with `IsSentinel: false` at that call site) rather than a redesign.
---
## 3. Data Needed That Doesn't Exist Yet
Two small additions, both minimal:
1. **A request counter per locked product/SKU.** `negCache`/`linkCache` don't track "how many times did this get hit while broken" — add a small `map[string]int` (or extend `negCacheEntry` with a counter field) incremented at the existing `"no stale available"` log call sites.
2. **Consumer product display names on the backend.** `validContributeProducts` (main.go:476) is `map[string]bool` — just an ID allow-list, no names. Eval products already have names (`{Name, EvalURL}` map at main.go:207-211). Converting `validContributeProducts` to `map[string]string` (ID → Name) serves both its existing validation role and this new lookup need, with no behavior change to `/contribute`.
Language name hydration for consumer products reuses the existing `skuCache` (already populated per-product from the normal SKU-fetch flow) — look up the SKU by ID within that product's cached SKU list to get `Language`/`LocalizedLanguage`. No new structure needed; if the product isn't in `skuCache` yet, just omit the language field rather than blocking the response.
---
## 4. New Endpoint
### `GET /needs-warming`
Computed on each request by iterating `negCache`, filtering to entries where `IsSentinel` (or rate-limited) is still active **and** no valid `linkCache`/eval-cache entry exists for that key. No background job, no persistence — it's a live view.
**Query params:** `limit` (default 10, max 50)
**Response:**
```json
{
"items": [
{
"product_id": "3262",
"sku_id": "0x0409",
"product_name": "Windows 11 25H2",
"language": "English (United States)",
"is_eval": false,
"reason": "waf_blocked",
"last_seen": "2026-07-13T17:42:00Z",
"request_count": 47,
"cli_command": "msdl --id 3262 --lang \"English (United States)\""
}
],
"total": 3
}
```
- Public, unauthenticated, rate-limited (30 req/min per IP — matches the existing pattern used by `/contribute` at 5/min and `/telemetry` at 10/min)
- Sorted by `request_count` descending (surfaces the highest-impact items first, more useful than recency alone)
- Eval products included (`is_eval: true`, `cli_command` uses `msdl --eval <slug>` instead of `--id`)
---
## 5. Backend Implementation Outline
No new package-level state beyond the two additions in §3. The handler:
```go
func handleNeedsWarming(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !needsWarmingRL.allow(clientIP(r)) {
respondJSONError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
limit := parseLimitParam(r, 10, 50)
negCacheMu.RLock()
defer negCacheMu.RUnlock()
linkCacheMu.RLock()
defer linkCacheMu.RUnlock()
var items []needsWarmingItem
for key, neg := range negCache {
if time.Now().After(neg.ExpiresAt) {
continue // lockdown/rate-limit window itself already expired
}
if _, hasValidLink := resolveValidLink(key, linkCache); hasValidLink {
continue // already resolved via cached/stale link -- not a failure state
}
items = append(items, buildNeedsWarmingItem(key, neg))
}
sort.Slice(items, func(i, j int) bool { return items[i].RequestCount > items[j].RequestCount })
if len(items) > limit {
items = items[:limit]
}
json.NewEncoder(w).Encode(map[string]interface{}{"items": items, "total": len(items)})
}
```
Integration points:
- Increment the new request counter at the existing `"lockdown active, no stale available"` and first-time-Sentinel/rate-limit-with-no-stale log sites (main.go:1225, ~1367)
- No changes needed to `/contribute` or the fresh-fetch success path — an item stops appearing automatically once `linkCache` has a valid entry, which those paths already populate
---
## 6. Frontend: Dedicated Page
**Route:** `/needs-warming` (or `/community` — bikeshed-able, not load-bearing)
Linked from the footer and from the per-product "blocked" banner (`CliHandoff.tsx`'s `highlight` variant) — "See what else needs help →" — so people already primed to help via the CLI can see the fuller picture instead of just their one product.
**Empty state:** hide the page's list section entirely (or show a short "Everything's healthy right now" line) — no "0 items" placeholder box. Most visits will likely find this empty, which is a good sign, not a gap to fill with UI.
Each card:
- Product name + language (or eval product name)
- Request count as a small badge (social proof — "47 people hit this")
- One-click "Copy CLI command" button (same interaction pattern as the existing `CliHandoff` component — reuse its copy-button styling)
- No per-item "reason" jargon exposed in the UI (WAF/rate-limit distinction is backend detail); just "temporarily unavailable"
Tone: matches the project's existing calm, transparent voice (README already explains the caching layer and Sentinel lockdown openly) — helpful, not alarmist.
---
## 7. CLI Impact
None required for MVP. Optional future addition: `msdl --needs-warming` prints the same list from the terminal and offers to fetch+contribute the top item directly — natural fit for the homepage-screen work already shipped (`cli/homepage.go`), but a separate phase.
---
## 8. Privacy & Safety
- Only ever stores/exposes `product_id`, `sku_id`/slug, reason, counters, timestamps — no IPs, no user identifiers (matches the existing telemetry/contribute philosophy already documented in README)
- Rate-limited public endpoint, same pattern as existing public endpoints
- Nothing new to abuse: the list is derived from state Microsoft's own WAF already put the backend into, not something a client can inject
---
## 9. Implementation Phases
| Phase | Description | Effort |
|---|---|---|
| MVP | Request counter + product-name map conversion + `/needs-warming` handler + dedicated page | Small — most of the state already exists |
| 2 | Link from per-product blocked banner; polish empty/loading states | Small |
| 3 | Close the non-rate-limit fetch-error gap (§2) if it turns out to matter in practice | Small, only if needed |
| 4 | `msdl --needs-warming` CLI command | Small |
(Dropped the original draft's "Redis-backed persistence" phase — this is inherently live/transient state mirroring `negCache`'s own in-memory, reset-on-restart behavior; persisting it would add a Redis key namespace for no real benefit.)
---
## 10. Open Questions Resolved
1. **Eval products included?** Yes.
2. **Max age before auto-removal?** No separate TTL — inherits the underlying `negCache` entry's own expiry (60s rate-limit / 90min Sentinel), since the list is computed live rather than tracked separately.
3. **Empty state?** Hide the list, don't show a "0 items" box.
4. **Tone?** Helpful/neutral, matching existing project voice.
---
## 11. Success Metrics (Future)
- Contributions triggered from this page specifically (could tag `/contribute` calls with a `source=needs-warming` query param to measure this)
- Reduction in time-to-resolution for locked-down products
- CLI download/usage bump correlated with items appearing on the page

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
# --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Serve stage ---
FROM nginx:alpine
WORKDIR /usr/share/nginx/html/
COPY --from=build /app/dist .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf

10
frontend/nginx.conf Normal file
View file

@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -1 +0,0 @@
/* /index.html 200

View file

@ -1,15 +1,4 @@
{
"48": {
"name": "Windows 8.1 Single Language (9600.17415)",
"badge": "LEGACY",
"archs": [
"x64",
"x86"
],
"related": [
"52"
]
},
"52": {
"name": "Windows 8.1 (9600.17415)",
"badge": "LEGACY",
@ -18,13 +7,12 @@
"x86"
],
"related": [
"48",
"2618"
]
},
"2378": {
"name": "Windows 10 22H2 Home China (19045.2006)",
"badge": "EOL SOON",
"badge": "EOL",
"archs": [
"x64"
],
@ -34,7 +22,7 @@
},
"2618": {
"name": "Windows 10 22H2 v1 (19045.2965)",
"badge": "EOL SOON",
"badge": "EOL",
"archs": [
"x64",
"x86"
@ -181,7 +169,7 @@
]
},
"3321": {
"name": "Windows 11 25H2 (Updated Oct)",
"name": "Windows 11 25H2 (V2)",
"badge": "LATEST",
"archs": [
"x64"
@ -192,8 +180,30 @@
"3113"
]
},
"3322": {
"name": "Windows 11 25H2 Home China (V2)",
"badge": "LATEST",
"archs": [
"x64"
],
"related": [
"3321",
"3323"
]
},
"3323": {
"name": "Windows 11 25H2 Pro China (V2)",
"badge": "LATEST",
"archs": [
"x64"
],
"related": [
"3321",
"3322"
]
},
"3324": {
"name": "Windows 11 Arm64 25H2 (Updated Oct)",
"name": "Windows 11 Arm64 25H2 (V2)",
"badge": "LATEST",
"archs": [
"ARM64"
@ -203,5 +213,27 @@
"3262",
"3265"
]
},
"3325": {
"name": "Windows 11 Arm64 25H2 Home China (V2)",
"badge": "LATEST",
"archs": [
"ARM64"
],
"related": [
"3324",
"3326"
]
},
"3326": {
"name": "Windows 11 Arm64 25H2 Pro China (V2)",
"badge": "LATEST",
"archs": [
"ARM64"
],
"related": [
"3324",
"3325"
]
}
}

View file

@ -46,7 +46,6 @@
<url><loc>https://msdl.tech-latest.com/product/win11-ent</loc><lastmod>2026-05-18</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<!-- Product Pages -->
<url><loc>https://msdl.tech-latest.com/product/48</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/52</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/2378</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/2618</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
@ -63,5 +62,9 @@
<url><loc>https://msdl.tech-latest.com/product/3266</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3267</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3321</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3322</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3323</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3324</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3325</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
<url><loc>https://msdl.tech-latest.com/product/3326</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
</urlset>

View file

@ -10,6 +10,7 @@ import PrivacyPolicyPage from './pages/PrivacyPolicyPage'
import DisclaimerPage from './pages/DisclaimerPage'
import EvalPage from './pages/EvalPage'
import EvalDetailPage from './pages/EvalDetailPage'
import CliPage from './pages/CliPage'
import NotFoundPage from './pages/NotFoundPage'
import { evalSlugSet } from './data/evalProducts'
import { useParams } from 'react-router-dom'
@ -31,6 +32,7 @@ export default function App() {
<Route path="/products" element={<ProductsPage />} />
<Route path="/product/:productId" element={<ProductRouter />} />
<Route path="/eval" element={<EvalPage />} />
<Route path="/cli" element={<CliPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicyPage />} />
<Route path="/disclaimer" element={<DisclaimerPage />} />

View file

@ -0,0 +1,188 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Terminal, ChevronRight, Copy, Check, ExternalLink } from 'lucide-react'
import { toast } from 'sonner'
interface Props {
productId: string
langName?: string
langDisplay?: string
defaultOpen?: boolean
highlight?: boolean
}
const RELEASES_URL = 'https://minxl.ink/msdl-github-release'
function InstallSteps() {
const [copied, setCopied] = useState(false)
const [brewCopied, setBrewCopied] = useState(false)
const [curlCopied, setCurlCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText('winget install starkSV.msdl')
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
function handleBrewCopy() {
navigator.clipboard.writeText('brew tap starkSV/msdl && brew install msdl-cli')
setBrewCopied(true)
setTimeout(() => setBrewCopied(false), 2000)
}
function handleCurlCopy() {
navigator.clipboard.writeText('curl -fsSL https://api.msdl.tech-latest.com/install.sh | bash')
setCurlCopied(true)
setTimeout(() => setCurlCopied(false), 2000)
}
return (
<div className="space-y-2">
<p className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600">1. Install</p>
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono font-semibold tracking-widest uppercase text-emerald-500">Windows · winget</span>
<span className="text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/20 text-emerald-600 font-mono">recommended</span>
</div>
<div className="flex items-center justify-between gap-2 rounded bg-black/30 px-3 py-2">
<code className="text-[12px] font-mono text-zinc-300">winget install starkSV.msdl</code>
<button onClick={handleCopy} className="flex-shrink-0 text-zinc-500 hover:text-white transition-colors">
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
</div>
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono font-semibold tracking-widest uppercase text-emerald-500">macOS / Linux · Homebrew</span>
<span className="text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/20 text-emerald-600 font-mono">recommended</span>
</div>
<div className="flex items-center justify-between gap-2 rounded bg-black/30 px-3 py-2">
<code className="text-[12px] font-mono text-zinc-300">brew tap starkSV/msdl && brew install msdl-cli</code>
<button onClick={handleBrewCopy} className="flex-shrink-0 text-zinc-500 hover:text-white transition-colors">
{brewCopied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
</div>
<div className="flex items-center justify-between gap-2 rounded-lg bg-white/4 border border-white/7 px-3 py-2">
<code className="text-[11px] font-mono text-zinc-400 truncate">curl -fsSL https://api.msdl.tech-latest.com/install.sh | bash</code>
<button onClick={handleCurlCopy} className="flex-shrink-0 text-zinc-500 hover:text-white transition-colors">
{curlCopied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
<a
href={RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/4 border border-white/7 text-[12px] text-zinc-400 hover:text-zinc-200 hover:border-white/12 transition-colors"
>
<ExternalLink size={12} className="flex-shrink-0 text-zinc-600" />
Other platforms — GitHub Releases
</a>
</div>
)
}
function RunStep({ command, langDisplay, onCopy, copied }: {
command: string
langDisplay?: string
onCopy: () => void
copied: boolean
}) {
return (
<div className="space-y-1.5">
<p className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600">2. Run</p>
<div className="rounded-lg border border-white/7 bg-[#0a0a0c] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-white/5">
<span className="text-[10px] font-mono text-zinc-600">{langDisplay ?? 'will prompt for language'}</span>
<button
onClick={onCopy}
className="flex items-center gap-1.5 text-[11px] text-zinc-500 hover:text-white transition-colors"
>
{copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<div className="px-3 py-2.5 overflow-x-auto">
<code className="text-[12px] font-mono text-zinc-300 whitespace-nowrap">
<span className="text-zinc-600 select-none">$ </span>
{command}
</code>
</div>
</div>
</div>
)
}
export default function CliHandoff({ productId, langName, langDisplay, defaultOpen = false, highlight = false }: Props) {
const [open, setOpen] = useState(defaultOpen)
const [copied, setCopied] = useState(false)
const command = langName
? `msdl --id ${productId} --lang "${langName}"`
: `msdl --id ${productId}`
function handleCopy() {
navigator.clipboard.writeText(command)
setCopied(true)
toast.success('Command copied!')
setTimeout(() => setCopied(false), 2000)
}
if (highlight) {
return (
<div className="rounded-xl border border-blue-500/25 bg-blue-500/5 p-4 space-y-3">
<div className="flex items-center gap-2">
<Terminal size={14} className="text-blue-400 flex-shrink-0" />
<p className="text-[12px] font-semibold text-blue-300">Use the MSDL CLI instead</p>
<span className="ml-auto text-[10px] font-mono font-semibold px-1.5 py-0.5 rounded bg-blue-500/15 border border-blue-500/20 text-blue-400 uppercase tracking-wide">
Recommended
</span>
</div>
<p className="text-[12px] text-zinc-500 leading-relaxed">
Our server is currently blocked by Microsoft. The CLI fetches the link directly from your machine — no server involved.
</p>
<InstallSteps />
<RunStep command={command} langDisplay={langDisplay} onCopy={handleCopy} copied={copied} />
</div>
)
}
return (
<div className="mt-3">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 text-[12px] text-zinc-600 hover:text-zinc-400 transition-colors py-1"
>
<Terminal size={13} />
<span>Get it from your machine via CLI</span>
<motion.span
animate={{ rotate: open ? 90 : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
>
<ChevronRight size={13} />
</motion.span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="cli-handoff"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="overflow-hidden"
>
<div className="mt-2 rounded-xl border border-white/7 bg-[#0d0d0f] p-4 space-y-3">
<p className="text-[12px] text-zinc-500 leading-relaxed">
The MSDL CLI runs the lookup directly from your machine — no server in the middle.
</p>
<InstallSteps />
<RunStep command={command} langDisplay={langDisplay} onCopy={handleCopy} copied={copied} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { motion } from 'motion/react'
import { Home, LayoutGrid, Info, ExternalLink, Shield } from 'lucide-react'
import { Home, LayoutGrid, Info, ExternalLink, Shield, Terminal } from 'lucide-react'
interface DockItem {
label: string
@ -13,6 +13,7 @@ interface DockItem {
const items: DockItem[] = [
{ label: 'Home', icon: <Home size={19} />, path: '/' },
{ label: 'Products', icon: <LayoutGrid size={19} />, path: '/products' },
{ label: 'CLI', icon: <Terminal size={19} />, path: '/cli' },
{ label: 'About', icon: <Info size={19} />, path: '/about' },
{ label: 'Privacy', icon: <Shield size={19} />, path: '/privacy-policy' },
{ label: 'TechLatest', icon: <ExternalLink size={19} />, href: 'https://tech-latest.com' },
@ -44,6 +45,11 @@ export default function Dock() {
return (
<>
{/* Full-width shelf so the floating desktop dock separates from scrolled content — page bg is
near-black (zinc-950), so plain color gradients are invisible; backdrop-blur is what reads.
Desktop only: the mobile dock is already a flush full-width bar with no gap to bridge. */}
<div className="hidden sm:block fixed inset-x-0 bottom-0 h-24 backdrop-blur-md bg-gradient-to-t from-zinc-950/90 via-zinc-950/60 to-transparent pointer-events-none z-40" />
{/* ── DESKTOP DOCK (sm+) — centered floating pill ── */}
<div className="hidden sm:flex fixed bottom-5 left-0 right-0 justify-center z-50 pointer-events-none">
<motion.div

View file

@ -1,12 +1,23 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight } from 'lucide-react'
interface FaqItem {
q: string
a: string
a: ReactNode
}
const repoLink = (
<a
href="https://github.com/starkSV/windows-iso-downloader"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
GitHub
</a>
)
const faqs: FaqItem[] = [
{
q: 'Is this legal?',
@ -34,7 +45,13 @@ const faqs: FaqItem[] = [
},
{
q: 'Is MSDL open source?',
a: 'Yes. Both the frontend and the backend proxy are fully open source. The backend implements the same session-based flow as the Fido PowerShell script, ported to Go and Node.js. You can self-host the entire stack.',
a: (
<>
Yes. Both the frontend and the backend proxy are fully open source, on {repoLink}. The
backend and the standalone CLI both implement the same session-based flow as the Fido
PowerShell script, ported to Go. You can self-host the entire stack.
</>
),
},
]

View file

@ -0,0 +1,116 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { ExternalLink, ChevronRight } from 'lucide-react'
interface Props {
productId: string
languageName: string
}
function officialUrl(productId: string): string {
const id = Number(productId)
if (id >= 3113) return 'https://www.microsoft.com/software-download/windows11'
if (id >= 2378) return 'https://www.microsoft.com/software-download/windows10ISO'
return 'https://www.microsoft.com/software-download/windows8ISO'
}
function editionLabel(productId: string): string {
const id = Number(productId)
if (id >= 3113) return 'Windows 11'
if (id >= 2378) return 'Windows 10'
return 'Windows 8.1'
}
function guideUrl(productId: string): string {
const id = Number(productId)
if (id >= 3113) return 'https://minxl.ink/tl-win11-dl-guide'
if (id >= 2378) return 'https://minxl.ink/tl-win10-dl-guide'
return 'https://minxl.ink/tl-win81-dl-guide'
}
export default function OfficialFallback({ productId, languageName }: Props) {
const [open, setOpen] = useState(false)
const url = officialUrl(productId)
const edition = editionLabel(productId)
const guide = guideUrl(productId)
return (
<div className="mt-3">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 text-[12px] text-zinc-600 hover:text-zinc-400 transition-colors py-1"
>
<ExternalLink size={13} />
<span>Get it directly from Microsoft</span>
<motion.span
animate={{ rotate: open ? 90 : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
>
<ChevronRight size={13} />
</motion.span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="official-fallback"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="overflow-hidden"
>
<div className="mt-2 rounded-xl border border-white/7 bg-[#0d0d0f] p-4 space-y-3">
<p className="text-[12px] text-zinc-500 leading-relaxed">
Microsoft's official page runs the same lookup from your browser — it always works.
</p>
<ol className="space-y-2.5">
<li className="flex items-start gap-2.5">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[10px] font-mono text-zinc-500 mt-0.5">1</span>
<span className="text-[12px] text-zinc-400">
Open{' '}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline underline-offset-2"
>
Microsoft's official {edition} download page
</a>
</span>
</li>
<li className="flex items-start gap-2.5">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[10px] font-mono text-zinc-500 mt-0.5">2</span>
<span className="text-[12px] text-zinc-400">
Select edition: <span className="font-mono text-zinc-300">{edition}</span>, then click Confirm
</span>
</li>
<li className="flex items-start gap-2.5">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[10px] font-mono text-zinc-500 mt-0.5">3</span>
<span className="text-[12px] text-zinc-400">
Select language: <span className="font-mono text-zinc-300">{languageName}</span>, then click Confirm
</span>
</li>
<li className="flex items-start gap-2.5">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[10px] font-mono text-zinc-500 mt-0.5">4</span>
<span className="text-[12px] text-zinc-400">Click the download link for your architecture</span>
</li>
</ol>
<p className="text-[11px] text-zinc-600 pt-1 border-t border-white/5">
Need a visual walkthrough?{' '}
<a
href={guide}
target="_blank"
rel="noopener noreferrer"
className="text-zinc-500 hover:text-zinc-300 underline underline-offset-2 transition-colors"
>
See our step-by-step guide →
</a>
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -97,6 +97,11 @@ export default function RecentlyViewed() {
if (entries.length === 0) return null
function handleClear() {
localStorage.removeItem(STORAGE_KEY)
setEntries([])
}
return (
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
@ -104,6 +109,12 @@ export default function RecentlyViewed() {
<span className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600">
Recently viewed
</span>
<button
onClick={handleClear}
className="ml-auto text-[10px] text-zinc-700 hover:text-zinc-400 transition-colors"
>
Clear
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{entries.map(entry => (

View file

@ -1,5 +1,6 @@
import { motion } from 'motion/react'
import { useState, useEffect } from 'react'
import { evalProducts } from '../data/evalProducts'
interface Stat {
value: string
@ -7,14 +8,14 @@ interface Stat {
}
export default function StatsBar() {
const [totalReleases, setTotalReleases] = useState('17')
const [totalReleases, setTotalReleases] = useState((17 + evalProducts.length).toString())
useEffect(() => {
fetch('/data/products.json')
.then(r => r.json())
.then((data: Record<string, { active?: boolean }>) => {
const activeCount = Object.values(data).filter(p => p.active !== false).length
setTotalReleases(activeCount.toString())
setTotalReleases((activeCount + evalProducts.length).toString())
})
.catch(() => {})
}, [])

View file

@ -49,16 +49,8 @@ export default function AboutPage() {
<Section title="How it works">
<p>
Our backend replicates the session-based authentication flow that Microsoft uses to
serve download links to end users. The same approach is used by{' '}
<a
href="https://github.com/pbatard/Fido"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
Fido
</a>{' '}
(the PowerShell script bundled with Rufus). The flow is:
serve download links to end users — the same technique used by Rufus (credited below).
The flow is:
</p>
<ol className="list-decimal list-inside space-y-1 text-zinc-500 text-sm">
<li>Register a session with Microsoft's tracking endpoint</li>
@ -87,9 +79,22 @@ export default function AboutPage() {
</code>
</p>
<p>
Product IDs are maintained manually based on Microsoft's release cadence.
Windows 11 25H2, 24H2, Windows 10 22H2, and Windows 8.1 are currently listed.
New releases are added as Microsoft publishes them.
Product IDs are maintained manually based on Microsoft's release cadence, covering
consumer Windows (11, 10, 8.1) as well as Windows Server (2016–2025) and Windows 11
Enterprise evaluation editions. New releases are added as Microsoft publishes them —
see the <a href="/products" className="text-blue-400 hover:text-blue-300 transition-colors">full catalog</a>.
</p>
</Section>
<Section title="CLI tool">
<p>
MSDL also ships as a standalone command-line tool,{' '}
<code className="text-[11px] font-mono bg-white/5 border border-white/7 px-1.5 py-0.5 rounded text-zinc-400">msdl</code>.
It runs the same Microsoft session flow directly from your own machine instead of through
our backend — useful when Microsoft is rate-limiting our server, since a request from
your own connection isn't affected by that. See the{' '}
<a href="/cli" className="text-blue-400 hover:text-blue-300 transition-colors">CLI page</a> for
install instructions.
</p>
</Section>

View file

@ -0,0 +1,231 @@
import { useState } from 'react'
import { motion } from 'motion/react'
import { Terminal, Download, Copy, Check, ExternalLink, Zap, WifiOff, Code } from 'lucide-react'
import { toast } from 'sonner'
const RELEASES_URL = 'https://github.com/starkSV/windows-iso-downloader/releases/latest'
const SITE_URL = 'https://msdl.tech-latest.com'
function CodeBlock({ code, label }: { code: string; label?: string }) {
const [copied, setCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText(code)
setCopied(true)
toast.success('Copied!')
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="rounded-xl border border-white/7 bg-[#0a0a0c] overflow-hidden">
{label && (
<div className="flex items-center justify-between px-4 py-2 border-b border-white/5">
<span className="text-[10px] font-mono text-zinc-600">{label}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-[11px] text-zinc-500 hover:text-white transition-colors"
>
{copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
)}
<div className="px-4 py-3 overflow-x-auto flex items-center justify-between gap-3">
<code className="text-[13px] font-mono text-zinc-300 whitespace-nowrap">
<span className="text-zinc-600 select-none">$ </span>
{code}
</code>
{!label && (
<button
onClick={handleCopy}
className="flex-shrink-0 text-zinc-600 hover:text-white transition-colors"
>
{copied ? <Check size={13} className="text-green-400" /> : <Copy size={13} />}
</button>
)}
</div>
</div>
)
}
export default function CliPage() {
return (
<>
<title>msdl CLI — Windows ISO downloader for the command line</title>
<meta name="description" content="Download Windows ISO links directly from your machine using the msdl command-line tool. No browser, no server — works even when the web service is blocked." />
<link rel="canonical" href={`${SITE_URL}/cli`} />
<div className="max-w-2xl mx-auto px-5 pt-12 pb-16">
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
className="space-y-10"
>
{/* Hero */}
<div>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center flex-shrink-0">
<Terminal size={20} className="text-emerald-400" />
</div>
<div>
<p className="text-[10px] font-mono font-semibold tracking-widest uppercase text-emerald-500 mb-0.5">Command-line tool</p>
<h1 className="text-2xl font-bold text-white leading-tight">msdl CLI</h1>
</div>
</div>
<p className="text-zinc-400 leading-relaxed">
Get official Windows ISO download links directly from your machine — no browser, no server in the middle. The same links as the web app, fetched straight from Microsoft.
</p>
</div>
{/* Why */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{[
{ icon: <WifiOff size={15} />, title: 'Server-independent', desc: "Works even when msdl.tech is blocked by Microsoft's WAF." },
{ icon: <Code size={15} />, title: 'Scriptable', desc: 'Pipe the URL directly into wget, curl, or aria2 for automation.' },
{ icon: <Zap size={15} />, title: 'Fast', desc: 'No page load, no UI. Just the link.' },
].map(item => (
<div key={item.title} className="p-4 rounded-xl border border-white/7 bg-white/2 space-y-1.5">
<div className="text-zinc-500">{item.icon}</div>
<p className="text-sm font-medium text-white">{item.title}</p>
<p className="text-[12px] text-zinc-500 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
{/* Install */}
<div className="space-y-4">
<h2 className="text-base font-semibold text-white">Install</h2>
{/* winget — Windows primary */}
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold tracking-widest uppercase text-emerald-500">Windows · winget</span>
<span className="text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/20 text-emerald-600 font-mono">recommended</span>
</div>
<CodeBlock code="winget install starkSV.msdl" />
</div>
{/* Homebrew — macOS/Linux primary */}
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold tracking-widest uppercase text-emerald-500">macOS / Linux · Homebrew</span>
<span className="text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/20 text-emerald-600 font-mono">recommended</span>
</div>
<CodeBlock code="brew tap starkSV/msdl && brew install msdl-cli" />
</div>
{/* No Homebrew — curl installer */}
<div className="rounded-xl border border-white/7 bg-[#111113] p-4 space-y-2">
<p className="text-[12px] text-zinc-400">No Homebrew? One-line installer (auto-detects OS/arch, incl. Termux):</p>
<CodeBlock code="curl -fsSL https://api.msdl.tech-latest.com/install.sh | bash" />
</div>
{/* Manual / other platforms */}
<div className="rounded-xl border border-white/7 bg-[#111113] p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[12px] text-zinc-400">Other platforms — download binary manually:</p>
<a
href={RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-[12px] text-emerald-400 hover:text-emerald-300 transition-colors flex-shrink-0"
>
<Download size={13} />
GitHub Releases
<ExternalLink size={11} className="opacity-60" />
</a>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[12px]">
{[
{ os: 'Windows (manual)', file: 'msdl-windows-amd64.exe', rename: 'msdl.exe' },
{ os: 'macOS (Apple Silicon)', file: 'msdl-darwin-arm64', rename: 'msdl' },
{ os: 'macOS (Intel)', file: 'msdl-darwin-amd64', rename: 'msdl' },
{ os: 'Linux (x86_64)', file: 'msdl-linux-amd64', rename: 'msdl' },
{ os: 'Linux (ARM64 / Termux)', file: 'msdl-linux-arm64', rename: 'msdl' },
].map(p => (
<div key={p.os} className="px-3 py-2.5 rounded-lg bg-white/3 border border-white/5 space-y-1">
<p className="text-zinc-500">{p.os}</p>
<p className="font-mono text-zinc-300">{p.file}</p>
<p className="text-zinc-600">Rename to <code className="text-zinc-400">{p.rename}</code> · add to PATH</p>
</div>
))}
</div>
</div>
</div>
{/* Usage */}
<div className="space-y-4">
<h2 className="text-base font-semibold text-white">Usage</h2>
<div className="space-y-3">
<p className="text-[12px] text-zinc-500 font-mono uppercase tracking-widest">Interactive — pick product and language</p>
<CodeBlock code="msdl" />
</div>
<div className="space-y-3">
<p className="text-[12px] text-zinc-500 font-mono uppercase tracking-widest">Skip the picker with flags</p>
<CodeBlock code='msdl --id 3262 --lang "English"' label="Windows 11 25H2 · English" />
<CodeBlock code='msdl --id 3262' label="Windows 11 25H2 · prompts for language" />
</div>
<div className="space-y-3">
<p className="text-[12px] text-zinc-500 font-mono uppercase tracking-widest">Evaluation / Server ISOs</p>
<CodeBlock code="msdl --eval server-2025" label="Windows Server 2025" />
<CodeBlock code="msdl --eval win11-ent" label="Windows 11 Enterprise" />
</div>
<div className="space-y-3">
<p className="text-[12px] text-zinc-500 font-mono uppercase tracking-widest">Pipe directly into a download tool</p>
<CodeBlock code='msdl --id 3262 --lang "English" | wget -i -' label="wget" />
<CodeBlock code='msdl --id 3262 --lang "English" | xargs curl -L -O' label="curl" />
<CodeBlock code='msdl --id 3262 --lang "English" | xargs aria2c -x 16 -s 16' label="aria2c" />
</div>
<div className="space-y-3">
<p className="text-[12px] text-zinc-500 font-mono uppercase tracking-widest">List all available products</p>
<CodeBlock code="msdl --list" />
</div>
</div>
{/* Contribute */}
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">Sharing links with the web app</h2>
<div className="rounded-xl border border-white/7 bg-[#111113] p-4 space-y-2">
<p className="text-[12px] text-zinc-400 leading-relaxed">
By default, each successful fetch is shared with the <a href={SITE_URL} className="text-blue-400 hover:text-blue-300 underline underline-offset-2">msdl web app</a> to warm its cache — so the next person who visits gets a cached link instead of hitting Microsoft's API cold.
</p>
<p className="text-[12px] text-zinc-500 leading-relaxed">
This is a background POST to our backend. No personal data is sent — only the product ID, SKU ID, and the raw Microsoft JSON response.
</p>
<div className="pt-2 border-t border-white/5">
<p className="text-[12px] text-zinc-600 mb-1.5">To opt out:</p>
<CodeBlock code="msdl --id 3262 --lang English --no-contribute" />
</div>
</div>
</div>
{/* Open source */}
<div className="rounded-xl border border-white/7 bg-white/2 p-4 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-white mb-0.5">Open source</p>
<p className="text-[12px] text-zinc-500">Built in Go. MIT licensed. Contributions welcome.</p>
</div>
<a
href="https://github.com/starkSV/windows-iso-downloader"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-white/5 border border-white/8 text-[12px] text-zinc-400 hover:text-white hover:border-white/15 transition-all flex-shrink-0"
>
GitHub
<ExternalLink size={11} />
</a>
</div>
</motion.div>
</div>
</>
)
}

View file

@ -31,7 +31,7 @@ export default function DisclaimerPage() {
>
<div>
<h1 className="text-2xl font-bold text-white mb-1">Disclaimer</h1>
<p className="text-xs font-mono text-zinc-600">Last updated: May 18, 2026</p>
<p className="text-xs font-mono text-zinc-600">Last updated: July 16, 2026</p>
</div>
<div className="p-4 rounded-xl border border-amber-500/15 bg-amber-500/6 text-amber-400/80 text-[12px] leading-relaxed">
@ -43,7 +43,9 @@ export default function DisclaimerPage() {
<p>
MSDL is an independent, open-source project. It is not produced, approved, or supported
by Microsoft Corporation. The name "Windows" and the Windows logo are registered
trademarks of Microsoft Corporation.
trademarks of Microsoft Corporation. This applies equally to the website and the{' '}
<code className="text-zinc-400 text-[11px] font-mono bg-white/5 px-1 py-0.5 rounded">msdl</code>{' '}
command-line tool.
</p>
</Section>
@ -76,8 +78,9 @@ export default function DisclaimerPage() {
<Section title="Open source">
<p>
The source code is publicly available. You are free to inspect, fork, and self-host
this project under the terms of its open-source license. The project credits the
open-source Fido script by Pete Batard for the underlying session flow.
this project under the terms of its open-source license. See the{' '}
<a href="/about" className="text-blue-400 hover:text-blue-300 transition-colors">About page</a>{' '}
for credits.
</p>
</Section>
</motion.div>

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'motion/react'
import { ArrowRight, Server } from 'lucide-react'
import { ArrowRight, Server, Terminal } from 'lucide-react'
import ProductCard from '../components/ProductCard'
import StatsBar from '../components/StatsBar'
import HowItWorks from '../components/HowItWorks'
@ -35,7 +35,7 @@ const featured = [
name: 'Windows 10',
version: '22H2',
build: '19045.2965',
description: 'The final Windows 10 feature update. Security support until October 2025.',
description: 'The final Windows 10 feature update. Support ended Oct 2025; ESU through Oct 2026.',
badge: 'eol' as const,
archs: ['x64', 'x86'],
},
@ -121,7 +121,7 @@ export default function HomePage() {
<p className="text-zinc-400 text-base max-w-sm mx-auto mb-6 leading-relaxed">
Direct links from Microsoft's CDN. No ads. No registration.
</p>
<div className="flex items-center justify-center gap-4 text-[12px] text-zinc-500">
<div className="flex items-center justify-center flex-wrap gap-x-4 gap-y-1.5 text-[12px] text-zinc-500">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Always official
@ -136,6 +136,16 @@ export default function HomePage() {
<span className="w-1.5 h-1.5 rounded-full bg-zinc-600 inline-block" />
Free forever
</span>
<span className="text-zinc-700">·</span>
<a
href="https://github.com/starkSV/windows-iso-downloader"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 hover:text-zinc-300 transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 inline-block" />
<span className="underline decoration-dotted decoration-zinc-600 underline-offset-4">Open source</span>
</a>
</div>
</motion.div>
@ -209,6 +219,33 @@ export default function HomePage() {
</Link>
</motion.div>
{/* CLI promo */}
<motion.div
className="mt-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
>
<Link
to="/cli"
className="group block px-4 py-3 rounded-xl border border-white/6 bg-[#0d0d0f] hover:border-emerald-500/20 hover:bg-[#0a0f0b] transition-all duration-200"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Terminal size={11} className="text-emerald-500/50" />
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-widest">CLI tool</span>
</div>
<span className="flex items-center gap-1 text-[11px] text-zinc-600 group-hover:text-emerald-400 transition-colors">
Learn more
<ArrowRight size={10} className="group-hover:translate-x-0.5 transition-transform" />
</span>
</div>
<code className="text-[13px] font-mono text-zinc-300">
<span className="text-zinc-600">$ </span>msdl --id 3262 --lang &quot;English&quot;
</code>
</Link>
</motion.div>
{/* How it works */}
<HowItWorks />

View file

@ -31,14 +31,37 @@ export default function PrivacyPolicyPage() {
>
<div>
<h1 className="text-2xl font-bold text-white mb-1">Privacy Policy</h1>
<p className="text-xs font-mono text-zinc-600">Last updated: May 18, 2026</p>
<p className="text-xs font-mono text-zinc-600">Last updated: July 16, 2026</p>
</div>
<Section title="No data collected">
<Section title="No data collected on the website">
<p>
MSDL does not collect, store, log, or share any personally identifiable information.
There are no user accounts, no cookies, no analytics trackers, and no third-party
advertising scripts of any kind.
The MSDL website does not collect, store, log, or share any personally identifiable
information. There are no user accounts, no cookies, no analytics trackers, and no
third-party advertising scripts of any kind.
</p>
</Section>
<Section title="CLI tool: anonymous telemetry and link sharing">
<p>
The <code className="text-zinc-400 text-[11px] font-mono bg-white/5 px-1 py-0.5 rounded">msdl</code>{' '}
CLI is a separate, optional download and sends two kinds of anonymous data by default,
both of which you can disable:
</p>
<p>
<strong className="text-zinc-400">Usage telemetry</strong> — action type (fetch, eval, list,
interactive), platform (Windows/macOS/Linux), CLI version, and whether the run succeeded
or failed. No personal data, IP address, or product-key information is included. Disable
with <code className="text-zinc-400 text-[11px] font-mono bg-white/5 px-1 py-0.5 rounded">--no-telemetry</code>{' '}
or the <code className="text-zinc-400 text-[11px] font-mono bg-white/5 px-1 py-0.5 rounded">MSDL_NO_TELEMETRY=1</code> environment variable.
</p>
<p>
<strong className="text-zinc-400">Link contribution</strong> — after a successful fetch, the
CLI can share the product ID, SKU ID, and Microsoft's raw signed-link response back to our
backend, so the next website visitor gets a cached hit instead of a fresh Microsoft request.
Only that Microsoft response is shared — nothing about you or your machine. Disable with{' '}
<code className="text-zinc-400 text-[11px] font-mono bg-white/5 px-1 py-0.5 rounded">--no-contribute</code>{' '}
or <code className="text-zinc-400 text-[11px] font-mono bg-white/5 px-1 py-0.5 rounded">MSDL_NO_CONTRIBUTE=1</code>.
</p>
</Section>

View file

@ -8,6 +8,8 @@ import type { Sku, DownloadOption } from '../types'
import SystemRequirements from '../components/SystemRequirements'
import CliCommand from '../components/CliCommand'
import RelatedReleases from '../components/RelatedReleases'
import OfficialFallback from '../components/OfficialFallback'
import CliHandoff from '../components/CliHandoff'
import { addRecentEntry, updateRecentExpiry } from '../components/RecentlyViewed'
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3002'
@ -61,6 +63,8 @@ export default function ProductDetailPage() {
const [, setLinkExpiry] = useState<Date | null>(null)
const [expiryRemaining, setExpiryRemaining] = useState<number | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const [linkStatus, setLinkStatus] = useState<'fresh' | 'cached' | 'stale' | null>(null)
const [isLockdown, setIsLockdown] = useState(false)
const expiryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [meta, setMeta] = useState<{ badge: string; archs: string[]; active: boolean }>({ badge: '', archs: [], active: false })
@ -195,26 +199,42 @@ export default function ProductDetailPage() {
setDownloadLinks([])
setLinkExpiry(null)
setExpiryRemaining(null)
setLinkStatus(null)
setIsLockdown(false)
if (expiryTimerRef.current) clearInterval(expiryTimerRef.current)
fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}`)
.then(r => r.json())
.then(data => {
if (data.error) throw new Error(data.error)
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
setDownloadLinks(data.ProductDownloadOptions)
const expiry = parseSeExpiry(data.ProductDownloadOptions[0].Uri)
setLinkExpiry(expiry)
if (expiry) {
startExpiryCountdown(expiry)
if (productId) updateRecentExpiry(productId, expiry.getTime())
}
})
.catch(e => {
setError(e.message)
toast.error(e.message)
})
.finally(() => setIsFetching(false))
try {
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}`)
const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null
const expiresHeader = r.headers.get('X-MSDL-Link-Expires')
const lockdownHeader = r.headers.get('X-MSDL-Lockdown')
const data = await r.json()
if (data.error) {
if (data.lockdown) setIsLockdown(true)
throw new Error(data.error)
}
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
setDownloadLinks(data.ProductDownloadOptions)
setLinkStatus(status)
if (lockdownHeader === '1') setIsLockdown(true)
let expiry: Date | null = null
if (expiresHeader) {
const d = new Date(expiresHeader)
if (!isNaN(d.getTime())) expiry = d
}
if (!expiry) expiry = parseSeExpiry(data.ProductDownloadOptions[0].Uri)
setLinkExpiry(expiry)
if (expiry) {
startExpiryCountdown(expiry)
if (productId) updateRecentExpiry(productId, expiry.getTime())
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to fetch links'
setError(msg)
toast.error(msg)
} finally {
setIsFetching(false)
}
}
function handleCopy(uri: string) {
@ -256,11 +276,25 @@ export default function ProductDetailPage() {
setError(null)
try {
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}&force=true`)
const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null
const expiresHeader = r.headers.get('X-MSDL-Link-Expires')
const lockdownHeader = r.headers.get('X-MSDL-Lockdown')
const data = await r.json()
if (data.error) throw new Error(data.error)
if (data.error) {
if (data.lockdown) setIsLockdown(true)
throw new Error(data.error)
}
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
setDownloadLinks(data.ProductDownloadOptions)
const expiry = parseSeExpiry(data.ProductDownloadOptions[0].Uri)
setLinkStatus(status)
if (lockdownHeader === '1') setIsLockdown(true)
else setIsLockdown(false)
let expiry: Date | null = null
if (expiresHeader) {
const d = new Date(expiresHeader)
if (!isNaN(d.getTime())) expiry = d
}
if (!expiry) expiry = parseSeExpiry(data.ProductDownloadOptions[0].Uri)
setLinkExpiry(expiry)
if (expiry) {
startExpiryCountdown(expiry)
@ -439,6 +473,7 @@ export default function ProductDetailPage() {
if (sku) setSelectedSku(sku)
setDownloadLinks([])
setError(null)
setLinkStatus(null)
}}
>
<Select.Trigger className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-white/5 border border-white/7 text-sm text-white hover:bg-white/7 hover:border-white/12 focus:outline-none transition-all cursor-pointer">
@ -505,10 +540,24 @@ export default function ProductDetailPage() {
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="flex items-start gap-3 p-4 rounded-xl bg-red-500/8 border border-red-500/15 text-red-400 text-sm"
className="space-y-3"
>
<AlertTriangle size={15} className="flex-shrink-0 mt-0.5" />
<span>{error}</span>
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/8 border border-red-500/15 text-red-400 text-sm">
<AlertTriangle size={15} className="flex-shrink-0 mt-0.5" />
<span>{isLockdown
? 'Our server has been temporarily blocked by Microsoft and is in a 90-minute cooldown. Use the CLI to get your link instantly — it bypasses our server entirely.'
: error
}</span>
</div>
{selectedSku && (
<CliHandoff
productId={productId!}
langName={selectedSku.Language}
langDisplay={selectedSku.LocalizedLanguage}
highlight
defaultOpen
/>
)}
</motion.div>
)}
</AnimatePresence>
@ -523,81 +572,141 @@ export default function ProductDetailPage() {
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
>
<div className="pt-4 border-t border-white/6 space-y-3">
{/* Expiry row */}
<div className="flex items-center justify-between gap-2">
<div className={`flex items-center gap-2 p-3 rounded-lg border text-[11px] flex-1 ${
showRefresh
? 'bg-red-500/6 border-red-500/15 text-red-400/80'
: 'bg-amber-500/6 border-amber-500/12 text-amber-400/80'
}`}>
<AlertTriangle size={12} className="flex-shrink-0" />
{expiryRemaining !== null
? expiryRemaining <= 0
? 'Links expired — refresh to get new ones'
: `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN`
: 'Links expire in 24 hours · Direct from Microsoft CDN'
}
</div>
{showRefresh && (
<button
onClick={handleRefreshLinks}
disabled={isRefreshing}
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 disabled:opacity-40 transition-all flex-shrink-0"
>
<motion.span
animate={{ rotate: isRefreshing ? 360 : 0 }}
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
className="inline-block"
>
<RefreshCw size={11} />
</motion.span>
Refresh
</button>
)}
</div>
{/* Links */}
{downloadLinks.map(link => {
const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso'
const arch = link.Architecture || archFromUri(link.Uri)
const isCopied = copiedUri === link.Uri
return (
<div
key={link.Uri}
className="flex items-center justify-between gap-3 p-3.5 rounded-xl bg-white/3 border border-white/5 hover:border-white/9 transition-colors"
>
<div className="min-w-0">
<p className="text-[12px] font-mono text-white truncate">{filename}</p>
<p className="text-[11px] text-zinc-600 mt-0.5">{arch}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{linkStatus === 'stale' ? (
/* Stale state: hide links, show banner + CLI recommendation */
<>
{isLockdown ? (
/* Sentinel lockdown — no Refresh, no links, CLI only */
<>
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-red-500/8 border border-red-500/20 text-red-400">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
<p className="text-[12px] leading-relaxed">
Our server has been temporarily blocked by Microsoft and is in a 90-minute cooldown. Use the CLI to get your link instantly — it bypasses our server entirely.
</p>
</div>
{selectedSku && (
<CliHandoff
productId={productId!}
langName={selectedSku.Language}
langDisplay={selectedSku.LocalizedLanguage}
highlight
defaultOpen
/>
)}
</>
) : (
/* Normal stale — show warning, CLI, and Refresh */
<>
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-amber-500/8 border border-amber-500/20 text-amber-400">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
<p className="text-[12px] leading-relaxed">
These links are cached and have expired — Microsoft is currently limiting automated requests.
</p>
</div>
{selectedSku && (
<CliHandoff
productId={productId!}
langName={selectedSku.Language}
langDisplay={selectedSku.LocalizedLanguage}
highlight
defaultOpen
/>
)}
<button
onClick={() => handleCopy(link.Uri)}
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/6 transition-all"
title="Copy link"
onClick={handleRefreshLinks}
disabled={isRefreshing}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-white/4 border border-white/8 text-zinc-400 text-[12px] font-medium hover:bg-white/7 disabled:opacity-40 transition-all"
>
{isCopied
? <Check size={13} className="text-green-400" />
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="w-3.5 h-3.5" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
}
<motion.span
animate={{ rotate: isRefreshing ? 360 : 0 }}
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
className="inline-block"
>
<RefreshCw size={12} />
</motion.span>
Try refreshing links
</button>
<a
href={link.Uri}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 transition-all"
>
<Download size={11} />
{arch}
<ExternalLink size={10} className="opacity-50" />
</a>
</>
)}
</>
) : (
/* Fresh / cached state: show expiry row + links */
<>
<div className="flex items-center justify-between gap-2">
<div className={`flex items-center gap-2 p-3 rounded-lg border text-[11px] flex-1 ${
showRefresh
? 'bg-red-500/6 border-red-500/15 text-red-400/80'
: 'bg-amber-500/6 border-amber-500/12 text-amber-400/80'
}`}>
<AlertTriangle size={12} className="flex-shrink-0" />
{expiryRemaining !== null
? expiryRemaining <= 0
? 'Links expired — refresh to get new ones'
: `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN`
: 'Links expire in 24 hours · Direct from Microsoft CDN'
}
</div>
{showRefresh && (
<button
onClick={handleRefreshLinks}
disabled={isRefreshing}
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 disabled:opacity-40 transition-all flex-shrink-0"
>
<motion.span
animate={{ rotate: isRefreshing ? 360 : 0 }}
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
className="inline-block"
>
<RefreshCw size={11} />
</motion.span>
Refresh
</button>
)}
</div>
)
})}
{downloadLinks.map(link => {
const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso'
const arch = link.Architecture || archFromUri(link.Uri)
const isCopied = copiedUri === link.Uri
return (
<div
key={link.Uri}
className="flex items-center justify-between gap-3 p-3.5 rounded-xl bg-white/3 border border-white/5 hover:border-white/9 transition-colors"
>
<div className="min-w-0">
<p className="text-[12px] font-mono text-white truncate">{filename}</p>
<p className="text-[11px] text-zinc-600 mt-0.5">{arch}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleCopy(link.Uri)}
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/6 transition-all"
title="Copy link"
>
{isCopied
? <Check size={13} className="text-green-400" />
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="w-3.5 h-3.5" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
}
</button>
<a
href={link.Uri}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 transition-all"
>
<Download size={11} />
{arch}
<ExternalLink size={10} className="opacity-50" />
</a>
</div>
</div>
)
})}
</>
)}
</div>
</motion.div>
)}
@ -606,6 +715,28 @@ export default function ProductDetailPage() {
{(!isNotFound && !hasCatalogError) && (
<>
{/* Fallbacks — only on stale or error */}
{meta.active && (linkStatus === 'stale' || error !== null) && (
<>
<OfficialFallback
productId={productId!}
languageName={selectedSku?.LocalizedLanguage ?? ''}
/>
{/* Skip outer CLI card when already shown inside the main card (any stale or error) */}
{linkStatus !== 'stale' && !(error !== null && languages.length > 0) && (
selectedSku ? (
<CliHandoff
productId={productId!}
langName={selectedSku.Language}
langDisplay={selectedSku.LocalizedLanguage}
/>
) : (
<CliHandoff productId={productId!} />
)
)}
</>
)}
{/* System requirements */}
<SystemRequirements isWin11={isWin11(productId!)} />

View file

@ -1,42 +0,0 @@
# Issue Drafts
## Inactive products still trigger backend requests
### Summary
The new `active: false` catalog state is not fully enforced on the product detail page.
Inactive products still pass validation and trigger `/skuinfo` requests before the UI shows the discontinued-product panel.
### Affected file
- `frontend/src/pages/ProductDetailPage.tsx`
### Current behavior
For a catalog entry with `active: false`:
- the page loads the product from `products.json`
- `setIsValidated(true)` still runs
- the follow-up effect calls `/skuinfo?product_id=...`
- the backend and Microsoft endpoints are still hit
- only afterward does the page render the `Product Discontinued` state
### Expected behavior
Inactive products should fail fast on the frontend and should not trigger backend or Microsoft API requests.
### Risk
The inactive-product flow still consumes backend capacity and external request budget even though the user cannot download that release.
### Suggested fix
- gate validation on `product.active !== false`
- avoid setting `isValidated` for inactive products
- ensure the `/skuinfo` effect only runs for active products
### Prevention
- add a test covering an inactive catalog entry and assert that no backend request is made
- model the route state explicitly so `active`, `not_found`, and `error` paths cannot fall through into fetch logic

View file

@ -0,0 +1,91 @@
# check-new-releases
Two complementary tools for finding Windows product edition IDs that exist on
Microsoft's side but aren't yet in `cli/catalog.go` / `products.json` /
`validContributeProducts`. Neither touches those files automatically --
naming a new entry correctly (build number, refresh qualifiers like "V2",
etc.) still needs a human checking Microsoft's own release-health pages.
Both only *discover* candidates.
Only Windows 11 is worth checking at all: Windows 8.1 is fully frozen (no
updates since Jan 2023) and Windows 10 is past end-of-life (ESU security
patches only, delivered via Windows Update, not new consumer ISOs). Neither
will ever produce a new release again.
## Method 1 -- `msdls_v3.py` (brute-force range scan)
Checks every ID in a range you specify, one by one, against Microsoft's
SKU-info API. Use this when you already have a rough idea of where to look
(e.g. "the last release was around 3260, check up to 3350") or want to
directly merge results into a JSON catalog file.
```bash
pip install requests
python msdls_v3.py --first 3320 --last 3330
```
Optionally write results to a JSON file (merges with an existing one if
present, preserving fields like `badge`/`archs`/`related`, marking IDs that
stopped responding as `"active": false`):
```bash
python msdls_v3.py --first 3320 --last 3330 --write scan-results.json
```
Don't point `--write` directly at `frontend/public/data/products.json` --
write to a scratch file first and review before merging into the real
catalog.
**How it identifies a release:** reads `ProductDisplayName` from the first
SKU in the API response (e.g. `"Windows 11 25H2__V2"`). This field name was
wrong in an earlier version of this script (checked `EditionName`/
`ReleaseName`/`FriendlyName`, none of which exist in the real response) --
confirmed live and fixed 2026-07-14.
**Session handling:** registers the session via `vlscppe`'s permit endpoint
before scanning. A single SKU lookup was confirmed to work without this, but
this script's use case -- many rapid consecutive requests across a range --
is a much higher-risk pattern for triggering a Sentinel block than a
one-shot lookup, so the permit call is worth keeping. The CLI/backend's full
session flow additionally replays an `ov-df.microsoft.com` fingerprint; add
that too if the permit call alone isn't enough under real scanning load.
## Method 2 -- `main.go` (auto-discovery)
Scrapes Microsoft's public `/windows11` download page for the *current*
flagship product edition ID (no range-guessing needed), compares it against
`cli/catalog.go`, and if it's new, probes a bounded range of adjacent IDs to
discover the accompanying variant family (Home China, Pro China, ARM64).
Use this when you just want to know "is there anything new" without knowing
where to look.
```bash
cd scripts/check-new-releases
go run .
```
If a new release is found, it makes real requests to Microsoft to probe the
variant family -- let it finish rather than re-running immediately.
Flags:
- `-catalog <path>` — path to `cli/catalog.go` (default: `../../cli/catalog.go`)
- `-probe-range <n>` — how many adjacent IDs to check after a new flagship is found (default: 20)
**Known limitation:** the ARM64 family's offset from the flagship isn't
consistent release to release (observed: +3 for the 25H2 family, +18 for
24H2's). The default probe range of 20 covers both known cases, but if no
ARM64 variant shows up, try a wider `-probe-range`.
## Which one to use
| | Method 1 (Python) | Method 2 (Go) |
|---|---|---|
| Need to guess a starting range? | Yes | No -- finds the current flagship automatically |
| Gets the real Microsoft name? | Yes (`ProductDisplayName`) | Yes (`ProductDisplayName`) |
| Can write results to a catalog-shaped JSON? | Yes (`--write`) | No -- prints a report only |
| Best for | "I want to scan a specific range and get a mergeable file" | "Just tell me if anything's new" |
Both were verified end-to-end against live Microsoft endpoints on
2026-07-14, which surfaced a real gap: product IDs `3322`/`3323`/`3325`/`3326`
(Home/Pro China variants of the 25H2 "V2" refresh) existed on Microsoft's
side but weren't yet in the catalog. Since fixed.

View file

@ -0,0 +1,3 @@
module check-new-releases
go 1.25.6

View file

@ -0,0 +1,321 @@
// check-new-releases scrapes Microsoft's public Windows 11 download page for
// the current "flagship" product edition ID and compares it against the
// catalog already tracked in cli/catalog.go. Windows 8.1 and Windows 10 are
// intentionally not checked here -- 8.1 is fully frozen and Windows 10 is
// past end-of-life, so neither will ever produce a new consumer ISO release.
//
// If a new flagship ID is found, it probes a bounded range of adjacent IDs to
// discover the accompanying variant family (Home China, Pro China, ARM64,
// ...), since Microsoft allocates those in a cluster near the flagship ID
// but the exact offset isn't consistent release to release (observed gaps:
// +3 for the 25H2 family, +18 for 24H2's ARM64 family).
//
// This tool only *discovers* candidate IDs -- it does not touch cli/catalog.go,
// products.json, or validContributeProducts automatically. Naming a new entry
// correctly (build number, "Updated Oct"-style qualifiers, etc.) still needs a
// human checking Microsoft's own release-health pages.
//
// Usage: go run . [-catalog path/to/cli/catalog.go] [-probe-range N]
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"sync"
"time"
)
// --- Copied from cli/microsoft.go (not imported -- cli/ is package main and
// can't be imported from elsewhere). This is a standalone maintenance tool,
// not part of the shipped CLI/backend; keep in sync manually if the real
// session flow ever changes. ---
const (
msUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
msProfile = "606624d44113"
msLocale = "en-US"
msOrgID = "y6jn8c31"
msCustomer = "560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175"
)
var (
reW = regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
reRt = regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
)
type skuLang struct {
ID string `json:"Id"`
Language string `json:"Language"`
ProductDisplayName string `json:"ProductDisplayName"`
}
type simpleCookieJar struct {
mu sync.Mutex
cookies []*http.Cookie
}
func (j *simpleCookieJar) SetCookies(_ *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
for _, c := range cookies {
found := false
for i, existing := range j.cookies {
if existing.Name == c.Name {
j.cookies[i] = c
found = true
break
}
}
if !found {
j.cookies = append(j.cookies, c)
}
}
}
func (j *simpleCookieJar) Cookies(_ *url.URL) []*http.Cookie {
j.mu.Lock()
defer j.mu.Unlock()
out := make([]*http.Cookie, len(j.cookies))
copy(out, j.cookies)
return out
}
func newSessionID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]), hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]))
}
func newSession() (*http.Client, string) {
sessionID := newSessionID()
jar := &simpleCookieJar{}
client := &http.Client{Timeout: 15 * time.Second, Jar: jar}
q1 := url.Values{}
q1.Set("org_id", msOrgID)
q1.Set("session_id", sessionID)
req1, _ := http.NewRequest("GET", "https://vlscppe.microsoft.com/tags?"+q1.Encode(), nil)
req1.Header.Set("User-Agent", msUA)
client.Do(req1)
q2 := url.Values{}
q2.Set("instanceId", msCustomer)
q2.Set("PageId", "si")
q2.Set("session_id", sessionID)
req2, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/mdt.js?"+q2.Encode(), nil)
req2.Header.Set("User-Agent", msUA)
resp2, err := client.Do(req2)
if err != nil {
return client, sessionID
}
body, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
wMatch := reW.FindStringSubmatch(string(body))
rtMatch := reRt.FindStringSubmatch(string(body))
if len(wMatch) > 1 && len(rtMatch) > 1 {
q3 := url.Values{}
q3.Set("session_id", sessionID)
q3.Set("CustomerId", msCustomer)
q3.Set("PageId", "si")
q3.Set("w", wMatch[1])
q3.Set("mdt", fmt.Sprintf("%d", time.Now().UnixMilli()))
q3.Set("rticks", rtMatch[1])
req3, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/?"+q3.Encode(), nil)
req3.Header.Set("User-Agent", msUA)
client.Do(req3)
}
return client, sessionID
}
type msErrorEntry struct {
Type float64 `json:"Type"`
Value string `json:"Value"`
}
func firstError(errs []msErrorEntry) string {
if len(errs) == 0 {
return ""
}
if int(errs[0].Type) == 9 {
if errs[0].Value != "" {
return errs[0].Value
}
return "Your IP has been temporarily blocked by Microsoft (Code 715-123130)"
}
if errs[0].Value != "" {
return errs[0].Value
}
return "Microsoft API error"
}
func fetchLanguages(client *http.Client, sessionID, productID string) ([]skuLang, error) {
q := url.Values{}
q.Set("profile", msProfile)
q.Set("productEditionId", productID)
q.Set("SKU", "undefined")
q.Set("friendlyFileName", "undefined")
q.Set("Locale", msLocale)
q.Set("sessionID", sessionID)
req, _ := http.NewRequest("GET", "https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?"+q.Encode(), nil)
req.Header.Set("User-Agent", msUA)
req.Header.Set("Referer", "https://www.microsoft.com/en-us/software-download/windows11")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Microsoft returned HTTP %d", resp.StatusCode)
}
if len(body) > 0 && body[0] == '"' {
var unquoted string
json.Unmarshal(body, &unquoted)
body = []byte(unquoted)
}
var data struct {
Skus []skuLang `json:"Skus"`
Errors []msErrorEntry `json:"Errors"`
}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("invalid SKU response: %w", err)
}
if msg := firstError(data.Errors); msg != "" {
return nil, fmt.Errorf("%s", msg)
}
if len(data.Skus) == 0 {
return nil, fmt.Errorf("no languages found for this product")
}
return data.Skus, nil
}
// --- New logic specific to this tool ---
var catalogEntryRe = regexp.MustCompile(`\{"(\d+)",\s*"([^"]*)"\}`)
func knownCatalogIDs(catalogPath string) (map[string]string, error) {
data, err := os.ReadFile(catalogPath)
if err != nil {
return nil, err
}
matches := catalogEntryRe.FindAllStringSubmatch(string(data), -1)
out := make(map[string]string, len(matches))
for _, m := range matches {
out[m[1]] = m[2]
}
return out, nil
}
var flagshipRe = regexp.MustCompile(`<option value="(\d+)">Windows`)
func scrapeFlagshipID(pageURL string) (string, error) {
req, _ := http.NewRequest("GET", pageURL, nil)
req.Header.Set("User-Agent", msUA)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetching download page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
m := flagshipRe.FindStringSubmatch(string(body))
if len(m) < 2 {
return "", fmt.Errorf("could not find a product edition ID on the page -- Microsoft may have changed the page layout")
}
return m[1], nil
}
func main() {
catalogPath := flag.String("catalog", "../../cli/catalog.go", "path to cli/catalog.go")
probeRange := flag.Int("probe-range", 20, "how many adjacent IDs to probe after a new flagship is found")
flag.Parse()
const windows11Page = "https://www.microsoft.com/en-us/software-download/windows11"
fmt.Printf("Checking %s for the current flagship product edition ID...\n", windows11Page)
flagshipID, err := scrapeFlagshipID(windows11Page)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("Flagship ID found: %s\n", flagshipID)
known, err := knownCatalogIDs(*catalogPath)
if err != nil {
fmt.Fprintln(os.Stderr, "error reading catalog:", err)
os.Exit(1)
}
fmt.Printf("Loaded %d known product IDs from %s\n\n", len(known), *catalogPath)
if name, ok := known[flagshipID]; ok {
fmt.Printf("Up to date -- flagship ID %s is already in the catalog as %q\n", flagshipID, name)
return
}
fmt.Printf("NEW RELEASE DETECTED -- product edition ID %s is not in the current catalog.\n\n", flagshipID)
fmt.Println("Probing adjacent IDs to discover the variant family (this makes real")
fmt.Println("requests to Microsoft -- allow it to finish rather than re-running):")
fmt.Println()
client, sessionID := newSession()
idNum, err := strconv.Atoi(flagshipID)
if err != nil {
fmt.Fprintln(os.Stderr, "error: flagship ID is not numeric:", err)
os.Exit(1)
}
fmt.Printf("%-8s %-8s %s\n", "ID", "STATUS", "NOTES")
for offset := 0; offset <= *probeRange; offset++ {
candidate := strconv.Itoa(idNum + offset)
langs, err := fetchLanguages(client, sessionID, candidate)
time.Sleep(300 * time.Millisecond) // be a good citizen -- don't hammer Microsoft
if err != nil {
continue // not a valid product ID; skip silently, only report hits
}
realName := ""
if len(langs) > 0 {
realName = langs[0].ProductDisplayName
}
note := fmt.Sprintf("%q -- %d language(s)", realName, len(langs))
switch {
case len(langs) == 1:
note += " -- likely a China-only single-edition variant"
case len(langs) > 30:
note += " -- likely a worldwide multi-edition variant"
}
if existingName, ok := known[candidate]; ok {
note += fmt.Sprintf(" (already in catalog as %q)", existingName)
}
fmt.Printf("%-8s %-8s %s\n", candidate, "FOUND", note)
}
fmt.Println()
fmt.Println("Note: the ARM64 family's offset from the flagship isn't consistent release")
fmt.Println("to release (seen +3 for 25H2, +18 for 24H2) -- if no ARM64 variant showed up")
fmt.Println("above, try a wider -probe-range.")
fmt.Println()
fmt.Println("Next steps: confirm the build number from Microsoft's release-health pages,")
fmt.Println("then add entries to cli/catalog.go, frontend/public/data/products.json,")
fmt.Println("and the validContributeProducts map in backend/main.go.")
}

View file

@ -12,8 +12,27 @@ LOCALE = "en-US"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
def setup_session():
"""Initializes a session with Microsoft tracking servers."""
"""Initializes a session with Microsoft tracking servers.
Registers the session_id via vlscppe's "permit" endpoint before use --
confirmed live that getskuinformationbyproductedition still returns valid
data without this for a single lookup, but this script's brute-force
range scan makes many rapid consecutive requests, which is a much
higher-risk pattern for triggering a Sentinel block than the single-shot
case that was tested. The CLI/backend's full session flow additionally
replays an ov-df.microsoft.com fingerprint; add that too if this alone
turns out not to be enough under real scanning load.
"""
session_id = str(uuid.uuid4())
try:
requests.get(
"https://vlscppe.microsoft.com/tags",
params={"org_id": "y6jn8c31", "session_id": session_id},
headers={"User-Agent": UA},
timeout=10,
)
except Exception as e:
logging.warning(f"Session permit call failed (continuing anyway): {e}")
return session_id
def get_product(product_id, session_id):
@ -66,13 +85,15 @@ def scan_id(product_id):
# MS usually puts the release name in the first SKU
skus = data.get("Skus", [])
if skus:
# Check multiple possible name keys
s = skus[0]
# ReleaseName is often "Windows 10 Version 22H2 (Updated Oct 2025)"
name = s.get("EditionName") or s.get("ReleaseName") or s.get("FriendlyName")
# Confirmed live 2026-07-14: the real field is "ProductDisplayName"
# (e.g. "Windows 11 25H2"), not EditionName/ReleaseName/FriendlyName --
# those don't exist in the actual response and always fell through to
# the generic fallback below, silently.
name = s.get("ProductDisplayName") or s.get("LocalizedProductDisplayName")
if name:
return name
return f"Windows Product {product_id}"
if __name__ == "__main__":

View file

@ -1,5 +1,5 @@
PackageIdentifier: starkSV.msdl
PackageVersion: 0.1.0
PackageVersion: 0.3.7
Platform:
- Windows.Desktop
MinimumOSVersion: 10.0.0.0
@ -8,7 +8,7 @@ Commands:
- msdl
Installers:
- Architecture: x64
InstallerUrl: https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv0.1.0/msdl-windows-amd64.exe
InstallerSha256: PASTE_SHA256_FROM_STEP_1_HERE
InstallerUrl: https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv0.3.7/msdl-windows-amd64.exe
InstallerSha256: 44E9225BFE12DCE801301C2A68CBC114F57BA3313F775740BFC9A83CD87A431F
ManifestType: installer
ManifestVersion: 1.6.0

View file

@ -1,5 +1,5 @@
PackageIdentifier: starkSV.msdl
PackageVersion: 0.1.0
PackageVersion: 0.3.7
PackageLocale: en-US
Publisher: starkSV
PublisherUrl: https://github.com/starkSV

View file

@ -1,5 +1,5 @@
PackageIdentifier: starkSV.msdl
PackageVersion: 0.1.0
PackageVersion: 0.3.7
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.6.0