D9010 VmApp architecture
For: anyone who wants to understand how D9010 VmApp works and why it is built this way. To build an app, read Building apps instead. To change the platform, read Contributing after this. The normative contract is Specification; this document explains it, and doesn't replace it.
1. The idea in one picture#
┌───────────────────────────── your Mac (host) ──────────────────────────────┐
│ │
│ D9010 VmApp Launcher (Electron) ── trusted, the same for every app │
│ ├─ Library, permission sheet, inspector (loader's own UI) │
│ ├─ App windows: sandboxed Chromium renderers (the app's UI: HTML/CSS/JS)│
│ │ no Node · no network · only window.vmapp │
│ └─ hostlib: packages, grants, one AppSession per running app │
│ │ spawns ▲ bridge (JSON lines, Unix sock)│
│ ▼ │ │
│ vmapp-vm (native, ~0.8 MB) ── one per running app ────────────────────┼──┐
│ │ Apple Virtualization.framework │ │
└───────────┼────────────────────────────────────────────────────────────────┘ │
▼ │
┌──────────────────── Linux VM (one per app, ~0.4 s to start) ─────────────┐ │
│ /init = vmapp-init │ │
│ / = shared runtime image (read-only): native | node22 | dotnet8 │ │
│ /app = the app's core files (read-only) │ │
│ /data = the app's private data ◄── host folder per app │ │
│ /host/f-<name>, pick-N, save-N ◄── only what the user granted │ │
│ │ │
│ the app's core (its "main process"), uid 1000 ◄──── vsock ───────────────┼─┘
│ no network device (granted hosts only, via the proxy: see below) │
└──────────────────────────────────────────────────────────────────────────┘
An app is split exactly where Electron splits it:
- The UI (renderer) runs on the host, so it gets native rendering, fonts, input, accessibility and HiDPI.
- The core (main process) runs in a VM, so everything powerful it does (files, processes, CPU) is confined.
The only connection between the two is a message bridge that passes through the loader.
2. Layers, from the bottom up#
| # | Layer | Component | Trusted? | Where |
|---|---|---|---|---|
| 1 | Hypervisor | Apple Virtualization.framework | yes (OS) | macOS |
| 2 | VM launcher | vmapp-vm (Rust + Objective-C shim) |
yes (platform) | host, one process per app |
| 3 | Kernel + init | Linux 6.18 + vmapp-init (Rust, static, ~530 KB) |
yes (platform) | guest |
| 4 | Runtime | native / node22 / dotnet8 read-only images |
yes (platform; pinned, reproducible) | guest root |
| 5 | App core | the app's code, any language | no | guest, uid 1000 |
| 6 | Host library | hostlib (packages, store, sessions, bridge client) |
yes (platform) | host, inside the loader |
| 7 | Loader | Electron main + loader UI + dialogs | yes (platform) | host |
| 8 | App UI | the app's HTML/CSS/JS | no | host, sandboxed renderer |
The untrusted layers (5 and 8) never talk to each other directly, and never touch the host except through layers the platform owns.
3. What happens when you click Launch#
Loader hostlib/AppSession vmapp-vm VM: vmapp-init App core
│ Launch │ │ │ │
│──grants?─────────►│ (permission sheet if needed; saved in grants/<id>.json) │
│ │──spawn(args)──────────►│ │ │
│ │ │─boot kernel+initramfs─►│ mount runtime as /rt │
│ │ │ disks: runtime, core │ extract core.cpio→/app│
│ │ │ virtiofs: appdata,f-* │ mount /host,/data │
│ │ │◄──────hello (vsock 1023)│ │
│ │ │──start{command,env}────►│ fork→chroot→uid 1000 ─►│ exec
│ │◄─ready line {socket}───│ │ │ listen $VMAPP_SOCKET
│ │──connect bridge (Unix sock → vsock 1024 → core socket)──────────────────►│
│ │──init {app, folders, data}──────────────────────────────────────────────►│
│ │◄─ready──────────────────────────────────────────────────────────────────│
│ │◄─request window.create {entry:index.html}───────────────────────────────│
│◄─create window────│ │
│ vmapp-ui://<id>/index.html (hash-checked, CSP) │
│ │──reply {win:1}──────────────────────────────────────────────────────────►│
Measured on an M-series Mac: from spawn to the window's first message is under a second. A minimal app ran a full launch → window → calls → quit cycle in 0.64 s.
4. How a click in the UI reaches the core#
App UI (renderer) preload-app.js (loader) Loader main / AppSession App core (VM)
window.vmapp.invoke('notes:list')
│──ipc──────────────────►│──ipc────────────────────►│ (window → app lookup by webContents id)
│ │ │──{"t":"invoke","id":7,"win":1,"channel":"notes:list"}──►│ ipcMain.handle
│ │ │◄─{"t":"result","id":7,"result":[…]}──────────────────────│
│◄─Promise resolves──────│◄─────────────────────────│ (also shown in the bridge inspector)
The other directions:
window.vmapp.sendworks the same way, with no reply.webContents.sendin the core becomes{"t":"emit"}, which the loader delivers to that one window.- Requests from the core (
window.create,dialog.open, …) go to the loader. The loader enforces the manifest's permissions and shows native dialogs. If a dialog grants a folder, it tellsvmapp-vmto add a share to the running VM, and the folder appears as/host/pick-N.
5. Files and folders: how access is enforced#
| Inside the VM | Comes from | Access enforced by |
|---|---|---|
/ |
the runtime image (EROFS) | read-only block device |
/app |
core.cpio, extracted to tmpfs; or core.erofs, mounted as it is |
remounted read-only; or a read-only block device |
/data |
$VMAPP_HOME/data/<id>/ on the host |
per-app folder; nothing else is shared |
/host/f-<name> |
a folder the user chose for the manifest request <name> |
read-only / read-write by the hypervisor's share flag; write-only by a staging folder |
/host/pick-N |
a folder or file chosen in an open dialog | read-only by default; file picks are APFS clones, so siblings are never visible |
/host/save-N |
a save dialog's target | staging folder; the finished file is moved to the chosen path |
/tmp, /run |
memory | gone when the app stops |
Write-only never shares the destination. The core writes into an empty staging folder, and the host moves each
finished file out: a file is finished once it has stopped changing for about a second, or when it is renamed from
*.partial. The core can never see what the destination already contains.
The network (SPEC §8.3) follows the same idea: the host decides, and the VM has no device to go around it.
core ──HTTPS_PROXY──► 127.0.0.1:3128 (vmapp-init) ──vsock 1022: "open api.example.com:443?"──► vmapp-vm
▲ │ rules, DNS, address check,
└────────── vsock 1025: one per connection, raw bytes ◄──┘ connect to the checked address
- The core's HTTP client reaches a small HTTP proxy inside the VM through environment variables. For each connection the proxy asks the host over one vsock control connection, which the host opens only when the app has a grant.
vmapp-vmchecks the host and port against the user's grants, resolves the name itself, refuses it if any answer is a private or local address, connects, and opens a fresh vsock connection for the bytes. TLS passes through aCONNECTtunnel untouched.- It reports each connection (host, port, bytes, time) to the loader as a
netevent. Revoking a host closes its open connections immediately.
The GPU (SPEC §8.6) reuses that tunnel for one more thing, a host service. Linux has no Metal, so the VM is not given a GPU API; it is given llama.cpp's RPC protocol, forwarded to a Metal server on the Mac.
core ── 127.0.0.1:50052 ──► vmapp-init ──vsock 1022: "service-open gpu"──► vmapp-vm ──► 127.0.0.1:PORT ──► rpc-server (Metal)
($VMAPP_GPU_RPC) ◄── vsock 1025: raw bytes ◄──────────┘ (sandbox-exec, one per app run)
- Only an app that asked for
gpuand was allowed gets any of it. The loader (not the app, not the guest) starts the server, inside a deny-by-default macOS sandbox, on a loopback port it picked, and hands that endpoint tovmapp-vmon its command line. - The guest names a service;
vmapp-vmmaps the name to the one endpoint it was started with. The guest never supplies an address. - Everything stops together: the app, a revoke, the panic shortcut and the loader quitting end the server and its tunnels.
6. Lifecycle and process ownership#
- The loader owns every
AppSession, and each session owns exactly onevmapp-vmchild, which owns exactly one VM. - The core exits → the VM powers off →
vmapp-vmprintsstopped→ the session closes the app's windows. - The user presses Stop →
before-quitgoes to the core, which gets 3 s → shutdown → write-only files are delivered. - The loader quits → it stops every session.
- The loader crashes or is killed → each
vmapp-vmsees its parent die (kqueue, plus EOF on stdin) and shuts its VM down. A VM can't outlive the loader.
7. Why it is built this way: choices and rejected alternatives#
| Decision | Chosen | Rejected, and why |
|---|---|---|
| Where the UI renders | On the host, in Chromium | In the VM (X11/Wayland forwarding, virtio-gpu): weak GPU support on macOS VMs, HiDPI and resize issues, broken IME, clipboard, accessibility (screen readers), and every input path (keyboard, mouse, trackpad, touch, pen, scrolling devices) would have to be forwarded and emulated, higher latency. On the host, Chromium gets all of these from the OS, and the window is a web standard the launcher can be tested against over the Chrome DevTools Protocol. Possible later as a separate mode for legacy Linux GUI apps. |
| Isolation unit | One VM per app | Containers: they share one kernel, so an escape reaches every app. Docker Desktop also shares /Users into its VM. One shared VM: same problem. |
| Programming model | Electron's main/renderer split with an Electron-like SDK | A new UI toolkit: nobody would port apps to it. Remote desktop: see "UI renders" above. |
| What an app ships | Only its own code (core.cpio + UI) on shared runtimes |
A full image per app, like containers or agent-vm: MBs to GBs per app, every app rebuilds Linux, and slow updates. |
| Runtime images | Read-only EROFS, built reproducibly from pinned Docker images | Building root filesystems by hand: hard to keep current. Docker is a build-time tool only. |
| App core format | cpio extracted to tmpfs (default), or an EROFS image mounted as it is (core.image: "erofs", for large cores) |
The cpio costs the VM its size in memory and boot time, so it stays under 256 MiB. EROFS with erofs-utils would need Docker to pack; hostlib/erofs.js writes the image in plain JavaScript instead, so packing stays something anyone can run. |
| Package format | ustar tar + a strict manifest with a SHA-256 for every file | zip: more complex to read strictly. Signed bundles: signatures come in milestone 2, reserved in the manifest. |
| Bridge | JSON lines over one socket (vsock in the guest) | gRPC or protobuf: heavier, and harder to implement from any language with no dependencies. HTTP: needs a server in every core. |
| Permissions | Granted by the user at launch, plus dialog picks at run time (a "powerbox") | Granted by the vendor's code (Electron + containers): the vendor writes its own rules. |
| Network | None until the user allows named hosts (SPEC §8.3), through a host-enforced allowlist proxy; the VM never has a network device | Open by default: the exfiltration risk is too high. A NAT or bridged device: the guest could then reach the LAN and bypass any check. |
| VM size | The user chooses vCPUs and memory (default 1 vCPU and the recommended memory, capped at the Mac's cores and half its RAM, SPEC §8.5); the manifest only recommends | The manifest decides: any app could take most of the Mac. |
| Host side of the app | None: no vendor code on the host except UI in a sandboxed renderer | An app-provided preload or main process (plain Electron): gives the vendor full user rights. |
Why D9010 VmApp? compares D9010 VmApp with Electron and with Electron + container backends in detail, including where those are still better.
8. Component map (where the code is)#
| Concern | Code |
|---|---|
| Package read/write/verify, manifest rules | hostlib/package.js, hostlib/manifest.js, hostlib/tar.js, hostlib/cpio.js, hostlib/erofs.js |
| Installs, grants, data folders | hostlib/store.js |
| A running app (spawn, bridge, requests, permissions, traffic events) | hostlib/session.js, hostlib/bridge.js |
| The VM launcher, shares, live delivery, grants | host/src/lib.rs, host/src/cli.rs (command line and main loop, shared), host/src/bin/vmapp-vm.rs, host/src/shim/vz_shim.m |
| The experimental Vulkan backend (libkrun, sandboxed VMM, watchdog; SPEC 8.7) | host/src/krun.rs, host/src/bin/vmapp-vm-krun.rs, gpu/krun/ |
| The VM's init: mounts, extraction, start, bridge forwarding | guest/src/main.rs, guest/src/appcore.rs, guest/src/cpio.rs, guest/src/erofs.rs |
| Runtimes | runtimes/<name>/Dockerfile, tools/mkruntime |
| Electron-like SDK | sdk/node/vmapp.js |
Loader: windows, vmapp-ui:// protocol, dialogs, inspector |
loader/ |
| CLI | tools/vmapp |