| 1 | #!/usr/bin/env python3 |
| 2 | """Build Windows 10/11 installation ISOs from Microsoft's Media Creation Tool catalog.""" |
| 3 | |
| 4 | import argparse |
| 5 | import hashlib |
| 6 | from pathlib import Path |
| 7 | import shutil |
| 8 | import subprocess |
| 9 | import tempfile |
| 10 | import urllib.request |
| 11 | import xml.etree.ElementTree as ET |
| 12 | |
| 13 | from env import require |
| 14 | |
| 15 | # The catalogs the Media Creation Tool itself downloads. |
| 16 | CATALOGS = { |
| 17 | "win10": ("https://go.microsoft.com/fwlink/?LinkId=841361", "x64"), |
| 18 | "win11": ("https://go.microsoft.com/fwlink/?linkid=2156292", "ARM64"), |
| 19 | } |
| 20 | |
| 21 | |
| 22 | def 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. |
| 30 | def 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 | |
| 43 | def 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 | |
| 53 | def 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. |
| 66 | VIRTIO_WIN = ("https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/" |
| 67 | "stable-virtio/virtio-win.iso") |
| 68 | NETKVM = ("netkvm.inf", "netkvm.cat", "netkvm.sys", "netkvmco.exe", "netkvmp.exe") |
| 69 | |
| 70 | |
| 71 | def 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 | |
| 86 | def 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 | |
| 126 | def main(): |
| 127 | parser = argparse.ArgumentParser(description=__doc__) |
| 128 | parser.add_argument("base", choices=sorted(CATALOGS)) |
| 129 | build(parser.parse_args().base) |
| 130 | |
| 131 | |
| 132 | if __name__ == "__main__": |
| 133 | main() |