Files
vm-images/build-windows-install-disk.sh
T
GuofangandClaude Sonnet 5.5 d9707fd57b Stage the Windows ISO files with rsync before copying them into the image
Copy the ISO without install.wim to a staging directory, split install.wim
into it, and fill the image with one recursive mcopy instead of a mmd and
mcopy per directory. rsync --chmod keeps the staged directories writable so
that cleanup can remove them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:26:24 +08:00

262 lines
9.2 KiB
Bash
Executable File

#!/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 rsync udisks2
#
# Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img]
# OUTPUT defaults to the ISO path with ".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%.*}.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
}
# show logs a command line before it runs; run logs it and then runs it. Use show
# alone where the output of the command is captured.
show() {
local line
printf -v line '%q ' "$@"
note "\$ ${line% }"
}
run() {
show "$@"
"$@"
}
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 rsync)
[[ "$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 rsync"
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=""
# Set while the output image is being written, so that a failed or cancelled
# run does not leave a half-built image behind.
partial_output=""
cleanup() {
[[ -z "$partial_output" ]] || rm -f "$partial_output"
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
# Staged directories may be read-only when the run was cancelled early.
chmod -R u+rwX "$work_dir" >/dev/null 2>&1 || true
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
show udisksctl loop-setup -r -f "$iso"
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
show lsblk -no MOUNTPOINT "$loop_device"
point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)"
if [[ -z "$point" ]]; then
show udisksctl mount -b "$loop_device" -t udf -o ro
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}')"
run 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"
run 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"
# The staging directory holds everything that goes into the image: the ISO files
# without install.wim, and the install.swm parts in sources/.
stage_dir="$work_dir/stage"
mkdir "$stage_dir"
step "Staging the ISO files without install.wim"
# The UDF mount is read-only, so its directories are too; --chmod keeps the copies
# writable, otherwise cleanup cannot remove them.
run rsync -rt --chmod=u+rwX --exclude=/sources/install.wim "$mount_dir/" "$stage_dir/"
run mkdir -p "$stage_dir/sources"
# 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)"
run wimlib-imagex split "$install_wim" "$stage_dir/sources/install.swm" 3800 >/dev/null
for part in "$stage_dir"/sources/install*.swm; do
note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes"
done
# Size the image: the staged files, and 5% plus 64 MiB of room for FAT32
# metadata and partition alignment.
content_bytes=$(
find "$stage_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="$output"
run mkdir -p "$(dirname -- "$output")"
# --force replaces an existing image; truncate alone would keep its old content.
run rm -f "$image"
partial_output="$image"
run truncate -s "${image_mib}m" "$image"
run sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
show sgdisk -i 1 "$image"
sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }')
[[ -n "$sectors" ]] || die "could not read the partition size"
run 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
step "Copying the installation files into the image"
entries=()
while IFS= read -r -d '' entry; do
entries+=("$entry")
done < <(find "$stage_dir" -mindepth 1 -maxdepth 1 -print0)
note "\$ mcopy -s -Q -m -i $mtools_image <${#entries[@]} entries from the staging directory> ::/"
mcopy -s -Q -m -i "$mtools_image" "${entries[@]}" "::/"
partial_output=""
printf 'Built Windows 11 installation disk: %s\n' "$output"