wasmbox
A window manager, in your browser, written in Ruby.
A Wayland-inspired compositor with an Openbox-style window-manager policy —
pure Ruby, running in WebAssembly, driving real external clients, no server.
wasmbox is a self-contained browser desktop: a Wayland-inspired compositor
with an Openbox-style window-manager policy (Aqua-style + others selectable
at boot), written entirely in pure Ruby and rendered to a <canvas> through
the interpreter's interactive JS bridge. The Ruby program owns the desktop — it
composites window surfaces, maintains the stacking order, applies focus and
placement policy, draws decorations, runs a desktop-environment shell (dock,
tray, notifications, applets, wallpaper, Spotlight, Exposé) and drives its own
requestAnimationFrame render loop.
The compositor runs in one WebAssembly instance of the pure-Go (CGO=0)
rbgo interpreter, with the Ruby
source (split across compositor/*.rb) baked into the wasm binary via
//go:embed — there is no server-side application logic and nothing of the
compositor is fetched at runtime; the page is just a loader. It builds on
rbgo's JS bridge (a JS module plus JS::Ref handles that let Ruby reach
the DOM and the Canvas 2D context, register event listeners, and schedule
animation frames), and renders its widgets with the pure-Go
go-widgets/toolkit (AA-rendered,
OpenType-shaped text) bound into Ruby.
Windows are no longer all the same program: external clients each run in their
own Web Worker / WASM instance and paint into a SharedArrayBuffer, speaking a
documented wire protocol to the compositor (see
docs/protocol.md). The single-instance demo was "step A";
the code today is step C.1 — the compositor itself lives in a dedicated
worker rendering into an OffscreenCanvas, and each client talks to it over a
direct MessageChannel.
Live demo
https://wasmdesk.github.io/wasmbox/ — the desktop, served from GitHub Pages
(cross-origin isolation is earned client-side via coi-serviceworker.js, so the
SharedArrayBuffer surfaces work without special server headers).
Boot experience
The page shows a determinate boot splash from the very first paint: the
wasmbox.wasm binary is fetched with a streaming reader that drives a real
progress bar (bytes received vs Content-Length), then the bar completes as the
Go runtime instantiates and the embedded Ruby compositor starts. The splash then
fades and is removed from the DOM (no lingering timer/rAF). If boot fails, an
error surface replaces it.
Desktop environment
Beyond bare window management, the compositor ships a small DE shell, all in Ruby:
- Dock — the
wasmdock client:
bottom-anchored, magnifying, with running/active indicators; launches apps
through the launch protocol message.
- System tray / status strip, desktop notification toasts (icon +
multi-line + multi-action, from the go-widgets v0.86 refinements), and a
big clock.
- Desktop applets behind the windows — live clock, a real Calendar
applet, and monitor tiles.
- Selectable wallpaper (gradients + a bundled image), persisted to
localStorage.
- Window snapping / half-tiling (
Super+arrow, or drag to a screen edge).
- Visual Alt-Tab switcher and Exposé (
F3 "show all windows"), both with
live per-window framebuffer thumbnails.
- Spotlight launcher (
⌘/Ctrl+Space) — a centered fuzzy-search command
palette over the launchable apps.
Clients
Clients live under clients/ and are built for js/wasm. Most are pure-Go
toolkit consumers; one is authored entirely in Ruby:
| Client |
What it is |
hello |
reference minimal external client (the protocol smoke test) |
dock |
the wasmdock dock, built from the sibling repo |
terminal, files |
placeholder app windows with distinct surfaces/titles |
code |
a VS Code-styled editor shell |
showcase |
a 7-tab Notebook exercising every toolkit widget family |
calculator |
Entry + 5×4 Button grid — a small focused toolkit consumer |
notepad |
multi-doc TextView editor |
settings |
a WhiteSur (macOS Big Sur) preferences panel (Switch/Scale rows) |
browser |
a WhiteSur/Safari-styled browser shell (see note below) |
rubytk |
a Tip Calculator whose whole UI + logic are Ruby via require "widgets" |
quake |
pure-Go Quake 1 from the sibling go-quake1 repo, streaming its assets from an OCI registry |
doom |
pure-Go Doom from the sibling go-doom repo (software renderer, 4:3-locked window), streaming the Freedoom IWAD (BSD-3-Clause, sha256-pinned) from the same-origin /v2/doom-assets mirror |
Doom
Applications → Doom in the root menu. The client (go-doom/engine
cmd/doom-wasmbox, built by the Pages workflow or task build:doom) drives the
protocol from Go, like Quake. It presents Doom's 320×200 frame at 640×480 with a
4:3 aspect lock and takes the classic keys: arrows to move, Ctrl to fire, Space to
use, Shift to run, Alt or , . to strafe, Escape for the menu, Enter, and digits
for weapons. The left mouse button fires and the right one uses. The client sets
the new hello field inhibit_shortcuts (the analog of Wayland's
keyboard-shortcuts inhibitor, see docs/protocol.md), so
while Doom has the keyboard, Ctrl+Alt+arrow, Ctrl+Space, Ctrl/Alt+Tab and F3 go
to the game instead of snapping the window or opening Spotlight, the switcher or
Exposé. Click another window or the dock to leave.
The game data is Freedoom: Phase 1 0.13.0, which is never committed. The
workflows download the official release, check the zip against the sha256 in
Freedoom's signed CHECKSUM file, check the extracted freedoom1.wad against
the digest the client pins, then pack it with packs/doom-assets.toml into
/v2/doom-assets. The client fetches only from its own origin, recomputes the
digest, and bounds the WAD's lump directory before the engine reads it. Sound is
wired, but Chromium exposes no AudioContext to a worker, so Doom is silent there
(DOOM: ... audio=unavailable in the console). test/probe-doom-smoke.mjs is the
cross-browser check: it boots the client, opens the menu with Escape, starts E1M1
and walks, asserting on the client's DOOM: state=... log lines and on frames
read inside the compositor worker.
Clients are spawned two ways: from a static worker URL (the compositor's own
asset tree) or pulled at runtime as an OCI artifact via
ociapps (?ociapps=1). The set of
launchable ids is a compositor-owned trust boundary (LAUNCHABLE in
compositor/04_window_manager.rb): a launch message carries only an opaque id,
never a path or command line.
Streaming browser (in review). A new clients/browser variant renders
real web pages by proxying them through the
go-webengine/browserproxy (a WebSocket
feeds rendered frames the client blits into its surface, behind a go-widgets
loading skeleton). It is proposed in PR #93 and depends on the user hosting
the proxy, so it is not yet on main; the bundled browser here is the
offline WhiteSur/Safari shell (the page is COEP: require-corp, so it cannot
embed live cross-origin sites directly — tiles open local placeholder pages).
Pick a window decoration
Wasmbox ships 16 registered decoration presets (2 layouts × 7 GTK palettes,
plus the 2 plain layouts). Pick one via a URL query param:
http://localhost:8080/ # OpenboxFrame (default)
http://localhost:8080/?frame=aqua # AquaFrame — subsumes wasmaqua
http://localhost:8080/?frame=aqua-whitesur-light # Aqua + WhiteSur ≈ macOS Big Sur
http://localhost:8080/?frame=aqua-whitesur-dark # ≈ Big Sur dark
http://localhost:8080/?frame=openbox-juno # Openbox + Juno teal palette
http://localhost:8080/?frame=aqua-adwaita-{light,dark}
http://localhost:8080/?frame=openbox-solarized-{light,dark}
Or run wasmbox-serve -default-frame=NAME to make a whole instance default to
one preset (bare / 307-redirects to /?frame=NAME). The registry lives in
compositor/02_frame.rb — adding a new frame is one
entry in FrameRegistry::TABLE. You can also switch at runtime from the
root-menu Frame submenu.
The sibling wasmdesk/wasmaqua repo is
archived as of 2026-06-30 — its macOS-Aqua chrome is now the AquaFrame
preset here.
Architecture
- Threading (step C / C.1). The compositor runs in a dedicated Web
Worker, rendering into an
OffscreenCanvas transferred from the page. The
main thread is a thin shell — it owns the <canvas> element, relays DOM
input to the compositor worker, and mirrors localStorage; it runs no
Ruby/wasm. Each external client runs in its own worker and talks to the
compositor over a direct MessageChannel (port2 handed to the client at
spawn), not relayed through the main thread. See
docs/protocol.md for the wire contract.
Compositor — holds the canvas, fits it to the viewport, installs input
handlers, and drives a JS.raf render loop. A frame-level dirty gate skips
the full-frame recomposite when nothing changed, so an idle desktop costs
~0 CPU (this fixed a 100%-CPU idle regression in Firefox). A small HUD shows the
window count, a smoothed FPS reading and a frame counter.
WindowManager — owns the stacking order (last element topmost), a
click-to-focus / raise-on-focus policy, cascade placement, workspaces,
minimize-into-dock, the launch trust boundary, and the focus cycle.
Window — a client surface plus its decoration geometry, with roles
(window / panel / popup), hit-testing helpers, and per-frame damage.
The window-management logic (geometry, hit-testing, stacking, placement, focus)
is plain Ruby with no reference to JS, so it is unit-tested natively; only the
rendering methods touch the bridge.
Wasm size
The compositor wasm is built with -trimpath -ldflags '-s -w', and the
js/wasm build drops the network backends the browser cannot use anyway. That
cut the binary from ~175 MB to ~86 MB (about 18 MB gzipped over the
wire), which is what the streaming boot splash downloads.
Build & serve
Uses Task:
task # list the available tasks
task build # build wasmbox.wasm (embeds compositor/*.rb) + every client + wasm_exec.js
task serve # build, then serve http://localhost:8080/ with COOP/COEP headers
task test # native Go + Ruby unit tests (no browser)
task clean # remove the build artifacts
wasmbox is a Go module that imports the interpreter, so task build is a plain
go build for the js/wasm target (GOOS=js GOARCH=wasm CGO_ENABLED=0) — Go
fetches the interpreter as a dependency; there is no separate checkout to clone.
External clients built from sibling repos (the dock, Quake, Doom) resolve those
repos via DOCK_DIR / ENGINE_DIR / DOOM_DIR (see the Taskfile).
The SharedArrayBuffer client surfaces require cross-origin isolation
(COOP/COEP), which python3 -m http.server does not set — that is why the demo
ships its own dev server, cmd/serve (task serve). For the GitHub-Pages model
(no server headers), task serve:pages assembles the site and relies on
coi-serviceworker.js to supply isolation, reproducing the real deploy locally.
The ?ociapps=1 flow (task demo-wasmbox-ociapps) streams every client from a
local OCI registry instead of the static tree.
Then open http://localhost:8080/. The page instantiates wasmbox.wasm, the Go
runtime runs the embedded compositor, and the canvas takes over once ready. Any
static server that serves .wasm as application/wasm with COOP/COEP works.
Controls
- Drag a titlebar — move a window. Drag the bottom-right corner — resize.
- Click a window — focus and raise it. × in the titlebar — close.
- Right-click the desktop — root menu (Applications submenu, Frame submenu,
wallpaper). Right-click a window — context menu.
- Tab — cycle focus; Alt+Tab — visual switcher; Esc — dismiss.
- Super+arrow / drag-to-edge — snap / half-tile. F3 — Exposé.
- ⌘/Ctrl+Space — Spotlight launcher.
Validation note
The in-browser rendering is validated with a headless browser (Playwright
driving Chrome): after boot the splash overlay is removed, the <canvas> is
fully painted (desktop + cascaded windows + DE shell), external clients spawn and
commit damage, an OCI-streamed client is pulled and rendered, and the console
reports the compositor started. The GOOS=js GOARCH=wasm binary build is also
checked in CI, along with the native Ruby/Go unit tests.
wasmdesk is a family for a WebAssembly desktop built on pure-Go Ruby. wasmbox
is its compositor + window manager;
wasmdock is the dock client; and
ociapps streams client apps from any OCI
registry.