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)
This commit is contained in:
commit
7ebf5a9560
12 changed files with 1048 additions and 0 deletions
85
.github/workflows/cli-release.yml
vendored
Normal file
85
.github/workflows/cli-release.yml
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
name: CLI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cli/v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Build binaries
|
||||
working-directory: cli
|
||||
run: |
|
||||
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 .
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: "msdl CLI ${{ github.ref_name }}"
|
||||
files: dist/*
|
||||
body: |
|
||||
## Install
|
||||
|
||||
**Windows (winget):**
|
||||
```
|
||||
winget install starkSV.msdl
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```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/
|
||||
|
||||
# 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/
|
||||
|
||||
# macOS Intel
|
||||
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-darwin-amd64 -o msdl
|
||||
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 "windows 11 25h2" # filter products, pick language
|
||||
msdl --id 3262 --lang "English (United States)" # direct, no prompts
|
||||
msdl --eval server-2025 # evaluation ISOs
|
||||
msdl --list # list all products
|
||||
```
|
||||
|
||||
- name: Submit winget update
|
||||
if: success()
|
||||
env:
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$WINGET_TOKEN" ]; then
|
||||
echo "WINGET_TOKEN not set, skipping winget update"
|
||||
exit 0
|
||||
fi
|
||||
dotnet tool install --global wingetcreate
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#cli/v}"
|
||||
wingetcreate update starkSV.msdl \
|
||||
--version "$VERSION" \
|
||||
--urls "https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-windows-amd64.exe" \
|
||||
--submit \
|
||||
--token "$WINGET_TOKEN"
|
||||
82
cli/catalog.go
Normal file
82
cli/catalog.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
type Product struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type EvalProduct struct {
|
||||
Slug string
|
||||
Name string
|
||||
EvalURL string
|
||||
}
|
||||
|
||||
var consumerProducts = []Product{
|
||||
{"52", "Windows 8.1 (9600.17415)"},
|
||||
{"2378", "Windows 10 22H2 Home China (19045.2006)"},
|
||||
{"2618", "Windows 10 22H2 v1 (19045.2965)"},
|
||||
{"3113", "Windows 11 24H2 (26100.1742)"},
|
||||
{"3114", "Windows 11 24H2 Home China (26100.1742)"},
|
||||
{"3115", "Windows 11 24H2 Pro China (26100.1742)"},
|
||||
{"3131", "Windows 11 Arm64 24H2 (26100.1742)"},
|
||||
{"3132", "Windows 11 Arm64 24H2 Home China (26100.1742)"},
|
||||
{"3133", "Windows 11 Arm64 24H2 Pro China (26100.1742)"},
|
||||
{"3262", "Windows 11 25H2 (26200.6584)"},
|
||||
{"3263", "Windows 11 25H2 Home China (26200.6584)"},
|
||||
{"3264", "Windows 11 25H2 Pro China (26200.6584)"},
|
||||
{"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)"},
|
||||
}
|
||||
|
||||
var evalProducts = []EvalProduct{
|
||||
{"server-2025", "Windows Server 2025", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025"},
|
||||
{"server-2022", "Windows Server 2022", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022"},
|
||||
{"server-2019", "Windows Server 2019", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2019"},
|
||||
{"server-2016", "Windows Server 2016", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2016"},
|
||||
{"win11-ent", "Windows 11 Enterprise", "https://www.microsoft.com/en-us/evalcenter/download-windows-11-enterprise"},
|
||||
}
|
||||
|
||||
func findProductByID(id string) (Product, bool) {
|
||||
for _, p := range consumerProducts {
|
||||
if p.ID == id {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Product{}, false
|
||||
}
|
||||
|
||||
func searchProducts(query string) []Product {
|
||||
words := strings.Fields(strings.ToLower(query))
|
||||
if len(words) == 0 {
|
||||
return consumerProducts
|
||||
}
|
||||
var results []Product
|
||||
for _, p := range consumerProducts {
|
||||
name := strings.ToLower(p.Name)
|
||||
match := true
|
||||
for _, w := range words {
|
||||
if !strings.Contains(name, w) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
results = append(results, p)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func findEvalProduct(slug string) (EvalProduct, bool) {
|
||||
for _, p := range evalProducts {
|
||||
if p.Slug == slug {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return EvalProduct{}, false
|
||||
}
|
||||
58
cli/catalog_test.go
Normal file
58
cli/catalog_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFindProductByID_found(t *testing.T) {
|
||||
p, ok := findProductByID("3262")
|
||||
if !ok {
|
||||
t.Fatal("expected product 3262 to exist")
|
||||
}
|
||||
if p.ID != "3262" {
|
||||
t.Errorf("got ID %s, want 3262", p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProductByID_notFound(t *testing.T) {
|
||||
_, ok := findProductByID("9999")
|
||||
if ok {
|
||||
t.Fatal("expected product 9999 to not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchProducts_match(t *testing.T) {
|
||||
results := searchProducts("windows 11 25h2")
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one result for 'windows 11 25h2'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchProducts_noMatch(t *testing.T) {
|
||||
results := searchProducts("zzznomatch")
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected no results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchProducts_caseInsensitive(t *testing.T) {
|
||||
results := searchProducts("WINDOWS 11")
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for uppercase query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindEvalProduct_found(t *testing.T) {
|
||||
p, ok := findEvalProduct("server-2025")
|
||||
if !ok {
|
||||
t.Fatal("expected server-2025 to exist")
|
||||
}
|
||||
if p.Slug != "server-2025" {
|
||||
t.Errorf("got slug %s, want server-2025", p.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindEvalProduct_notFound(t *testing.T) {
|
||||
_, ok := findEvalProduct("zzz-nope")
|
||||
if ok {
|
||||
t.Fatal("expected zzz-nope to not exist")
|
||||
}
|
||||
}
|
||||
3
cli/go.mod
Normal file
3
cli/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/starkSV/msdl-cli
|
||||
|
||||
go 1.21
|
||||
177
cli/main.go
Normal file
177
cli/main.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("msdl", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, `msdl — Windows ISO downloader
|
||||
|
||||
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 --eval [slug] evaluation ISOs (server-2025, win11-ent, ...)
|
||||
msdl --list list all products and exit
|
||||
|
||||
Flags:`)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
productID := fs.String("id", "", "consumer product ID (skips product picker)")
|
||||
langFlag := fs.String("lang", "", `language name, e.g. "English (United States)"`)
|
||||
evalMode := fs.Bool("eval", false, "fetch evaluation ISOs")
|
||||
listMode := fs.Bool("list", false, "list all products and exit")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
query := strings.Join(fs.Args(), " ")
|
||||
|
||||
if *listMode {
|
||||
fmt.Fprintln(os.Stderr, "Consumer products:")
|
||||
for _, p := range consumerProducts {
|
||||
fmt.Fprintf(os.Stderr, " %-6s %s\n", p.ID, p.Name)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\nEvaluation products:")
|
||||
for _, p := range evalProducts {
|
||||
fmt.Fprintf(os.Stderr, " %-20s %s\n", p.Slug, p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if *evalMode {
|
||||
return runEval(query)
|
||||
}
|
||||
return runConsumer(*productID, query, *langFlag)
|
||||
}
|
||||
|
||||
func runConsumer(productID, query, langName string) error {
|
||||
var product Product
|
||||
|
||||
if productID != "" {
|
||||
p, ok := findProductByID(productID)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown product ID %q — run msdl --list to see all products", productID)
|
||||
}
|
||||
product = p
|
||||
} else {
|
||||
candidates := consumerProducts
|
||||
if query != "" {
|
||||
candidates = searchProducts(query)
|
||||
if len(candidates) == 0 {
|
||||
return fmt.Errorf("no products match %q — run msdl --list to see all products", query)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
product = candidates[0]
|
||||
fmt.Fprintf(os.Stderr, "Selected: %s\n", product.Name)
|
||||
} else {
|
||||
var err error
|
||||
product, err = pickProduct(candidates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Setting up Microsoft session...\n")
|
||||
client, sessionID := newSession()
|
||||
|
||||
langs, err := fetchLanguages(client, sessionID, product.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching languages for %s: %w", product.Name, err)
|
||||
}
|
||||
|
||||
var lang Language
|
||||
if langName != "" {
|
||||
for _, l := range langs {
|
||||
if strings.EqualFold(l.Language, langName) {
|
||||
lang = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if lang.ID == "" {
|
||||
return fmt.Errorf("language %q not available for %s", langName, product.Name)
|
||||
}
|
||||
} else {
|
||||
lang, err = pickLanguage(langs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Fetching download link...\n")
|
||||
links, err := fetchDownloadLinks(client, sessionID, product.ID, lang.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching download links: %w", err)
|
||||
}
|
||||
|
||||
for _, link := range links {
|
||||
fmt.Println(link.URI)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEval(slug string) error {
|
||||
var ep EvalProduct
|
||||
|
||||
if slug != "" {
|
||||
p, ok := findEvalProduct(slug)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown eval product %q — valid slugs: server-2025, server-2022, server-2019, server-2016, win11-ent", slug)
|
||||
}
|
||||
ep = p
|
||||
} else {
|
||||
var err error
|
||||
ep, err = pickEvalProduct(evalProducts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Fetching eval links for %s...\n", ep.Name)
|
||||
links, err := fetchEvalLinks(ep.EvalURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching eval links for %s: %w", ep.Name, err)
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return fmt.Errorf("no eval links returned for %s", ep.Slug)
|
||||
}
|
||||
|
||||
if len(links) == 1 {
|
||||
fmt.Println(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
|
||||
}
|
||||
fmt.Println(links[n-1].URL)
|
||||
return nil
|
||||
}
|
||||
375
cli/microsoft.go
Normal file
375
cli/microsoft.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 (
|
||||
fwlinkRe = regexp.MustCompile(`https://go\.microsoft\.com/fwlink/[^"'\s<>]+`)
|
||||
isoLangRe = regexp.MustCompile(`_([a-z]{2}-[a-z]{2})\.iso$`)
|
||||
reW = regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
|
||||
reRt = regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
|
||||
)
|
||||
|
||||
type Language struct {
|
||||
ID string `json:"Id"`
|
||||
Language string `json:"Language"`
|
||||
}
|
||||
|
||||
type DownloadLink struct {
|
||||
URI string
|
||||
Architecture string
|
||||
}
|
||||
|
||||
type EvalLink struct {
|
||||
Arch string
|
||||
Lang string
|
||||
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)
|
||||
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 referer(productID string) string {
|
||||
id, err := strconv.Atoi(productID)
|
||||
if err != nil {
|
||||
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
|
||||
}
|
||||
if id >= 2935 {
|
||||
return "https://www.microsoft.com/en-us/software-download/windows11"
|
||||
}
|
||||
if id >= 2618 {
|
||||
return "https://www.microsoft.com/en-us/software-download/windows10ISO"
|
||||
}
|
||||
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
|
||||
}
|
||||
|
||||
func mapDownloadType(n int) string {
|
||||
switch n {
|
||||
case 0:
|
||||
return "x86"
|
||||
case 1:
|
||||
return "x64"
|
||||
case 2:
|
||||
return "ARM64"
|
||||
default:
|
||||
return fmt.Sprintf("type_%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func msGet(client *http.Client, reqURL, productID string) ([]byte, error) {
|
||||
req, _ := http.NewRequest("GET", reqURL, nil)
|
||||
req.Header.Set("User-Agent", msUA)
|
||||
req.Header.Set("Referer", referer(productID))
|
||||
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)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
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 parseSkuInfo(raw []byte) ([]Language, error) {
|
||||
var data struct {
|
||||
Skus []Language `json:"Skus"`
|
||||
Errors []msErrorEntry `json:"Errors"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &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
|
||||
}
|
||||
|
||||
func parseDownloadLinks(raw []byte) ([]DownloadLink, error) {
|
||||
var data struct {
|
||||
ProductDownloadOptions []struct {
|
||||
Uri string `json:"Uri"`
|
||||
Architecture interface{} `json:"Architecture"`
|
||||
DownloadType *float64 `json:"DownloadType"`
|
||||
} `json:"ProductDownloadOptions"`
|
||||
Errors []msErrorEntry `json:"Errors"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("invalid download response: %w", err)
|
||||
}
|
||||
if msg := firstError(data.Errors); msg != "" {
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if len(data.ProductDownloadOptions) == 0 {
|
||||
return nil, fmt.Errorf("no download links found for this SKU")
|
||||
}
|
||||
var links []DownloadLink
|
||||
for _, o := range data.ProductDownloadOptions {
|
||||
arch := ""
|
||||
if s, ok := o.Architecture.(string); ok && s != "" {
|
||||
arch = s
|
||||
} else if o.DownloadType != nil {
|
||||
arch = mapDownloadType(int(*o.DownloadType))
|
||||
}
|
||||
links = append(links, DownloadLink{URI: o.Uri, Architecture: arch})
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func fetchLanguages(client *http.Client, sessionID, productID string) ([]Language, 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)
|
||||
raw, err := msGet(client,
|
||||
"https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?"+q.Encode(),
|
||||
productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseSkuInfo(raw)
|
||||
}
|
||||
|
||||
func fetchDownloadLinks(client *http.Client, sessionID, productID, skuID string) ([]DownloadLink, error) {
|
||||
wq := url.Values{}
|
||||
wq.Set("profile", msProfile)
|
||||
wq.Set("productEditionId", productID)
|
||||
wq.Set("SKU", "undefined")
|
||||
wq.Set("friendlyFileName", "undefined")
|
||||
wq.Set("Locale", msLocale)
|
||||
wq.Set("sessionID", sessionID)
|
||||
msGet(client,
|
||||
"https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?"+wq.Encode(),
|
||||
productID)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("profile", msProfile)
|
||||
q.Set("productEditionId", "undefined")
|
||||
q.Set("SKU", skuID)
|
||||
q.Set("friendlyFileName", "undefined")
|
||||
q.Set("Locale", msLocale)
|
||||
q.Set("sessionID", sessionID)
|
||||
raw, err := msGet(client,
|
||||
"https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?"+q.Encode(),
|
||||
productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseDownloadLinks(raw)
|
||||
}
|
||||
|
||||
func extractFwlinks(pageHTML string) []string {
|
||||
matches := fwlinkRe.FindAllString(pageHTML, -1)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
link := html.UnescapeString(m)
|
||||
if !seen[link] {
|
||||
seen[link] = true
|
||||
out = append(out, link)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func detectArch(rawURL string) string {
|
||||
l := strings.ToLower(rawURL)
|
||||
switch {
|
||||
case strings.Contains(l, "arm64"):
|
||||
return "ARM64"
|
||||
case strings.Contains(l, "x64"):
|
||||
return "x64"
|
||||
case strings.Contains(l, "x86"):
|
||||
return "x86"
|
||||
}
|
||||
return "ISO"
|
||||
}
|
||||
|
||||
func detectLang(rawURL string) string {
|
||||
m := isoLangRe.FindStringSubmatch(strings.ToLower(rawURL))
|
||||
if len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchEvalLinks(evalURL string) ([]EvalLink, error) {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, _ := http.NewRequest("GET", evalURL, nil)
|
||||
req.Header.Set("User-Agent", msUA)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching eval page: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
fwlinks := extractFwlinks(string(body))
|
||||
var (
|
||||
links []EvalLink
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for _, fw := range fwlinks {
|
||||
wg.Add(1)
|
||||
go func(fw string) {
|
||||
defer wg.Done()
|
||||
r, _ := http.NewRequest("GET", fw, nil)
|
||||
r.Header.Set("User-Agent", msUA)
|
||||
resp, err := client.Do(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
final := resp.Request.URL.String()
|
||||
if !strings.Contains(strings.ToLower(final), ".iso") {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
links = append(links, EvalLink{Arch: detectArch(final), Lang: detectLang(final), URL: final})
|
||||
mu.Unlock()
|
||||
}(fw)
|
||||
}
|
||||
wg.Wait()
|
||||
if len(links) == 0 {
|
||||
return nil, fmt.Errorf("no eval ISO links found")
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
97
cli/microsoft_test.go
Normal file
97
cli/microsoft_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSkuInfo_success(t *testing.T) {
|
||||
raw := []byte(`{"Skus":[{"Id":"19675","Language":"English (United States)"},{"Id":"19676","Language":"French"}]}`)
|
||||
langs, err := parseSkuInfo(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(langs) != 2 {
|
||||
t.Fatalf("want 2 languages, got %d", len(langs))
|
||||
}
|
||||
if langs[0].ID != "19675" || langs[0].Language != "English (United States)" {
|
||||
t.Errorf("unexpected first lang: %+v", langs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkuInfo_rateLimitError(t *testing.T) {
|
||||
raw := []byte(`{"Errors":[{"Type":9,"Value":"715-123130 blocked"}]}`)
|
||||
_, err := parseSkuInfo(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for Errors array with Type 9")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkuInfo_empty(t *testing.T) {
|
||||
_, err := parseSkuInfo([]byte(`{"Skus":[]}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty Skus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadLinks_architectureString(t *testing.T) {
|
||||
raw := []byte(`{"ProductDownloadOptions":[{"Uri":"https://example.com/win11.iso?t=abc","Architecture":"x64"}]}`)
|
||||
links, err := parseDownloadLinks(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 link, got %d", len(links))
|
||||
}
|
||||
if links[0].URI != "https://example.com/win11.iso?t=abc" || links[0].Architecture != "x64" {
|
||||
t.Errorf("unexpected link: %+v", links[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadLinks_downloadTypeFallback(t *testing.T) {
|
||||
raw := []byte(`{"ProductDownloadOptions":[{"Uri":"https://example.com/a.iso","DownloadType":1}]}`)
|
||||
links, err := parseDownloadLinks(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if links[0].Architecture != "x64" {
|
||||
t.Errorf("want x64 from DownloadType 1, got %q", links[0].Architecture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadLinks_error(t *testing.T) {
|
||||
raw := []byte(`{"Errors":[{"Type":4,"Value":"no download links found for this SKU"}]}`)
|
||||
_, err := parseDownloadLinks(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for Errors array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadLinks_empty(t *testing.T) {
|
||||
_, err := parseDownloadLinks([]byte(`{"ProductDownloadOptions":[]}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFwlinks_dedupAndUnescape(t *testing.T) {
|
||||
html := `
|
||||
<a href="https://go.microsoft.com/fwlink/?linkid=111&clcid=0x409">x64</a>
|
||||
<a href="https://go.microsoft.com/fwlink/?linkid=111&clcid=0x409">dup</a>
|
||||
<a href="https://go.microsoft.com/fwlink/?linkid=222">arm</a>
|
||||
`
|
||||
links := extractFwlinks(html)
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("want 2 unique fwlinks, got %d: %v", len(links), links)
|
||||
}
|
||||
if links[0] != "https://go.microsoft.com/fwlink/?linkid=111&clcid=0x409" {
|
||||
t.Errorf("expected unescaped &, got %q", links[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectArchLang(t *testing.T) {
|
||||
url := "https://software-static.download.prss.microsoft.com/.../server2025_arm64_en-us.iso"
|
||||
if got := detectArch(url); got != "ARM64" {
|
||||
t.Errorf("arch: want ARM64, got %s", got)
|
||||
}
|
||||
if got := detectLang(url); got != "en-us" {
|
||||
t.Errorf("lang: want en-us, got %s", got)
|
||||
}
|
||||
}
|
||||
78
cli/picker.go
Normal file
78
cli/picker.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseChoice reads a 1-based integer from r, validated in [1, max].
|
||||
func parseChoice(r io.Reader, max int) (int, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
return 0, fmt.Errorf("no input")
|
||||
}
|
||||
text := strings.TrimSpace(scanner.Text())
|
||||
if text == "" {
|
||||
return 0, fmt.Errorf("enter a number between 1 and %d", max)
|
||||
}
|
||||
n, err := strconv.Atoi(text)
|
||||
if err != nil || n < 1 || n > max {
|
||||
return 0, fmt.Errorf("enter a number between 1 and %d", max)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func pickProduct(products []Product) (Product, error) {
|
||||
if len(products) == 0 {
|
||||
return Product{}, fmt.Errorf("no products available")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\nSelect a product:")
|
||||
for i, p := range products {
|
||||
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, p.Name)
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "\nChoice: ")
|
||||
n, err := parseChoice(os.Stdin, len(products))
|
||||
if err != nil {
|
||||
return Product{}, err
|
||||
}
|
||||
return products[n-1], nil
|
||||
}
|
||||
|
||||
func pickLanguage(langs []Language) (Language, error) {
|
||||
if len(langs) == 0 {
|
||||
return Language{}, fmt.Errorf("no languages available")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\nSelect a language:")
|
||||
for i, l := range langs {
|
||||
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, l.Language)
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "\nChoice: ")
|
||||
n, err := parseChoice(os.Stdin, len(langs))
|
||||
if err != nil {
|
||||
return Language{}, err
|
||||
}
|
||||
return langs[n-1], nil
|
||||
}
|
||||
|
||||
func pickEvalProduct(products []EvalProduct) (EvalProduct, error) {
|
||||
if len(products) == 0 {
|
||||
return EvalProduct{}, fmt.Errorf("no eval products available")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\nSelect an evaluation product:")
|
||||
for i, p := range products {
|
||||
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, p.Name)
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "\nChoice: ")
|
||||
n, err := parseChoice(os.Stdin, len(products))
|
||||
if err != nil {
|
||||
return EvalProduct{}, err
|
||||
}
|
||||
return products[n-1], nil
|
||||
}
|
||||
52
cli/picker_test.go
Normal file
52
cli/picker_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseChoice_valid(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
max int
|
||||
want int
|
||||
}{
|
||||
{"1\n", 3, 1},
|
||||
{"3\n", 3, 3},
|
||||
{" 2 \n", 5, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := parseChoice(strings.NewReader(c.input), c.max)
|
||||
if err != nil {
|
||||
t.Errorf("input %q: unexpected error: %v", c.input, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("input %q: got %d, want %d", c.input, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChoice_outOfRange(t *testing.T) {
|
||||
cases := []string{"0\n", "4\n"}
|
||||
for _, input := range cases {
|
||||
_, err := parseChoice(strings.NewReader(input), 3)
|
||||
if err == nil {
|
||||
t.Errorf("input %q: expected error for out-of-range, got nil", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChoice_nonNumeric(t *testing.T) {
|
||||
_, err := parseChoice(strings.NewReader("abc\n"), 3)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-numeric input, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChoice_emptyInput(t *testing.T) {
|
||||
_, err := parseChoice(strings.NewReader("\n"), 3)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty input, got nil")
|
||||
}
|
||||
}
|
||||
14
winget/manifests/starkSV.msdl.installer.yaml
Normal file
14
winget/manifests/starkSV.msdl.installer.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
PackageIdentifier: starkSV.msdl
|
||||
PackageVersion: 0.1.0
|
||||
Platform:
|
||||
- Windows.Desktop
|
||||
MinimumOSVersion: 10.0.0.0
|
||||
InstallerType: portable
|
||||
Commands:
|
||||
- msdl
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerUrl: https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv0.1.0/msdl-windows-amd64.exe
|
||||
InstallerSha256: 7FE06D224364D400C7A896048B6B3E8DEA7AA19DCA304C6D986A2EDF92DA72B9
|
||||
ManifestType: installer
|
||||
ManifestVersion: 1.6.0
|
||||
22
winget/manifests/starkSV.msdl.locale.en-US.yaml
Normal file
22
winget/manifests/starkSV.msdl.locale.en-US.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
PackageIdentifier: starkSV.msdl
|
||||
PackageVersion: 0.1.0
|
||||
PackageLocale: en-US
|
||||
Publisher: starkSV
|
||||
PublisherUrl: https://github.com/starkSV
|
||||
PublisherSupportUrl: https://github.com/starkSV/windows-iso-downloader/issues
|
||||
PackageName: msdl
|
||||
PackageUrl: https://msdl.tech-latest.com
|
||||
License: MIT
|
||||
LicenseUrl: https://github.com/starkSV/windows-iso-downloader/blob/main/LICENSE
|
||||
ShortDescription: Download Windows ISOs directly from Microsoft
|
||||
Description: |-
|
||||
msdl is a command-line tool for downloading Windows ISO files directly from Microsoft's servers.
|
||||
Supports interactive product and language selection, or fully automated with named arguments.
|
||||
Covers consumer Windows (10, 11, 8.1) and evaluation builds (Server 2025/2022/2019/2016, Windows 11 Enterprise).
|
||||
Tags:
|
||||
- windows
|
||||
- iso
|
||||
- download
|
||||
- microsoft
|
||||
ManifestType: defaultLocale
|
||||
ManifestVersion: 1.6.0
|
||||
5
winget/manifests/starkSV.msdl.yaml
Normal file
5
winget/manifests/starkSV.msdl.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
PackageIdentifier: starkSV.msdl
|
||||
PackageVersion: 0.1.0
|
||||
DefaultLocale: en-US
|
||||
ManifestType: version
|
||||
ManifestVersion: 1.6.0
|
||||
Loading…
Add table
Reference in a new issue