A boot manager for your iPad. Bootbox is a native iOS app that boots real operating systems — 64-bit Linux (x86-64 and ARM64), full Android, classic Windows, graphical desktops — inside a WebKit-hosted emulation stack, with real internet, real multi-core, a built-in file browser, guest files in the iOS Files app, and one-tap downloads for every guest image.
Everything runs on-device. No servers, no streaming, no jailbreak.
┌────────────────────────────────────────────────────────────┐
│ iPad (iOS) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Bootbox.app (Swift) │ │
│ │ HostView ── WKWebView (cross-origin isolated) │ │
│ │ LocalServer · in-app HTTP w/ COOP/COEP + gzip │ │
│ │ Iosnet.xcframework · gVisor netstack → real TCP/IP │ │
│ │ BinaryBridge/Downloader · guest images on demand │ │
│ │ FileProviderExtension · guest files in Files.app │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ web/ — the "firmware" UI (boot menu + UEFI │ │ │
│ │ │ setup) and the engines: │ │ │
│ │ │ · QEMU-Wasm x86-64 (SMP, ACPI, virtio, 9p) │ │ │
│ │ │ · QEMU-Wasm aarch64 (real ARM64, dual-core) │ │ │
│ │ │ · v86 (fast 32-bit x86: Win98/2000, i686) │ │ │
│ │ │ · Boxedwine (Wine in the browser) │ │ │
│ │ │ · noVNC (Linux desktop + direct Android UI) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
| System | Arch | Cores | Highlights | Download |
|---|---|---|---|---|
| 64-bit Linux + Python & Wine | x86-64 | 1–8 (default 2) | Alpine · Python 3.12 + pip · real internet · file browser · mc · wine-staging 11.5, esync on by default, warm prefix |
~310 MB, once |
| 64-bit Linux — Desktop | x86-64 | 1–8 (default 2) | Openbox desktop · app icons on the wallpaper · Dillo browser · PCManFM · Claude Code CLI · terminal + mc + links |
~240 MB, once |
| 64-bit Linux — ARM64 | aarch64 | 2 | Genuine ARM64 Alpine (uname -m = aarch64) · Python · internet · up to 1.5 GB RAM |
~61 MB, once |
| Android 12 AOSP | x86-64 | 1–4 (default 4) | Full Android framework + ART · System UI · Launcher3 · touch/keyboard · writable 2 GiB /data · direct framebuffer VNC |
~591 MB, once |
| Windows 98 SE | i686 (v86) | 1 | Boots to the desktop | ~89 MB, once |
| Windows 2000 Pro | i686 (v86) | 1 | Working internet (NT NDIS via the relay) | ~340 MB, once |
| Your own images | i686 (v86) | 1 | Import .iso/.img via the Files app; paired save-states resume in ~1 s |
— |
The app itself is ~68 MB. Guest images are not bundled — each system downloads once from GitHub Releases and is cached (delete it in the Files app to reclaim space). The 64-bit engines ship inside the app, but the multi-hundred-MB rootfs images (and the ARM64 engine, which few users boot) are fetched on demand.
The x86-64 console guest boots dual-core by default (nproc = 2) and scales to 8.
This is genuine parallelism, not a cosmetic core count: each vCPU is its own WebAssembly
worker running MTTCG, and a direct measurement (four CPU spinners for 8 s wall-clock,
integrating /proc/stat) shows the guest accruing ~4 cores' worth of CPU-time at
-smp 4 — i.e. ~4× throughput on parallel work (builds, numpy, compiles). A busy
program no longer freezes the shell.
Pick the count in the emulator toolbar (also in UEFI Setup → Advanced). Because a live WebAssembly engine cannot be re-instantiated in place (its pthread workers and shared memory are one-shot), changing cores or RAM cleanly reloads the page and auto-resumes the guest with the new setting — so the selector always takes effect (an earlier build could get "stuck" at the first boot's core count). More cores run genuinely faster and hotter under load, since each core is a full software-emulated CPU — the selector is an honest speed/heat knob: 1 = coolest, 2 = balanced, 4–8 for heavy parallel compute.
Every guest instruction is software-translated by WebKit's WASM JIT — there's no Hypervisor.framework on iOS, so the translation is the heat. Bootbox attacks it three ways:
- Idle busy-poll → futex sleep (engine). Emscripten's
poll()returns immediately regardless of timeout, so QEMU'sqemu_poll_ns()degenerated into a busy-spin — ~1.2 host cores at an idle shell prompt. The patched engine polls non-blocking and sleeps the remaining timeout on a real zero-CPU futex in adaptive 1→8 ms slices. Idle host CPU ~120% → ~32%, and boot/parallel workloads got faster (the spin had been starving the vCPU threads). - Tickless guest kernels. The guest kernels are built
NO_HZ_IDLE+HIGH_RES_TIMERS, so an idle CPU stops taking the 100 Hz scheduler tick — under emulation each avoided tick is an emulated LAPIC IRQ + TB execution + a worker wake-up saved. Combined with the futex engine, idle drops to ~3–6% host CPU. - Stripped engine binaries (
-g). The shipped engines were compiled with-g, which embedded full DWARF debug info — that was the bulk of the binary. Dropping it for release cut x86-64 41.2 MB → 11.4 MB and aarch64 55.7 MB → 15.5 MB (−72%) with byte-identical codegen (same-O3) — so zero runtime-speed cost, just far less to fetch, parse, instantiate and keep resident (~30 MB less memory per engine), and lower WebKit JIT pressure. Cooler startup, cooler idle, a smaller app.
Under sustained heavy load, heat is N_cores × cost-per-instruction × duty-cycle — you
can't make an emulated instruction cheap, so the honest levers are fewer cores / less
duty-cycle (hence the cores knob above). The three wins here cut everything around the
core emulation: idle, startup, memory, and the engine tax.
The Android option is a real 64-bit x86-64 AOSP userspace, not the older JavaScript
APK compatibility harness. Android init, Binder/HwBinder/VndBinder, ART, Package Manager,
System UI and Launcher3 all run inside Bootbox's full-system QEMU-Wasm VM. A real ext4
virtio userdata disk supplies Android's required security xattrs, so /data behaves like
an Android device rather than a temporary browser filesystem.
ReDroid's compositor publishes a linear software framebuffer to a small Android-side VNC bridge. The bridge maps the buffer directly, sends ZRLE-compressed updates to noVNC, and injects browser touch/keyboard events through a virtual Android input device. That avoids running an X11 desktop or repeatedly copying frames through EGL, which keeps the UI usable under software CPU emulation.
This is AOSP, so Google Play, Google Mobile Services and Play certification are not included. The guest is software-emulated because Apple exposes Hypervisor.framework and Virtualization.framework on macOS, not iPadOS; performance is therefore suitable for the Android compatibility testing rather than graphics-heavy games. Cold boot under browser WebAssembly is currently a development limitation: the clean Mac validation reached VNC in about 251 seconds but System UI had not drawn after ten minutes. The image downloads once and stays outside the app bundle.
pip installis backed by uv (a static Rust resolver — no slow Python import tree)- ~24 common pure-Python packages pre-installed (
flask,requests,jinja2, …) - 10-minute download timeouts baked in — large wheels (scipy is 35 MB) download fine over the emulated NIC
pip install numpyjust works: a baked-in constraints file resolves to the NumPy 1.x line, whose wheels do runtime CPU-feature dispatch. (NumPy 2.x wheels are compiled for x86-64-v2 and crash on the emulated CPU — and raising the CPUID is unsafe: the wasm TCG backend mis-executes SSE4-era instructions.) scipy/pandas/matplotlib/pillow ship Alpine (musllinux) wheels; for scikit-learn useapk add py3-scikit-learn.- Common TUI/file tools are pre-installed (
ncdu,htop,tree,tmux,nano,less).
The 64-bit console has a 📁 Files tab — an iOS-Files-style, point-and-tap browser of the
live guest filesystem (it drives ls/cat/cp/rm over the serial pty, no extra daemon).
Browse folders, view text files, ⬇ save a guest file to the iPad (Files app → Bootbox),
⬆ copy an iPad file into the guest (via a shared 9p folder), and delete. Hidden dotfiles
are shown. The first listing right after boot is slow only because the emulated CPU is still
finishing boot; once settled a listing is <0.5 s.
Classic Windows (98/2000) boots natively in v86, and 32-bit .exe run in BoxedWine
(import an .exe → Compatibility Center → 🍷 Run with Wine → the window renders via noVNC).
Wine in the 64-bit guest (experimental, v2): the first attempt (Wine 9.0) deadlocked on-device
on RtlpWaitForCriticalSection — the loader lock — even with the device services disabled. Two
root causes were identified and both are attacked in the current build:
- Engine:
qemu64advertises CX16, so Windows/Wine x64 code useslock cmpxchg16bfor its lock-free SLISTs (loader queues, heap lookasides) — and the Linux kernel's SLUB allocator uses it on every alloc/free. wasm32 has no 128-bit atomics, so each one used to freeze all vCPUs (a stop-the-world exclusive section). The engine now emulates 16-byte CAS behind a host spinlock (with hardware-faithful CAS-read semantics), speeding up every x64 guest, not just Wine (⚛️ cx16 probein the console measures it). - Wine: upgraded to wine-staging 11.5 (two major loader rewrites past 9.0) with esync
(
WINEESYNC=1) — sync objects become kernel eventfds, bypassing the wineserver round-trips the old deadlock lived in.
Use the 🍷 Wine test console button: it runs the cx16 probe, then winemine under esync, then
plain — with full WINEDEBUG logs in /root/winetest-*.log. A pass leaves the window visible in
the 🖥️ GUI tab. (The GUI pipeline itself was never the problem — X apps render fine via Xvnc→noVNC.)
For production Windows software today, still prefer v86 (98/2000) or BoxedWine (32-bit
.exe); for Python tools, run them natively in the Linux guest.
An iSH-style File Provider exposes the Bootbox folder in the iOS Files app — drop
.img/.iso/.exe files in, pull guest exports out. It deliberately uses the classic
(non-replicated) NSFileProviderExtension: the modern replicated API runs iOS's cloud-sync
engine, which permanently shows "Syncing Paused" for a purely-local provider. Materialized
files are hard links — zero copies even for multi-GB disk images.
Iosnet.xcframework embeds a gVisor userspace TCP/IP stack (from container2wasm's
c2w-net): the guest's virtio NIC frames travel over a local WebSocket into native
sockets. pip, apk, wget, DNS — everything works, fully on-device.
GRUB-style boot menu with per-system notes → classic Aptio-blue UEFI Setup
(functional RAM / CPU-cores / boot-order / network toggles, plus some loving theater) →
serial console on the left, GUI on the right. The terminal fills its pane, taps focus it,
keyboard routing follows your last tap, and full-screen TUIs (ncdu, mc, vi) get the
right arrow-key sequences (application-cursor mode). A 🔧 File-Sync diagnostics panel is
built into the boot menu.
Bootbox.xcodeproj/ Xcode project (app + FileProvider extension targets)
Bootbox/ Swift host app
HostView.swift WKWebView shell (cross-origin isolated)
LocalServer.swift in-app HTTP server: COOP/COEP headers, gzip-on-the-fly,
size-tagged download cache for guest images
BackgroundKeepAlive.swift keeps the VM alive briefly in background, then lets iOS
suspend (timed release — battery)
Bridge/ native bridges: Downloader, BinaryBridge (import/gunzip),
DomainRegistrar (File Provider), System/Media/Clipboard…
web/ the whole "firmware" UI + engines (served by LocalServer)
js/boot.js power-on → POST → boot manager (+ reboot-to-apply resume)
js/apps/biosmenu.js boot menu + UEFI setup
js/apps/emulator.js guest orchestration, toolbar (RAM/cores), download-on-demand
vendor/qemu/ QEMU-Wasm glue: run.js (boot, RAM/-smp overrides, noVNC
pre-warm), panel.js (terminal/GUI/Files panes)
vendor/qemu-aload/ x86-64 engine (patched QEMU-Wasm, -g-stripped) + loader
vendor/qemu-aarch64/ ARM64 engine loader (engine wasm + rootfs download on demand)
vendor/qemu-desktop/ desktop-guest loader (shares the x86-64 engine binary)
vendor/qemu-android/ Android guest loader + QEMU arguments
vendor/qemu-android-engine/ Android-only 3 GiB QEMU-Wasm engine
vendor/v86/ v86 (32-bit x86 in wasm)
vendor/boxedwine/ Boxedwine
vendor/novnc/ noVNC (guest GUI)
FileProvider/ Files-app integration (classic NSFileProviderExtension)
Frameworks/Iosnet.xcframework gVisor netstack (Go, via gomobile)
ubuntu-build/ Dockerfiles for the guest rootfs images (see below)
docs/ historical design docs (the MiniOS web-shell era)
tools/, build.sh misc build helpers
Requirements: Xcode 16+, an Apple developer team for device installs.
open Bootbox.xcodeproj # build & run the "Bootbox" scheme on an iPadCLI (unsigned build):
xcodebuild -project Bootbox.xcodeproj -scheme Bootbox -configuration Release \
-destination 'generic/platform=iOS' build CODE_SIGNING_ALLOWED=NOSpeed note: WKWebView JITs WebAssembly under normal App Store rules, so emulation is fast without sideloading; the in-app benchmark badge shows your device's tier.
Guest rootfs/kernel/engine packs are produced by
container2wasm from ordinary Docker
images. The Dockerfiles live in ubuntu-build/:
| Dockerfile | Produces | Used by |
|---|---|---|
Dockerfile.base → Dockerfile.pw |
Alpine + Python 3.12 + pip/uv + Wine + Xvnc + TUI tools (+ constraints, timeouts) | x86-64 console guest |
Dockerfile.desktop |
Alpine + twm/tint2 desktop + links + mc | x86-64 desktop guest |
Dockerfile.arm64 |
aarch64 Alpine + Python | ARM64 guest |
Dockerfile.android64 |
Android 12 x86-64 + direct-framebuffer VNC/input bridge | Android guest |
The Android build additionally applies
ubuntu-build/android64-container2wasm.patch
to the lowpower container2wasm branch. It adds Android kernel/Binder support, a SquashFS
guest root, and a writable ext4 userdata virtio disk. Exact build and artifact details are
in ubuntu-build/README.android64.md.
Two small public forks carry the fixes that make multi-core, low-power and small binaries possible (single-purpose branches over the upstream pins — diff them to see exactly what changed):
- yu314-coder/container2wasm, branch
lowpower— (a) enablesCONFIG_ACPIin the guest kernel so CPU discovery uses QEMU's MADT table (SMP), (b) builds the kernel tickless (NO_HZ_IDLE+HIGH_RES_TIMERS), and (c) drops-gfrom the QEMU/emscripten build flags for a release-size engine. - yu314-coder/qemu-wasm, branch
idle-sleep— replaces the event-loop busy-poll with adaptive futex sleeps.
Example (x86-64 console pack) — note --dockerfile selects the fork's -g-stripped build
recipe instead of c2w's embedded default:
docker buildx build --platform linux/amd64 -t alpine-pw:latest \
-f ubuntu-build/Dockerfile.pw ubuntu-build
c2w --to-js \
--dockerfile /path/to/container2wasm-fork/Dockerfile \
--build-arg SOURCE_REPO=https://github.com/yu314-coder/container2wasm \
--build-arg SOURCE_REPO_VERSION=lowpower \
--build-arg QEMU_REPO=https://github.com/yu314-coder/qemu-wasm \
--build-arg QEMU_REPO_VERSION=idle-sleep \
alpine-pw:latest ./out/ # add --target-arch=aarch64 for the ARM64 packThe output pack is stripped of its dead migration snapshot, gzipped, uploaded to a GitHub
Release, and referenced by a size-tagged filename (qemu64-rootfs-<bytes>.data.gz,
qemu-aarch64-engine-<bytes>.wasm.gz, …) in emulator.js + LocalServer.swift. The size
tag is the cache-buster: change the image → the tag changes → devices re-download exactly
once. (The x86-64 engine binary ships in the app; the ARM64 engine and all rootfs images
are download-on-demand.)
Six of the problems this app had to solve are unusual enough to write down:
1. Why -smp 2 used to boot with one CPU.
QEMU parsed it, created both vCPUs, both threads ran, the second CPU even answered the
BIOS's wake-up call — and Linux still saw one CPU. The culprit: SeaBIOS counts responding
CPUs into a bitmap written by the application processor's thread, and under the wasm
engine's memory model that write wasn't visible to the boot CPU when it built the MP
table, so the table listed one processor. Fix: enable ACPI in the guest kernel — QEMU
builds the MADT itself (single-threaded, lists every CPU), the kernel does its own
INIT/SIPI bring-up, and every core comes online.
2. Why the core count could get "stuck."
Selecting more cores wrote the setting and re-launched the guest — but a running
WebAssembly engine can't be re-instantiated in the same page realm (its emscripten pthread
workers and SharedArrayBuffer memory are one-shot), so the loader silently reused the live
module and kept the first boot's -smp. Fix: a 64-bit reboot now stashes the target
guest + cores + RAM in sessionStorage and reloads the page; the firmware boot path
(boot.js) picks that up and auto-resumes the guest in a fresh realm with the new setting.
3. Why the emulator burned a core doing nothing.
Emscripten's poll() returns immediately regardless of timeout, so QEMU's
qemu_poll_ns() degenerated into a busy-spin — ~1.2 host cores at an idle shell prompt.
The idle-sleep engine patch polls non-blocking and sleeps the remaining timeout on a
real futex in adaptive 1→8 ms slices; the tickless guest kernel then stops even the idle
scheduler tick. Idle host CPU dropped from ~120% to ~3–6%, and boot/parallel workloads got
faster — the spin had been starving the vCPU threads all along.
4. Why the shipped engines were 3–4× too big.
The QEMU/emscripten builds passed -g, which retained full DWARF debug sections in the
production .wasm (and extra debug metadata in the JS glue) — the majority of the binary.
Dropping -g for release cut the x86-64 engine 41.2 → 11.4 MB and the aarch64 engine
55.7 → 15.5 MB with byte-identical codegen (-O3 unchanged), so no runtime-speed cost —
purely faster download/parse/instantiate, less resident memory, and lower JIT pressure.
c2w embeds its own Dockerfile, so the strip only takes effect when the build passes
--dockerfile pointing at the fork's recipe.
5. Why the ARM64 guest crashed at 1.5 GB RAM.
The aarch64 engine has a fixed 2300 MB WebAssembly heap. Booting with -m 1536 and the
inherited tb-size=500 (a 500 MB translation-block buffer) plus the virt machine's edk2
firmware overflowed it — "memory access out of bounds" the moment QEMU started, before the
guest kernel ran. (This was a latent crash in the shipped 1.5 GB ARM config too.) Fix:
lower the ARM tb-size to 192 (matching x86-64), freeing ~308 MB — now it boots at 1536 MB.
6. Why pip install numpy needs NumPy 1.x here.
NumPy 2.x musllinux wheels are compiled for the x86-64-v2 baseline (SSSE3/SSE4/POPCNT).
The emulated CPU doesn't advertise those — and it must not: advertising them makes guest
code take SIMD paths that the wasm TCG backend mis-executes (silently wrong results, Go
runtime crashes). NumPy 1.x wheels detect CPU features at runtime and run correctly.
A baked pip/uv constraints file pins the working line so a plain pip install numpy
does the right thing.
Bootbox stands on excellent open source:
- v86 — x86 virtualization in WebAssembly (BSD-2-Clause)
- QEMU (GPL-2.0) via ktock/qemu-wasm — QEMU compiled to WebAssembly
- container2wasm (Apache-2.0) —
container → wasm packaging, guest init, and the
c2w-net/gVisor networking approach - noVNC (MPL-2.0) — the guest GUI viewer
- ReDroid — Android-in-container userspace
- LibVNCServer (GPL-2.0+) — Android framebuffer/input bridge
- Boxedwine (GPL-2.0) — Wine in the browser
- xterm.js (MIT) + xterm-pty — the serial console
- iSH — whose classic File Provider implementation showed the way past "Syncing Paused"
Modified engine sources are published in the two forks above, per the GPL. Guest Linux images are built from Alpine Linux packages; classic-Windows images are user-supplied.
App source (Swift + web/js): MIT. Vendored engines keep their own licenses (see Credits).