/tmp is memory-backed on some systems, and the work directory holds about 10 GiB. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
263 lines
9.3 KiB
Bash
Executable File
263 lines
9.3 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
|
|
|
|
# The work directory holds about 10 GiB, so it defaults to /var/tmp: /tmp is
|
|
# memory-backed on some systems (Debian 13, Fedora, Arch).
|
|
work_dir="$(mktemp -d "${TMPDIR:-/var/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/"
|
|
|
|
# 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"
|