1#!/usr/bin/env python3
2"""Build Windows 10/11 installation ISOs from Microsoft's Media Creation Tool catalog."""
3
4import argparse
5import hashlib
6from pathlib import Path
7import shutil
8import subprocess
9import tempfile
10import urllib.request
11import xml.etree.ElementTree as ET
12
13from env import require
14
15# The catalogs the Media Creation Tool itself downloads.
16CATALOGS = {
17 "win10": ("https://go.microsoft.com/fwlink/?LinkId=841361", "x64"),
18 "win11": ("https://go.microsoft.com/fwlink/?linkid=2156292", "ARM64"),
19}
20
21
22def tool(name):
23 path = shutil.which(name) or "/opt/homebrew/bin/" + name
24 if not Path(path).is_file():
25 raise SystemExit("Install with: /opt/homebrew/bin/brew install wimlib xorriso")
26 return path
27
28
29# Volume-license media install without asking for a product key and stay unactivated.
30def catalog_entry(base, work):
31 url, arch = CATALOGS[base]
32 cab = work / "products.cab"
33 urllib.request.urlretrieve(url, cab)
34 subprocess.run(["bsdtar", "-xf", str(cab), "-C", str(work), "products.xml"], check=True)
35 for entry in ET.parse(work / "products.xml").getroot().iter("File"):
36 field = lambda key: entry.findtext(key) or ""
37 if (field("LanguageCode") == "en-us" and field("Architecture") == arch
38 and "CLIENTBUSINESS_VOL" in field("FileName")):
39 return field("FilePath"), field("Sha1").lower()
40 raise SystemExit("The %s catalog has no en-us %s volume-license image" % (base, arch))
41
42
43def download(url, sha1, path):
44 digest = hashlib.sha1()
45 with urllib.request.urlopen(url) as response, path.open("wb") as output:
46 while chunk := response.read(8 * 1024 * 1024):
47 output.write(chunk)
48 digest.update(chunk)
49 if digest.hexdigest() != sha1:
50 raise SystemExit("Download failed SHA-1 verification: %s" % url)
51
52
53def pro_index(esd):
54 info = subprocess.check_output([tool("wimlib-imagex"), "info", str(esd)], text=True)
55 index = None
56 for line in info.splitlines():
57 key, _, value = line.partition(":")
58 if key.strip() == "Index":
59 index = value.strip()
60 elif key.strip() == "Edition ID" and value.strip() == "Professional":
61 return index
62 raise SystemExit("No Professional edition in %s" % esd)
63
64
65# Windows on Arm has no inbox driver for any network card QEMU emulates.
66VIRTIO_WIN = ("https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/"
67 "stable-virtio/virtio-win.iso")
68NETKVM = ("netkvm.inf", "netkvm.cat", "netkvm.sys", "netkvmco.exe", "netkvmp.exe")
69
70
71def fetch_netkvm(media):
72 """Keep only Red Hat's signed arm64 virtio-net driver from the virtio-win disc."""
73 with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary:
74 work = Path(temporary)
75 iso = work / "virtio-win.iso"
76 urllib.request.urlretrieve(VIRTIO_WIN, iso)
77 target = work / "netkvm-arm64"
78 target.mkdir()
79 for name in NETKVM:
80 subprocess.run([tool("xorriso"), "-osirrox", "on", "-indev", str(iso), "-extract",
81 "/NetKVM/w11/ARM64/" + name, str(target / name)], check=True)
82 (target / name).chmod(0o644)
83 target.replace(media / "netkvm-arm64")
84
85
86def build(base):
87 media = Path(require("ONE_VM_HOME")).expanduser() / "media"
88 iso = media / ("%s.iso" % base)
89 media.mkdir(parents=True, exist_ok=True)
90 if base == "win11" and not (media / "netkvm-arm64").exists():
91 fetch_netkvm(media)
92 if iso.exists():
93 print(iso)
94 return
95 wim = tool("wimlib-imagex")
96 with tempfile.TemporaryDirectory(prefix="one-media-", dir=media) as temporary:
97 work = Path(temporary)
98 url, sha1 = catalog_entry(base, work)
99 esd = work / "image.esd"
100 print("Downloading %s" % url, flush=True)
101 download(url, sha1, esd)
102 tree = work / "iso"
103 sources = tree / "sources"
104 subprocess.run([wim, "apply", str(esd), "1", str(tree)], check=True)
105 subprocess.run([wim, "export", str(esd), "2", str(sources / "boot.wim"),
106 "--compress=LZX"], check=True)
107 subprocess.run([wim, "export", str(esd), "3", str(sources / "boot.wim"),
108 "--boot"], check=True)
109 subprocess.run([wim, "export", str(esd), pro_index(esd),
110 str(sources / "install.esd"), "--compress=LZMS", "--solid"],
111 check=True)
112 esd.unlink()
113 partial = work / "out.iso"
114 # The no-prompt loader boots unattended instead of waiting for a key press.
115 subprocess.run([
116 # Level 3 stores the 4+ GiB install.esd as multiple extents, which Setup reads.
117 tool("xorriso"), "-as", "mkisofs", "-quiet", "-iso-level", "3", "-J",
118 "-joliet-long", "-V", base.upper(),
119 "-e", "efi/microsoft/boot/efisys_noprompt.bin", "-no-emul-boot",
120 "-o", str(partial), str(tree),
121 ], check=True)
122 partial.replace(iso)
123 print(iso)
124
125
126def main():
127 parser = argparse.ArgumentParser(description=__doc__)
128 parser.add_argument("base", choices=sorted(CATALOGS))
129 build(parser.parse_args().base)
130
131
132if __name__ == "__main__":
133 main()