From 592c7294c3c90a31ad56e3561ee20f7f446e9c03 Mon Sep 17 00:00:00 2001 From: Guofang Date: Wed, 21 Oct 2026 16:41:26 +0800 Subject: [PATCH] Add a windows11 target that downloads the Windows 11 ISO with headless Chromium Move the Node.js script and its dependencies into nodejs/ and let download-images.sh call it for the image architecture. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 + README.md | 20 ++++++ download-images.sh | 52 +++++++++++++++- nodejs/get-windows-iso-url.js | 112 ++++++++++++++++++++++++++++++++++ nodejs/package-lock.json | 28 +++++++++ nodejs/package.json | 16 +++++ 6 files changed, 228 insertions(+), 3 deletions(-) create mode 100755 nodejs/get-windows-iso-url.js create mode 100644 nodejs/package-lock.json create mode 100644 nodejs/package.json diff --git a/.gitignore b/.gitignore index a47e22d..ca23e25 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ !base-qcow2/.gitkeep !install-iso/.gitkeep !tools-iso/.gitkeep + +# Node dependencies of nodejs/get-windows-iso-url.js. +node_modules/ diff --git a/README.md b/README.md index 3b95716..1965269 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,26 @@ arm64 hosts) and `.iso` in that order (`*Win*11*x64*.iso` or version-sorted name is used. Check which file was picked with `./download-images.sh --print-path windows11 --iso`. +To get a fresh temporary URL, `nodejs/get-windows-iso-url.js` drives Microsoft's +download page with headless Chromium (needs Node.js and an installed +Chromium, for example from Playwright): + +```bash +cd nodejs +npm ci # once, installs playwright-core +node get-windows-iso-url.js # print the URL for the host architecture +node get-windows-iso-url.js --arch arm64 # choose the architecture explicitly +``` + +The script only prints the URL. `./download-images.sh windows11` downloads that +URL into `install-iso/` like the other images (aria2c, resumable) for the image +architecture (`VM_IMAGE_ARCH=arm64` for arm64), and skips it when a matching ISO +is already there. The `all` target does not include it. + +The URL expires after about 24 hours and may be bound to the IP that requested +it, so download from the machine that ran the script. Run it with `--help` for +the language and path options. + ## Query image paths The path interface prints one absolute path without downloading anything: diff --git a/download-images.sh b/download-images.sh index 8c4d575..f637b2a 100755 --- a/download-images.sh +++ b/download-images.sh @@ -130,7 +130,7 @@ force_download=false usage() { cat <&2; usage >&2; exit 2 ;; esac @@ -297,6 +301,9 @@ download_file() { local destination="$3" local checksum_algorithm="${4:-}" local expected_checksum="${5:-}" + # Signed URLs change on every request; this key identifies the source for + # resuming a partial download instead of the URLs when it is given. + local source_key="${6:-}" local partial="${destination}.part" # Records how the partial file was started, so that a partial download is # only resumed with the same downloader and URLs. @@ -340,7 +347,7 @@ download_file() { # aria2c fills a partial file out of order, so it must not be resumed by # curl or wget, and vice versa. - download_state="$(printf '%s\n' "$downloader" "${urls[@]}")" + download_state="$(printf '%s\n' "$downloader" "${source_key:-${urls[@]}}")" if [[ -e "$partial" ]]; then if [[ ! -f "$partial_url" ]] || [[ "$(<"$partial_url")" != "$download_state" ]]; then printf 'Download source changed; restarting partial download.\n' @@ -386,6 +393,41 @@ download_file() { printf 'Downloaded %s:\n%s\n' "$label" "$destination" } +# The Microsoft download link is temporary, so it is fetched with headless +# Chromium only when no matching ISO exists yet. "all" does not include it. +download_windows_iso() { + local iso + local url + local name + + if [[ "$force_download" == true ]]; then + printf 'Forcing a fresh download of the Windows 11 %s ISO.\n' "$windows_arch" + while iso="$(find_windows_image "$install_iso_dir" iso)"; do + rm -f -- "$iso" + done + fi + if iso="$(find_windows_image "$install_iso_dir" iso)"; then + printf 'Windows 11 %s ISO already exists; skipping:\n%s\n' "$windows_arch" "$iso" + return + fi + if ! command -v node >/dev/null 2>&1; then + printf 'Error: node is required to get the Windows 11 ISO URL.\n' >&2 + exit 1 + fi + url="$(node "${images_dir}/nodejs/get-windows-iso-url.js" --arch "$windows_arch")" + [[ -n "$url" ]] || { printf 'Error: no Windows 11 ISO URL.\n' >&2; exit 1; } + name="${url%%\?*}" + name="${name##*/}" + name="$(printf '%b' "${name//%/\\x}")" + download_file \ + "Windows 11 ${windows_arch} ISO" \ + "$url" \ + "${install_iso_dir}/${name}" \ + '' \ + '' \ + "$name" +} + if wants_target debian13; then download_file \ 'Debian 13 cloud image' \ @@ -435,3 +477,7 @@ if wants_target openssh-win; then "$openssh_url" \ "$openssh_image" fi + +if [[ " ${targets[*]} " == *" windows11 "* ]]; then + download_windows_iso +fi diff --git a/nodejs/get-windows-iso-url.js b/nodejs/get-windows-iso-url.js new file mode 100755 index 0000000..d69e280 --- /dev/null +++ b/nodejs/get-windows-iso-url.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// Print a signed, temporary download URL for the Windows 11 ISO by driving +// Microsoft's download page with headless Chromium. +// +// Usage: node get-windows-iso-url.js [--arch x64|arm64] [--lang NAME] +// Environment: +// PLAYWRIGHT_CORE playwright-core directory (default: node_modules here) +// CHROME_PATH Chromium executable (default: newest one in ~/.cache/ms-playwright) +// The URL expires after about 24 hours and may be bound to the IP that +// requested it. Only the URL goes to stdout; logs go to stderr. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node get-windows-iso-url.js [options] + +Print a signed, temporary download URL for the Windows 11 ISO (only the URL +goes to stdout; logs go to stderr). + +Options: + --arch Architecture, default: the host's (x64 on amd64 hosts) + --lang Language, default "English (United States)", + for example "Chinese Simplified" + -h, --help Show this help + +Environment: + PLAYWRIGHT_CORE playwright-core directory (default: node_modules here) + CHROME_PATH Chromium executable (default: newest one in + ~/.cache/ms-playwright) + +Examples: + node get-windows-iso-url.js --arch x64 --lang "Chinese Simplified" + wget -c "$(node get-windows-iso-url.js 2>/dev/null)" + +The URL expires after about 24 hours and may be bound to the IP that +requested it.`); + process.exit(0); +} +const opt = (name, def) => { + const i = args.indexOf(name); + return i < 0 ? def : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true); +}; +const hostArch = { x64: 'x64', arm64: 'arm64' }[process.arch]; +const arch = opt('--arch', hostArch); +const lang = opt('--lang', 'English (United States)'); +const pages = { arm64: 'windows11arm64', x64: 'windows11' }; +if (!pages[arch]) { + console.error(arch === undefined ? `unsupported host architecture ${process.arch}: pass --arch x64|arm64` : '--arch must be x64 or arm64'); + process.exit(2); +} +const log = (...a) => console.error('[iso]', ...a); + +function findPlaywright() { + if (process.env.PLAYWRIGHT_CORE) return process.env.PLAYWRIGHT_CORE; + try { return path.dirname(require.resolve('playwright-core/package.json')); } catch {} + console.error('playwright-core not found: run npm ci or set PLAYWRIGHT_CORE'); + process.exit(2); +} + +function findChrome() { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + const root = path.join(os.homedir(), '.cache/ms-playwright'); + const dirs = fs.existsSync(root) ? fs.readdirSync(root).filter(d => /^chromium-\d+$/.test(d)).sort() : []; + if (!dirs.length) { console.error('Chromium not found: set CHROME_PATH or run npx playwright-core install chromium'); process.exit(2); } + return path.join(root, dirs.pop(), 'chrome-linux64/chrome'); +} + +(async () => { + const { chromium } = require(findPlaywright()); + const browser = await chromium.launch({ executablePath: findChrome(), headless: true }); + try { + const ctx = await browser.newContext({ + locale: 'en-US', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36', + }); + const page = await ctx.newPage(); + + let api = null; + page.on('response', async r => { + if (/GetProductDownloadLinks/i.test(r.url())) api = await r.json().catch(() => null); + }); + + log('opening', pages[arch]); + await page.goto(`https://www.microsoft.com/en-us/software-download/${pages[arch]}`, { waitUntil: 'networkidle', timeout: 60000 }); + await page.click('button:has-text("Accept")', { timeout: 3000 }).catch(() => {}); + + // Pick the first real edition (currently the only multi-edition ISO). + const edition = await page.$$eval('#product-edition option', o => o.map(x => x.value).find(v => v && v !== 'null')); + if (!edition) throw new Error('no edition option found; the page layout may have changed'); + await page.selectOption('#product-edition', edition); + await page.click('#submit-product-edition'); + + await page.waitForSelector('#product-languages option:not([value="null"])', { state: 'attached', timeout: 60000 }); + const langs = await page.$$eval('#product-languages option', o => o.map(x => ({ value: x.value, text: x.text }))); + const hit = langs.find(l => l.text === lang); + if (!hit) throw new Error(`no language "${lang}"; available: ${langs.map(l => l.text).filter(t => t !== 'Choose one').join(', ')}`); + await page.selectOption('#product-languages', hit.value); + await page.click('#submit-sku'); + + await page.waitForSelector('a[href*="prss.microsoft.com"]', { timeout: 60000 }); + const url = await page.$eval('a[href*="prss.microsoft.com"]', a => a.href); + const opts = api && api.ProductDownloadOptions; + log(opts ? opts[0].Name : '(API response not captured)', '| expires:', api && api.DownloadExpirationDatetime); + console.log(url); + + } finally { + await browser.close(); + } +})().catch(e => { console.error('[iso] failed:', e.message); process.exit(1); }); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json new file mode 100644 index 0000000..8e69b23 --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "win-iso", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "win-iso", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright-core": "^1.64.1" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 0000000..a605269 --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,16 @@ +{ + "name": "win-iso", + "version": "1.0.0", + "description": "", + "main": "get-windows-iso-url.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "playwright-core": "^1.64.1" + } +}