Created
May 14, 2026 09:16
-
-
Save fstanis/5f454d15b2455ff361072e9dc3945d65 to your computer and use it in GitHub Desktop.
Bash script to install Windows to a USB drive (Windows To Go)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # win2usb.sh — install Windows to a USB drive from an ISO, on Linux. | |
| # | |
| # Usage: win2usb.sh [--wtg] <iso-path> <target-device> [wim-index] | |
| # | |
| # Partitions the target as GPT (ESP + MSR + NTFS), applies the selected | |
| # WIM index, and sets up UEFI boot via a BCD store built from the ISO's | |
| # BCD-Template. | |
| # | |
| # Pass --wtg to build as a "Windows To Go" drive. In practice, the only | |
| # difference is that this disables recovery. | |
| # | |
| # Dependencies: | |
| # Arch: sudo pacman -S gptfdisk dosfstools ntfs-3g wimlib parted hivex | |
| # Debian: sudo apt install gdisk dosfstools ntfs-3g wimtools parted hivex | |
| # Copyright (c) 2026 Filip Stanis | |
| # | |
| # Permission to use, copy, modify, and/or distribute this software for any | |
| # purpose with or without fee is hereby granted. | |
| # | |
| # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH | |
| # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY | |
| # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, | |
| # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM | |
| # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR | |
| # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR | |
| # PERFORMANCE OF THIS SOFTWARE. | |
| set -euo pipefail | |
| readonly ESP_SIZE_MIB=260 | |
| readonly MSR_SIZE_MIB=16 | |
| readonly ESP_TYPE_GUID=C12A7328-F81F-11D2-BA4B-00A0C93EC93B | |
| readonly MSR_TYPE_GUID=E3C9E316-0B5C-4DB8-817D-F92DF00215AE | |
| readonly WIN_TYPE_GUID=EBD0A0A2-B9E5-4433-87C0-68B6B72699C7 | |
| readonly BCD_TEMPLATE_PATH=Windows/System32/config/BCD-Template | |
| readonly WIN_EFI_DIR=Windows/Boot/EFI | |
| readonly WIN_FONTS_DIR=Windows/Boot/Fonts | |
| readonly BCD_BOOTMGR_GUID="{9dea862c-5cdd-4e70-acc1-f32b344d4795}" | |
| readonly BCD_OSLOADER_GUID="{b012b84d-c47c-4ed5-b722-c0c42163e569}" | |
| readonly REQUIRED_TOOLS=( | |
| sgdisk mkfs.fat mkfs.ntfs wimlib-imagex mount umount udevadm | |
| partprobe wipefs lsblk mountpoint mktemp awk hivexregedit blkid | |
| ) | |
| iso_path="" | |
| target_device="" | |
| wim_index="" | |
| iso_mount="" | |
| esp_mount="" | |
| wim_path="" | |
| wtg_mode=false | |
| main() { | |
| parse_args "$@" | |
| require_root | |
| require_tools | |
| assert_removable_target | |
| confirm_target | |
| trap cleanup EXIT | |
| prepare_workdirs | |
| mount_iso | |
| resolve_wim_path | |
| resolve_wim_index | |
| partition_target | |
| format_partitions | |
| apply_windows_image | |
| populate_efi_system_partition | |
| log "Done. Unplug ${target_device} and boot from it." | |
| } | |
| log() { | |
| echo "[wtg] $*" | |
| } | |
| die() { | |
| echo "Error: $*" >&2 | |
| exit 1 | |
| } | |
| parse_args() { | |
| if [[ "${1:-}" == "--wtg" ]]; then | |
| wtg_mode=true | |
| shift | |
| fi | |
| if (( $# < 2 || $# > 3 )); then | |
| echo "Usage: $0 [--wtg] <iso-path> <target-device> [wim-index]" >&2 | |
| exit 1 | |
| fi | |
| iso_path="$1" | |
| target_device="$2" | |
| if (( $# == 3 )); then | |
| wim_index="$3" | |
| fi | |
| [[ -f "${iso_path}" ]] || die "ISO not found: ${iso_path}" | |
| [[ -b "${target_device}" ]] || die "Not a block device: ${target_device}" | |
| } | |
| require_root() { | |
| (( EUID == 0 )) || die "Must run as root (try: sudo $0 ...)" | |
| } | |
| require_tools() { | |
| local tool | |
| for tool in "${REQUIRED_TOOLS[@]}"; do | |
| command -v "${tool}" >/dev/null 2>&1 \ | |
| || die "Missing required tool: ${tool}" | |
| done | |
| } | |
| assert_removable_target() { | |
| assert_not_root_device | |
| assert_usb_or_removable | |
| } | |
| assert_not_root_device() { | |
| local mp | |
| while read -r mp; do | |
| [[ "${mp}" != "/" ]] \ | |
| || die "Refusing to operate: ${target_device} contains the root filesystem." | |
| done < <(lsblk -lnp -o MOUNTPOINT "${target_device}" 2>/dev/null) | |
| } | |
| assert_usb_or_removable() { | |
| local tran rm_flag | |
| tran="$(lsblk -dnp -o TRAN "${target_device}" 2>/dev/null | awk 'NF{print $1}')" | |
| rm_flag="$(lsblk -dnp -o RM "${target_device}" 2>/dev/null | awk 'NF{print $1}')" | |
| [[ "${tran}" == "usb" || "${rm_flag}" == "1" ]] \ | |
| || die "${target_device} is not a removable/USB device" \ | |
| "(transport=${tran:-unknown}, removable=${rm_flag:-unknown})." | |
| } | |
| confirm_target() { | |
| local reply | |
| echo "About to DESTROY all data on ${target_device}:" >&2 | |
| lsblk -o NAME,SIZE,MODEL "${target_device}" >&2 | |
| read -r -p "Type 'yes' to continue: " reply | |
| [[ "${reply}" == "yes" ]] || die "Aborted." | |
| } | |
| prepare_workdirs() { | |
| iso_mount="$(mktemp -d)" | |
| esp_mount="$(mktemp -d)" | |
| } | |
| cleanup() { | |
| unmount_if_mounted "${esp_mount}" | |
| unmount_if_mounted "${iso_mount}" | |
| remove_dir_if_exists "${esp_mount}" | |
| remove_dir_if_exists "${iso_mount}" | |
| } | |
| unmount_if_mounted() { | |
| [[ -n "${1:-}" ]] || return | |
| mountpoint -q "$1" 2>/dev/null && umount "$1" || true | |
| } | |
| remove_dir_if_exists() { | |
| [[ -n "${1:-}" && -d "$1" ]] && rmdir "$1" 2>/dev/null || true | |
| } | |
| mount_iso() { | |
| mount -o loop,ro "${iso_path}" "${iso_mount}" | |
| } | |
| resolve_wim_path() { | |
| local candidate | |
| for candidate in sources/install.wim sources/install.esd; do | |
| if [[ -f "${iso_mount}/${candidate}" ]]; then | |
| wim_path="${iso_mount}/${candidate}" | |
| log "Using image: ${candidate}" | |
| return | |
| fi | |
| done | |
| die "No install.wim or install.esd found in ISO." | |
| } | |
| resolve_wim_index() { | |
| [[ -n "${wim_index}" ]] && return | |
| echo "Available Windows editions:" >&2 | |
| wimlib-imagex info "${wim_path}" \ | |
| | awk '/^Index:|^Name:|^Display Name:/' >&2 | |
| local reply | |
| read -r -p "Select WIM index: " reply | |
| [[ "${reply}" =~ ^[0-9]+$ ]] || die "Invalid index: ${reply}" | |
| wim_index="${reply}" | |
| } | |
| partition_target() { | |
| unmount_all_on_target | |
| wipefs --all --force "${target_device}" | |
| sgdisk --zap-all "${target_device}" | |
| create_gpt_partitions | |
| partprobe "${target_device}" | |
| udevadm settle | |
| } | |
| unmount_all_on_target() { | |
| local mp | |
| while read -r mp; do | |
| [[ -z "${mp}" ]] && continue | |
| umount "${mp}" || die "Failed to unmount ${mp}; target is busy." | |
| done < <(lsblk -lnp -o MOUNTPOINT "${target_device}") | |
| } | |
| create_gpt_partitions() { | |
| sgdisk \ | |
| "--new=1:0:+${ESP_SIZE_MIB}MiB" \ | |
| "--typecode=1:${ESP_TYPE_GUID}" \ | |
| "--change-name=1:EFI" \ | |
| "--new=2:0:+${MSR_SIZE_MIB}MiB" \ | |
| "--typecode=2:${MSR_TYPE_GUID}" \ | |
| "--change-name=2:Microsoft reserved" \ | |
| "--new=3:0:0" \ | |
| "--typecode=3:${WIN_TYPE_GUID}" \ | |
| "--change-name=3:Windows" \ | |
| "${target_device}" | |
| } | |
| partition_path() { | |
| if [[ "${target_device}" =~ [0-9]$ ]]; then | |
| echo "${target_device}p$1" | |
| else | |
| echo "${target_device}$1" | |
| fi | |
| } | |
| format_partitions() { | |
| mkfs.fat -F 32 -n "ESP" "$(partition_path 1)" | |
| mkfs.ntfs --quick --label "Windows" "$(partition_path 3)" | |
| } | |
| apply_windows_image() { | |
| log "Applying image to $(partition_path 3) (this may take a while)..." | |
| wimlib-imagex apply "${wim_path}" "${wim_index}" "$(partition_path 3)" | |
| } | |
| populate_efi_system_partition() { | |
| mount "$(partition_path 1)" "${esp_mount}" | |
| install_boot_binaries | |
| install_boot_fonts | |
| install_bcd | |
| install_fallback_loader | |
| umount "${esp_mount}" | |
| } | |
| install_boot_binaries() { | |
| local staging | |
| staging="$(mktemp -d)" | |
| extract_wim_path "/${WIN_EFI_DIR}" "${staging}" | |
| mkdir -p "${esp_mount}/EFI/Microsoft/Boot" | |
| cp -a "${staging}/EFI/." "${esp_mount}/EFI/Microsoft/Boot/" | |
| rm -rf "${staging}" | |
| } | |
| install_boot_fonts() { | |
| local staging | |
| staging="$(mktemp -d)" | |
| if ! extract_wim_path "/${WIN_FONTS_DIR}" "${staging}" 2>/dev/null; then | |
| rm -rf "${staging}" | |
| return | |
| fi | |
| mkdir -p "${esp_mount}/EFI/Microsoft/Boot/Fonts" | |
| cp -a "${staging}/Fonts/." "${esp_mount}/EFI/Microsoft/Boot/Fonts/" | |
| rm -rf "${staging}" | |
| } | |
| extract_wim_path() { | |
| wimlib-imagex extract "${wim_path}" "${wim_index}" \ | |
| "$1" --dest-dir="$2" --no-acls --no-attributes | |
| } | |
| install_fallback_loader() { | |
| local src="${esp_mount}/EFI/Microsoft/Boot/bootmgfw.efi" | |
| [[ -f "${src}" ]] || die "bootmgfw.efi missing after boot binary install." | |
| mkdir -p "${esp_mount}/EFI/Boot" | |
| cp "${src}" "${esp_mount}/EFI/Boot/bootx64.efi" | |
| } | |
| install_bcd() { | |
| local dst="${esp_mount}/EFI/Microsoft/Boot/BCD" | |
| extract_bcd_template "${dst}" | |
| populate_bcd "${dst}" | |
| } | |
| extract_bcd_template() { | |
| try_extract_bcd_template "${wim_path}" "${wim_index}" "$1" && return | |
| try_extract_bcd_template "${iso_mount}/sources/boot.wim" 1 "$1" && return | |
| die "No BCD-Template found in install image or boot.wim." | |
| } | |
| try_extract_bcd_template() { | |
| local wim="$1" index="$2" dst="$3" staging | |
| [[ -f "${wim}" ]] || return 1 | |
| staging="$(mktemp -d)" | |
| if ! wimlib-imagex extract "${wim}" "${index}" \ | |
| "/${BCD_TEMPLATE_PATH}" --dest-dir="${staging}" \ | |
| --no-acls --no-attributes 2>/dev/null; then | |
| rm -rf "${staging}" | |
| return 1 | |
| fi | |
| if [[ ! -f "${staging}/BCD-Template" ]]; then | |
| rm -rf "${staging}" | |
| return 1 | |
| fi | |
| cp "${staging}/BCD-Template" "${dst}" | |
| rm -rf "${staging}" | |
| } | |
| populate_bcd() { | |
| merge_bcd_static_elements "$1" | |
| merge_bcd_device_element "$1" "${BCD_BOOTMGR_GUID}" 11000001 1 | |
| merge_bcd_device_element "$1" "${BCD_OSLOADER_GUID}" 11000001 3 | |
| merge_bcd_device_element "$1" "${BCD_OSLOADER_GUID}" 21000001 3 | |
| mark_bcd_as_system_store "$1" | |
| log "Populated BCD store." | |
| } | |
| mark_bcd_as_system_store() { | |
| local reg | |
| reg="$(mktemp --suffix=.reg)" | |
| cat > "${reg}" <<'EOF' | |
| Windows Registry Editor Version 5.00 | |
| [HKEY_LOCAL_MACHINE\BCD\Description] | |
| "KeyName"="BCD00000001" | |
| "System"=dword:00000001 | |
| EOF | |
| hivexregedit --merge --prefix 'HKEY_LOCAL_MACHINE\BCD' "$1" "${reg}" | |
| rm -f "${reg}" | |
| } | |
| merge_bcd_static_elements() { | |
| local reg | |
| reg="$(mktemp --suffix=.reg)" | |
| generate_bcd_static_reg > "${reg}" | |
| hivexregedit --merge --prefix 'HKEY_LOCAL_MACHINE\BCD' "$1" "${reg}" | |
| rm -f "${reg}" | |
| } | |
| generate_bcd_static_reg() { | |
| generate_bcd_bootmgr_reg | |
| generate_bcd_osloader_reg | |
| if [[ "${wtg_mode}" == true ]]; then | |
| generate_bcd_recovery_disable_reg | |
| fi | |
| } | |
| generate_bcd_bootmgr_reg() { | |
| local display_order | |
| display_order="$(guid_to_multisz_hex "${BCD_OSLOADER_GUID}")" | |
| cat <<EOF | |
| Windows Registry Editor Version 5.00 | |
| [HKEY_LOCAL_MACHINE\\BCD\\Objects\\${BCD_BOOTMGR_GUID}\\Elements\\23000003] | |
| "Element"="${BCD_OSLOADER_GUID}" | |
| [HKEY_LOCAL_MACHINE\\BCD\\Objects\\${BCD_BOOTMGR_GUID}\\Elements\\24000001] | |
| "Element"=hex(7):${display_order} | |
| EOF | |
| } | |
| generate_bcd_osloader_reg() { | |
| cat <<EOF | |
| [HKEY_LOCAL_MACHINE\\BCD\\Objects\\${BCD_OSLOADER_GUID}\\Elements\\12000002] | |
| "Element"="\\\\windows\\\\system32\\\\winload.efi" | |
| [HKEY_LOCAL_MACHINE\\BCD\\Objects\\${BCD_OSLOADER_GUID}\\Elements\\12000004] | |
| "Element"="Windows 11" | |
| EOF | |
| } | |
| generate_bcd_recovery_disable_reg() { | |
| cat <<EOF | |
| [HKEY_LOCAL_MACHINE\\BCD\\Objects\\${BCD_OSLOADER_GUID}\\Elements\\26000024] | |
| "Element"=hex:00 | |
| EOF | |
| } | |
| merge_bcd_device_element() { | |
| local bcd="$1" object_guid="$2" element_id="$3" part_num="$4" | |
| local reg dev | |
| reg="$(mktemp --suffix=.reg)" | |
| dev="$(build_gpt_device_blob "${part_num}")" | |
| cat > "${reg}" <<EOF | |
| Windows Registry Editor Version 5.00 | |
| [HKEY_LOCAL_MACHINE\\BCD\\Objects\\${object_guid}\\Elements\\${element_id}] | |
| "Element"=hex:${dev} | |
| EOF | |
| hivexregedit --merge --prefix 'HKEY_LOCAL_MACHINE\BCD' "${bcd}" "${reg}" | |
| rm -f "${reg}" | |
| } | |
| build_gpt_device_blob() { | |
| local disk_guid part_guid | |
| disk_guid="$(blkid -o value -s PTUUID "${target_device}")" | |
| part_guid="$(blkid -o value -s PARTUUID "$(partition_path "$1")")" | |
| [[ -n "${disk_guid}" && -n "${part_guid}" ]] \ | |
| || die "Could not read GPT GUIDs from ${target_device}." | |
| local d p | |
| d="$(guid_to_le_hex "${disk_guid}")" | |
| p="$(guid_to_le_hex "${part_guid}")" | |
| printf '%s' \ | |
| "00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00," \ | |
| "06,00,00,00,00,00,00,00,48,00,00,00,00,00,00,00," \ | |
| "${p}," \ | |
| "00,00,00,00,00,00,00,00," \ | |
| "${d}," \ | |
| "00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00" | |
| } | |
| guid_to_le_hex() { | |
| local g="${1//[\{\}-]/}" | |
| printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s' \ | |
| "${g:6:2}" "${g:4:2}" "${g:2:2}" "${g:0:2}" \ | |
| "${g:10:2}" "${g:8:2}" "${g:14:2}" "${g:12:2}" \ | |
| "${g:16:2}" "${g:18:2}" \ | |
| "${g:20:2}" "${g:22:2}" "${g:24:2}" "${g:26:2}" "${g:28:2}" "${g:30:2}" | |
| } | |
| guid_to_multisz_hex() { | |
| local i out="" | |
| for (( i = 0; i < ${#1}; i++ )); do | |
| printf -v out '%s%02x,00,' "${out}" "'${1:i:1}" | |
| done | |
| printf '%s' "${out}00,00,00,00" | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment