Add a script that builds a fast Windows installation disk image

QEMU's emulated USB CD-ROM reads the Windows ISO at a few MB/s. The script
builds a bootable FAT32 GPT disk image from the ISO, splitting install.wim into
install*.swm parts for the 4 GB FAT32 file limit, so the image can be attached
as an NVMe disk and read about 15 times faster.

It runs on macOS and Debian. The ISO has to be read as UDF: hdiutil on macOS,
mount -t udf as root on Linux, otherwise udisksctl, otherwise 7z extracts it.
A desktop session automounts the loop device that udisksctl creates, so the
existing mount point is reused when there is one. The image itself is built
without root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
guofangandClaude Sonnet 5.5 authored and Guofang committed 2026-10-17 17:28:18 +08:00
1 parent 4d9e1b43d9
commit 21604f511f
2 files changed
+267

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# Build a bootable Windows 11 installation disk image from the Windows ISO.
#
# Attached to the VM as an NVMe disk instead of a USB CD-ROM, the installation
# files are read about 15 times faster (QEMU's emulated USB storage reads the ISO
# at a few MB/s). The image holds one FAT32 partition, which the UEFI firmware
# can boot. FAT32 files are limited to 4 GB, so sources/install.wim is split into
# install.swm, install2.swm and so on, which Windows Setup reads as one image.
#
# Runs on macOS and Linux (Debian) and needs no root for the image itself: the
# image is filled with mtools. The ISO is a hybrid of ISO 9660 and UDF whose
# ISO 9660 side only has a README, so it must be read as UDF:
# macOS hdiutil attach
# Linux udisksctl (udisks2)
#
# Dependencies
# macOS (Homebrew) wimlib dosfstools gptfdisk mtools
# Debian wimtools dosfstools gdisk mtools udisks2
#
# Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img]
# OUTPUT defaults to the ISO path with ".install.img" in place of ".iso".
set -euo pipefail
usage() {
printf 'Usage: %s [--force] ISO [OUTPUT.img]\n' "$(basename "$0")"
}
force=0
args=()
for arg in "$@"; do
case "$arg" in
-h|--help) usage; exit 0 ;;
--force) force=1 ;;
*) args+=("$arg") ;;
esac
done
if ((${#args[@]} < 1 || ${#args[@]} > 2)); then
usage >&2
exit 2
fi
iso="${args[0]}"
output="${args[1]:-${iso%.*}.install.img}"
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
# The build has five steps; step prints a numbered heading for each of them.
total_steps=5
step_number=0
step() {
step_number=$((step_number + 1))
printf '[%d/%d] %s\n' "$step_number" "$total_steps" "$*"
}
# Progress details under a step; they go to stderr to keep stdout for the
# headings and the result line.
note() {
printf ' %s\n' "$*" >&2
}
os="$(uname -s)"
case "$os" in
Darwin|Linux) ;;
*) die "unsupported system: $os" ;;
esac
# mkfs.fat and sgdisk live in sbin, which is usually not in a user's PATH (and
# Homebrew has its own sbin).
brew_prefix=""
if [[ "$os" == Darwin ]] && command -v brew >/dev/null 2>&1; then
brew_prefix="$(brew --prefix 2>/dev/null || true)"
fi
for dir in ${brew_prefix:+"$brew_prefix/sbin"} /opt/homebrew/sbin /usr/local/sbin /usr/sbin /sbin; do
[[ -d "$dir" ]] && PATH="$PATH:$dir"
done
tools=(wimlib-imagex mkfs.fat sgdisk mcopy mmd)
[[ "$os" == Darwin ]] && tools+=(hdiutil)
missing=()
for tool in "${tools[@]}"; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
if ((${#missing[@]} > 0)); then
if [[ "$os" == Darwin ]]; then
hint="brew install wimlib dosfstools gptfdisk mtools"
else
hint="apt install wimtools dosfstools gdisk mtools"
fi
die "missing tools: ${missing[*]} ($hint)"
fi
[[ -f "$iso" ]] || die "ISO was not found: $iso"
if [[ -e "$output" && $force -eq 0 ]]; then
die "$output already exists (use --force to replace it)"
fi
# stat prints a file size with -f %z on macOS (BSD) and -c %s on Linux (GNU).
if [[ "$os" == Darwin ]]; then size_args=(-f %z); else size_args=(-c %s); fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/win11-install-disk.XXXXXX")"
mount_dir="$work_dir/iso"
mkdir "$mount_dir"
note "work directory: $work_dir"
mount_method=""
loop_device=""
cleanup() {
case "$mount_method" in
hdiutil) hdiutil detach -quiet "$mount_dir" >/dev/null 2>&1 || true ;;
udisks)
udisksctl unmount -b "$loop_device" >/dev/null 2>&1 || true
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true ;;
esac
rm -rf "$work_dir"
}
trap cleanup EXIT
# Turn the signals of a cancelled run into a normal exit, so that the EXIT trap
# unmounts the ISO and removes the work directory.
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
mount_with_udisks() {
if ! command -v udisksctl >/dev/null 2>&1; then
note "udisksctl is not installed"
return 1
fi
local setup
if ! setup="$(udisksctl loop-setup -r -f "$iso" 2>&1)"; then
note "udisksctl loop-setup failed: $setup"
return 1
fi
loop_device="$(sed -n 's/^Mapped file .* as \(\/dev\/loop[0-9]*\)\.$/\1/p' <<<"$setup")"
if [[ -z "$loop_device" ]]; then
note "could not find the loop device in: $setup"
return 1
fi
note "loop device: $loop_device"
# Set at once so that a cancel from here on still releases the loop device.
mount_method=udisks
# A desktop session may have mounted the loop device already, which makes
# udisksctl mount fail with AlreadyMounted; use that mount point then.
local mounted point
point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)"
if [[ -z "$point" ]]; then
if mounted="$(udisksctl mount -b "$loop_device" -t udf -o ro 2>&1)"; then
# Some udisks versions end the message with a period, others do not.
point="$(sed -n 's/^Mounted .* at \(.*\)$/\1/p' <<<"$mounted")"
[[ -d "$point" ]] || point="${point%.}"
note "udisksctl mount said: $mounted"
else
note "udisksctl mount failed: $mounted"
fi
else
note "$loop_device was already mounted"
fi
if [[ ! -d "$point" ]]; then
note "no usable mount point for $loop_device (parsed mount point: '${point}')"
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true
mount_method=""
return 1
fi
mount_dir="$point"
note "mounted at $mount_dir"
}
step "Reading the ISO $iso (it is UDF, so it has to be mounted)"
note "ISO size: $(stat -L "${size_args[@]}" "$iso") bytes"
if [[ "$os" == Darwin ]]; then
note "mounting with hdiutil at $mount_dir"
hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null
mount_method=hdiutil
else
note "mounting with udisksctl"
mount_with_udisks || true
fi
note "read method: ${mount_method:-none}"
[[ -n "$mount_method" ]] || die "cannot mount the ISO as UDF; install udisks2 and run from a desktop or login session"
install_wim="$mount_dir/sources/install.wim"
[[ -f "$install_wim" ]] || die "sources/install.wim was not found in the ISO"
[[ -d "$mount_dir/efi/boot" ]] || die "efi/boot was not found in the ISO; is it a UEFI Windows ISO?"
note "install.wim: $(stat "${size_args[@]}" "$install_wim") bytes"
# Parts must stay below the 4 GiB FAT32 file limit; wimlib takes MiB.
step "Splitting install.wim into install.swm parts (FAT32 files must stay below 4 GiB)"
split_dir="$work_dir/split"
mkdir "$split_dir"
wimlib-imagex split "$install_wim" "$split_dir/install.swm" 3800 >/dev/null
for part in "$split_dir"/install*.swm; do
note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes"
done
# Size the image: everything but install.wim, the split parts, and 5% plus 64 MiB
# of room for FAT32 metadata and partition alignment.
content_bytes=$(
{ find "$mount_dir" -type f ! -path "$install_wim" -exec stat "${size_args[@]}" {} +
find "$split_dir" -type f -exec stat "${size_args[@]}" {} +; } |
awk '{ total += $1 } END { printf "%d", total }'
)
mib=$((1024 * 1024))
image_mib=$(( (content_bytes + content_bytes / 20) / mib + 64 ))
step "Creating a $image_mib MiB image with a GPT and one FAT32 partition"
note "content to copy: $content_bytes bytes (without install.wim)"
image="$work_dir/disk.img"
truncate -s "${image_mib}m" "$image"
sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }')
[[ -n "$sectors" ]] || die "could not read the partition size"
mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null
# mtools addresses the partition by its byte offset inside the image.
mtools_image="$image@@$((2048 * 512))"
note "partition: $sectors sectors, formatted as FAT32 (label WINPE)"
export MTOOLS_SKIP_CHECK=1
copy_dir() { # copy_dir SOURCE_DIR TARGET_DIR_IN_IMAGE
local source="$1" target="$2" files=() entry
while IFS= read -r -d '' entry; do
files+=("$entry")
done < <(find "$source" -maxdepth 1 -type f ! -path "$install_wim" -print0)
if ((${#files[@]} > 0)); then
mcopy -Q -m -i "$mtools_image" "${files[@]}" "::$target/"
fi
}
step "Copying the installation files into the image"
copy_dir "$mount_dir" ""
dir_count=0
while IFS= read -r -d '' dir; do
relative="${dir#"$mount_dir"/}"
dir_count=$((dir_count + 1))
mmd -i "$mtools_image" "::$relative"
copy_dir "$dir" "$relative"
done < <(find "$mount_dir" -mindepth 1 -type d -print0 | sort -z)
note "copied $dir_count directories"
note "copying the install*.swm parts to sources/"
mcopy -Q -m -i "$mtools_image" "$split_dir"/install*.swm "::sources/"
step "Moving the image to $output"
mkdir -p "$(dirname -- "$output")"
mv -f "$image" "$output"
printf 'Built Windows 11 installation disk: %s\n' "$output"