Skip to content

Instantly share code, notes, and snippets.

@VladimirMakaev
Last active September 5, 2026 18:23
Show Gist options
  • Select an option

  • Save VladimirMakaev/93503ab7c63c7bf4b0cada5db726614a to your computer and use it in GitHub Desktop.

Select an option

Save VladimirMakaev/93503ab7c63c7bf4b0cada5db726614a to your computer and use it in GitHub Desktop.
Install Ventoy on a USB drive from macOS - no VM, no macFUSE, no Linux required. Writes GPT + boot code directly via Python.
#!/usr/bin/env python3
"""
ventoy-macos-install.py - Install Ventoy on a USB drive from macOS.
This script installs Ventoy on a USB drive without requiring macFUSE,
Linux, or a VM. It writes the GPT partition table, boot code, and
EFI partition image directly to the raw block device.
Usage:
sudo python3 ventoy-macos-install.py /dev/diskN [--exfat|--ntfs] [--ventoy-version VERSION]
Requirements:
- macOS (tested on macOS 13+)
- Python 3.8+
- xz (install via: brew install xz)
- Root privileges (sudo)
- An external USB drive
What it does:
1. Downloads the specified Ventoy release (default: latest)
2. Decompresses boot images
3. Creates a GPT partition table with:
- Partition 1: exFAT (default) or NTFS, starting at sector 2048 (1MB)
- Partition 2: FAT16, 32MB, at the end of the disk (Ventoy EFI)
4. Writes Ventoy boot code (boot.img, core.img) to the GPT gap
5. Writes the Ventoy EFI partition image (ventoy.disk.img)
License: MIT
"""
import argparse
import json
import os
import struct
import subprocess
import sys
import tempfile
import time
import urllib.request
import uuid
import zlib
SECTOR_SIZE = 512
VENTOY_SECTOR_NUM = 65536 # 32MB for EFI partition
VENTOY_GITHUB_REPO = "ventoy/Ventoy"
# GPT Partition Type GUID for "Microsoft Basic Data" (FAT, NTFS, exFAT, etc.)
# Ventoy uses this for both partitions instead of the EFI System Partition type.
GPT_BASIC_DATA_GUID = uuid.UUID("EBD0A0A2-B9E5-4433-87C0-68B6B72699C7")
def die(msg):
print(f"\nERROR: {msg}", file=sys.stderr)
sys.exit(1)
def run(cmd, check=True, capture=True, timeout=30):
result = subprocess.run(cmd, capture_output=capture, text=True, timeout=timeout)
if check and result.returncode != 0:
stderr = result.stderr if capture else ""
die(f"Command failed: {' '.join(cmd)}\n{stderr}")
return result
def confirm(msg):
answer = input(f"\n{msg} (y/N): ").strip().lower()
if answer != "y":
print("Aborted.")
sys.exit(0)
# ── Ventoy download ──────────────────────────────────────────────────
def get_latest_version():
url = f"https://api.github.com/repos/{VENTOY_GITHUB_REPO}/releases/latest"
print(f"Fetching latest Ventoy version from GitHub...")
req = urllib.request.Request(url, headers={"User-Agent": "ventoy-macos-install"})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
tag = data["tag_name"].lstrip("v")
return tag
def download_ventoy(version, workdir):
tarball = f"ventoy-{version}-linux.tar.gz"
url = f"https://github.com/{VENTOY_GITHUB_REPO}/releases/download/v{version}/{tarball}"
dest = os.path.join(workdir, tarball)
if os.path.exists(dest):
print(f"Using cached {tarball}")
return dest
print(f"Downloading {url} ...")
urllib.request.urlretrieve(url, dest)
print(f"Downloaded {tarball} ({os.path.getsize(dest) / (1024 * 1024):.1f} MB)")
return dest
def extract_ventoy(tarball, workdir):
print("Extracting Ventoy package...")
run(["tar", "xzf", tarball, "-C", workdir])
# Find the extracted directory
for entry in os.listdir(workdir):
if entry.startswith("ventoy-") and os.path.isdir(os.path.join(workdir, entry)):
return os.path.join(workdir, entry)
die("Could not find extracted Ventoy directory")
def decompress_images(ventoy_dir, workdir):
boot_img = os.path.join(ventoy_dir, "boot", "boot.img")
core_xz = os.path.join(ventoy_dir, "boot", "core.img.xz")
disk_xz = os.path.join(ventoy_dir, "ventoy", "ventoy.disk.img.xz")
for f in [boot_img, core_xz, disk_xz]:
if not os.path.exists(f):
die(f"Missing file: {f}")
core_img = os.path.join(workdir, "core.img")
disk_img = os.path.join(workdir, "ventoy.disk.img")
if not os.path.exists(core_img):
print("Decompressing core.img...")
with open(core_img, "wb") as out:
result = subprocess.run(["xzcat", core_xz], stdout=out, timeout=30)
if result.returncode != 0:
die(
"Failed to decompress core.img.xz (is xz installed? brew install xz)"
)
if not os.path.exists(disk_img):
print("Decompressing ventoy.disk.img...")
with open(disk_img, "wb") as out:
result = subprocess.run(["xzcat", disk_xz], stdout=out, timeout=60)
if result.returncode != 0:
die("Failed to decompress ventoy.disk.img.xz")
return boot_img, core_img, disk_img
# ── Disk operations ──────────────────────────────────────────────────
def get_disk_info(disk):
"""Get disk size in sectors and verify it's external."""
result = run(["diskutil", "info", disk])
output = result.stdout
if "External" not in output and "Removable" not in output:
die(
f"{disk} does not appear to be an external/removable disk. Refusing to proceed."
)
for line in output.split("\n"):
if "Disk Size" in line:
# Extract byte count from format like: "256.1 GB (256060514304 Bytes)"
try:
bytes_str = line.split("(")[1].split("Bytes")[0].strip()
total_bytes = int(bytes_str)
return total_bytes // SECTOR_SIZE
except (IndexError, ValueError):
pass
die(f"Could not determine size of {disk}")
def calculate_layout(disk_sectors):
"""Calculate Ventoy-compatible partition layout."""
part1_start = 2048 # 1MB - Ventoy requirement
part1_end = disk_sectors - VENTOY_SECTOR_NUM - 34
part2_start = part1_end + 1
mod = part2_start % 8
if mod > 0:
part1_end -= mod
part2_start = part1_end + 1
part2_end = part2_start + VENTOY_SECTOR_NUM - 1
return {
"part1_start": part1_start,
"part1_end": part1_end,
"part1_sectors": part1_end - part1_start + 1,
"part2_start": part2_start,
"part2_end": part2_end,
"part2_sectors": part2_end - part2_start + 1,
}
# ── GPT construction ─────────────────────────────────────────────────
def uuid_to_mixed_endian(u):
b = u.bytes
return b[3::-1] + b[5:3:-1] + b[7:5:-1] + b[8:16]
def make_gpt_entry(type_guid, unique_guid, start, end, attrs, name):
t = uuid_to_mixed_endian(type_guid)
u = uuid_to_mixed_endian(unique_guid)
n = name.encode("utf-16-le")
n += b"\x00" * (72 - len(n))
return struct.pack("<16s16sQQQ72s", t, u, start, end, attrs, n)
def make_gpt_header(params):
data = struct.pack(
"<8sIIIIQQQQ16sQIII",
b"EFI PART",
0x00010000,
92,
0, # CRC placeholder
0,
params["my_lba"],
params["alt_lba"],
params["first_usable"],
params["last_usable"],
uuid_to_mixed_endian(params["disk_guid"]),
params["entry_start"],
params["num_entries"],
params["entry_size"],
params["entries_crc"],
)
crc = zlib.crc32(data) & 0xFFFFFFFF
data = data[:16] + struct.pack("<I", crc) + data[20:]
return data + b"\x00" * (SECTOR_SIZE - len(data))
def build_gpt(disk_sectors, layout):
"""Build complete GPT structures."""
disk_guid = uuid.uuid4()
e1 = make_gpt_entry(
GPT_BASIC_DATA_GUID,
uuid.uuid4(),
layout["part1_start"],
layout["part1_end"],
0,
"Ventoy",
)
e2 = make_gpt_entry(
GPT_BASIC_DATA_GUID,
uuid.uuid4(),
layout["part2_start"],
layout["part2_end"],
0,
"VTOYEFI",
)
entries = e1 + e2 + b"\x00" * (128 * 128 - 256)
entries_crc = zlib.crc32(entries) & 0xFFFFFFFF
common = {
"disk_guid": disk_guid,
"first_usable": 2048,
"last_usable": disk_sectors - 34,
"num_entries": 128,
"entry_size": 128,
"entries_crc": entries_crc,
}
primary = make_gpt_header(
{**common, "my_lba": 1, "alt_lba": disk_sectors - 1, "entry_start": 2}
)
backup = make_gpt_header(
{
**common,
"my_lba": disk_sectors - 1,
"alt_lba": 1,
"entry_start": disk_sectors - 33,
}
)
# Protective MBR
mbr = bytearray(512)
mbr[446:447] = b"\x00"
mbr[448] = 0x02
mbr[450] = 0xEE
mbr[451] = mbr[452] = mbr[453] = 0xFF
struct.pack_into("<I", mbr, 454, 1)
struct.pack_into("<I", mbr, 458, min(disk_sectors - 1, 0xFFFFFFFF))
mbr[510] = 0x55
mbr[511] = 0xAA
return bytes(mbr), primary, entries, backup
def patch_sector(fd, sector_lba, offset, data):
"""Read-modify-write a sector to patch sub-sector bytes."""
os.lseek(fd, sector_lba * SECTOR_SIZE, os.SEEK_SET)
sector = bytearray(os.read(fd, SECTOR_SIZE))
sector[offset : offset + len(data)] = data
os.lseek(fd, sector_lba * SECTOR_SIZE, os.SEEK_SET)
os.write(fd, bytes(sector))
def write_to_disk(
raw_device,
disk,
disk_sectors,
layout,
mbr,
primary,
entries,
backup,
boot_img_path,
core_img_path,
disk_img_path,
):
"""Write GPT and Ventoy boot code to the disk."""
boot_img = open(boot_img_path, "rb").read()
core_img = open(core_img_path, "rb").read()
disk_img = open(disk_img_path, "rb").read()
# Unmount
print("Unmounting disk...")
run(["diskutil", "unmountDisk", "force", disk], check=False)
time.sleep(2)
print("Writing to disk...")
fd = os.open(raw_device, os.O_RDWR)
try:
# Zero first 1MB (protective MBR + GPT header + entries area)
print(" Zeroing first 1MB...")
os.lseek(fd, 0, os.SEEK_SET)
os.write(fd, b"\x00" * (2048 * SECTOR_SIZE))
# Zero backup GPT area
print(" Zeroing backup GPT area...")
os.lseek(fd, (disk_sectors - 33) * SECTOR_SIZE, os.SEEK_SET)
os.write(fd, b"\x00" * (33 * SECTOR_SIZE))
# Write protective MBR
print(" Writing protective MBR...")
os.lseek(fd, 0, os.SEEK_SET)
os.write(fd, mbr)
# Write primary GPT header (sector 1)
print(" Writing primary GPT header...")
os.lseek(fd, SECTOR_SIZE, os.SEEK_SET)
os.write(fd, primary)
# Write primary GPT entries (sectors 2-33)
print(" Writing GPT entries...")
os.lseek(fd, 2 * SECTOR_SIZE, os.SEEK_SET)
os.write(fd, entries)
# Write backup GPT entries + header
print(" Writing backup GPT...")
os.lseek(fd, (disk_sectors - 33) * SECTOR_SIZE, os.SEEK_SET)
os.write(fd, entries)
os.lseek(fd, (disk_sectors - 1) * SECTOR_SIZE, os.SEEK_SET)
os.write(fd, backup)
# Write Ventoy boot.img (446 bytes of BIOS boot code to MBR)
print(" Writing Ventoy boot.img...")
patch_sector(fd, 0, 0, boot_img[:446])
# GPT marker at offset 92
patch_sector(fd, 0, 92, b"\x22")
# Write core.img to sectors 34-2047 (GPT gap area)
print(" Writing core.img...")
os.lseek(fd, 34 * SECTOR_SIZE, os.SEEK_SET)
core = core_img[: 2014 * SECTOR_SIZE]
if len(core) % SECTOR_SIZE:
core += b"\x00" * (SECTOR_SIZE - len(core) % SECTOR_SIZE)
os.write(fd, core)
# Second GPT marker at offset 17908
patch_sector(fd, 17908 // SECTOR_SIZE, 17908 % SECTOR_SIZE, b"\x23")
# Write ventoy.disk.img to partition 2
part2_start = layout["part2_start"]
print(f" Writing ventoy.disk.img at sector {part2_start}...")
os.lseek(fd, part2_start * SECTOR_SIZE, os.SEEK_SET)
os.write(fd, disk_img)
# Disk UUID at offset 384
patch_sector(fd, 0, 384, uuid.uuid4().bytes)
# Disk signature at offset 440
patch_sector(fd, 0, 440, os.urandom(4))
os.fsync(fd)
print(" All writes complete.")
finally:
os.close(fd)
def format_partition1(disk, fs_type="exfat"):
"""Format partition 1 with the specified filesystem."""
part1 = f"{disk}s1"
print(f"\nWaiting for macOS to detect partitions...")
time.sleep(3)
run(["diskutil", "unmountDisk", "force", disk], check=False)
time.sleep(1)
if fs_type == "exfat":
print(f"Formatting {part1} as exFAT...")
result = run(["newfs_exfat", "-v", "Ventoy", part1], check=False)
if result.returncode != 0:
print(" newfs_exfat failed, trying diskutil...")
run(["diskutil", "eraseVolume", "ExFAT", "Ventoy", part1])
else:
# NTFS is not natively supported for formatting on macOS
# but Ventoy data partition can remain unformatted and be formatted elsewhere
print(f"Warning: macOS cannot natively format NTFS.")
print(f"Formatting {part1} as exFAT instead (readable on Windows/Linux/macOS).")
result = run(["newfs_exfat", "-v", "Ventoy", part1], check=False)
if result.returncode != 0:
run(["diskutil", "eraseVolume", "ExFAT", "Ventoy", part1])
def verify(disk):
"""Print final disk layout and verify partition 1 offset."""
time.sleep(2)
run(["diskutil", "mountDisk", disk], check=False)
time.sleep(1)
print("\n" + "=" * 50)
print("Disk layout:")
print("=" * 50)
run(["diskutil", "list", disk], capture=False)
result = run(["diskutil", "info", f"{disk}s1"])
for line in result.stdout.split("\n"):
if "Offset" in line:
print(f"\n{line.strip()}")
if "2048" in line:
print(" -> Correct! Partition 1 starts at sector 2048 (1MB)")
else:
print(" -> WARNING: Partition 1 does not start at sector 2048!")
# ── Main ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Install Ventoy on a USB drive from macOS.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
sudo python3 ventoy-macos-install.py /dev/disk4
sudo python3 ventoy-macos-install.py /dev/disk4 --ventoy-version 1.1.10
sudo python3 ventoy-macos-install.py /dev/disk4 --exfat
""",
)
parser.add_argument("disk", help="Target disk device (e.g., /dev/disk4)")
parser.add_argument(
"--exfat",
action="store_const",
const="exfat",
dest="fs_type",
default="exfat",
help="Format data partition as exFAT (default)",
)
parser.add_argument(
"--ventoy-version",
default=None,
help="Ventoy version to install (default: latest)",
)
parser.add_argument(
"--work-dir",
default=None,
help="Working directory for downloads (default: temp directory)",
)
args = parser.parse_args()
disk = args.disk
raw_device = disk.replace("/dev/disk", "/dev/rdisk")
if os.geteuid() != 0:
die(
"This script must be run as root. Use: sudo python3 ventoy-macos-install.py ..."
)
if sys.platform != "darwin":
die("This script is designed for macOS only.")
# Check xz is available
if subprocess.run(["which", "xzcat"], capture_output=True).returncode != 0:
die("xz is required but not found. Install it with: brew install xz")
# Validate disk
if not disk.startswith("/dev/disk"):
die(f"Invalid disk device: {disk}")
if disk in ("/dev/disk0", "/dev/disk1"):
die("Refusing to operate on disk0/disk1 (likely your system disk).")
# Get disk info
print(f"Target disk: {disk}")
disk_sectors = get_disk_info(disk)
disk_gb = (disk_sectors * SECTOR_SIZE) / (1024**3)
print(f"Disk size: {disk_gb:.1f} GiB ({disk_sectors} sectors)")
# Show current layout
print(f"\nCurrent layout:")
run(["diskutil", "list", disk], capture=False)
# Calculate partition layout
layout = calculate_layout(disk_sectors)
p1_gb = (layout["part1_sectors"] * SECTOR_SIZE) / (1024**3)
p2_mb = (layout["part2_sectors"] * SECTOR_SIZE) / (1024**2)
print(f"\nPlanned Ventoy layout:")
print(
f" Part 1 (Ventoy, exFAT): {p1_gb:.1f} GiB [sectors {layout['part1_start']}-{layout['part1_end']}]"
)
print(
f" Part 2 (VTOYEFI, FAT16): {p2_mb:.0f} MiB [sectors {layout['part2_start']}-{layout['part2_end']}]"
)
confirm("ALL DATA ON THIS DISK WILL BE DESTROYED. Continue?")
# Setup working directory
if args.work_dir:
workdir = args.work_dir
os.makedirs(workdir, exist_ok=True)
else:
workdir = tempfile.mkdtemp(prefix="ventoy-macos-")
print(f"\nWorking directory: {workdir}")
# Get Ventoy version
version = args.ventoy_version or get_latest_version()
print(f"Ventoy version: {version}")
# Download and extract
tarball = download_ventoy(version, workdir)
ventoy_dir = extract_ventoy(tarball, workdir)
boot_img, core_img, disk_img = decompress_images(ventoy_dir, workdir)
print(f"\nBoot images ready:")
print(f" boot.img: {os.path.getsize(boot_img)} bytes")
print(f" core.img: {os.path.getsize(core_img)} bytes")
print(f" ventoy.disk.img: {os.path.getsize(disk_img)} bytes")
# Build GPT
print("\nBuilding GPT partition table...")
mbr, primary, entries, backup = build_gpt(disk_sectors, layout)
# Final confirmation
confirm("Ready to write. This is your last chance to abort. Proceed?")
# Write everything
write_to_disk(
raw_device,
disk,
disk_sectors,
layout,
mbr,
primary,
entries,
backup,
boot_img,
core_img,
disk_img,
)
# Format partition 1
format_partition1(disk, args.fs_type)
# Verify
verify(disk)
print(f"\n{'=' * 50}")
print(f"Ventoy {version} installed successfully!")
print(f"{'=' * 50}")
print(f"\nYou can now copy ISO/WIM/IMG/VHD files to the 'Ventoy' partition.")
print(f"Boot from the USB drive and Ventoy will list all bootable images.")
if __name__ == "__main__":
main()
@rgvxsthi

Copy link
Copy Markdown

Hello! Thanks so much for this script. Tried running it on mu Mac running Mac OS Golden Gate Beta 1 and got some garbled output during the decompress_images() function. I managed to get it fixed. Please see below:

Bug: decompress_images() dumps raw binary to the terminal

In decompress_images() there's a redundant xzcat call that decompresses core.img.xz straight to stdout (the terminal) before the real write. With capture=False, ~1MB of raw boot code prints as garbage/gibberish and can corrupt the terminal. The actual decompression happens on the very next line, so this call does nothing useful.

     if not os.path.exists(core_img):
         print("Decompressing core.img...")
-        run(["xzcat", core_xz], check=False, capture=False)
         with open(core_img, "wb") as out:
             result = subprocess.run(["xzcat", core_xz], stdout=out, timeout=30)
             if result.returncode != 0:
                 die(
                     "Failed to decompress core.img.xz (is xz installed? brew install xz)"
                 )

Removing that one line fixes it — output stays clean and the install works.

@haradakashiwa

Copy link
Copy Markdown

Damn, this works incredibly well.

@mamitry

mamitry commented Jun 23, 2026

Copy link
Copy Markdown

my installation gets aborted. not sure if i am doing something wrong:

% sudo python3 ventoy-macos-install.py /dev/disk28 --exfat
Target disk: /dev/disk28
Disk size: 29.3 GiB (61440000 sectors)

Current layout:
/dev/disk28 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      GUID_partition_scheme                        *31.5 GB    disk28

Planned Ventoy layout:
  Part 1 (Ventoy, exFAT):  29.3 GiB  [sectors 2048-61374423]
  Part 2 (VTOYEFI, FAT16): 32 MiB   [sectors 61374424-61439959]

ALL DATA ON THIS DISK WILL BE DESTROYED. Continue? (y/N): y

Working directory: /tmp/ventoy-macos-v72kack4
Fetching latest Ventoy version from GitHub...
Ventoy version: 1.1.12
Downloading https://github.com/ventoy/Ventoy/releases/download/v1.1.12/ventoy-1.1.12-linux.tar.gz ...
Downloaded ventoy-1.1.12-linux.tar.gz (19.4 MB)
Extracting Ventoy package...
Decompressing core.img...
RV??9^??f?-????|?tFf?f?Mf1??9?)ff?U??Df?f?L
                                           ?DpP?D?B????p?ff?Ef	???f?f1?f?4?T
f1?f?t?T
        ?D
        ...
        ...
                                                                                         ??E??bG?{>A|?>???0'????'??4??;{j W?wQ?Decompressing ventoy.disk.img...
^[[?1;2c^[[?1;2c^[[?1;2c^[[?1;2c
Boot images ready:
  boot.img:         512 bytes
  core.img:         1048064 bytes
  ventoy.disk.img:  33554432 bytes

Building GPT partition table...

Ready to write. This is your last chance to abort. Proceed? (y/N): y
Aborted.

@JvmName

JvmName commented Jun 25, 2026

Copy link
Copy Markdown

same as the above people, there's a bug in the xzcat line. If you remove it like this:

diff --git a/ventoy-macos-install.py b/ventoy-macos-install.py
index fd7c434..1186f85 100644
--- a/ventoy-macos-install.py
+++ b/ventoy-macos-install.py
@@ -121,7 +121,6 @@ def decompress_images(ventoy_dir, workdir):
 
     if not os.path.exists(core_img):
         print("Decompressing core.img...")
-        run(["xzcat", core_xz], check=False, capture=False)
         with open(core_img, "wb") as out:
             result = subprocess.run(["xzcat", core_xz], stdout=out, timeout=30)
             if result.returncode != 0:

everything works as expected.

@rgvxsthi

rgvxsthi commented Jun 25, 2026

Copy link
Copy Markdown

@JvmName - thanks - I believe the OP is not active any more. @mamitry - @JvmName's fix should probably work for you.

Also, there is this new GUI project which actually does work - https://github.com/cashcon57/mactoy

@JvmName

JvmName commented Jun 25, 2026

Copy link
Copy Markdown

Also, there is this new project which actually does work - github.com/cashcon57/mactoy

Yeah I saw that repo, but it's clearly mostly AI-coded (even the readme is written by AI), and I prefer not to install random vibecoded apps on my machine and then give them root access.

@VladimirMakaev

Copy link
Copy Markdown
Author

I've updated this script with proposed fix thanks for that !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment